npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@broberg/lens-engine

v0.9.1

Published

The shared Playwright capture + flow engine for the cardmem-lens fleet — capture(opts)→artifact and runFlow(opts)→report with self-healing locators (DOM layers + Set-of-Marks vision) and a frozen Zod flow grammar, plus token-frugal page-READ primitives (r

Readme

@broberg/lens-engine

The shared Playwright capture + flow engine for the cardmem-lens fleet. The hosted cloud Lens and the local daemon import this ONE engine, so the self-healing locators and the frozen /flow step grammar never drift between them.

npm i @broberg/lens-engine
npx playwright install chromium   # the engine launches a real browser at runtime

The three-package split

lens-engine is the heavy, Playwright-bearing one. Pick the right package:

| You need to… | Use | | --- | --- | | Mint / validate a Lens session (auth/compliance, no browser) | @broberg/lens (dep-free) | | Drive a real browser: capture + flow + self-healing locators | @broberg/lens-engine | | Call the hosted Lens over HTTP (no Playwright) | @broberg/lens-client |

Keeping them separate means an app that only mints a session never installs Chromium.

Usage

import { capture, runFlow } from "@broberg/lens-engine";

// Screenshot a page (viewport / fullPage / element)
const shot = await capture({ url: "https://example.com", mode: "fullPage" });
// → { png: Uint8Array, dom_hash, dims, title }

// Drive a multi-step flow with self-healing locators
const report = await runFlow({
  base_url: "https://appstoreconnect.apple.com",
  steps: [
    { action: "goto", url: "/apps" },
    { action: "click", target: { role: "button", name: "New Version" } },
    { action: "fill",  target: { label: "Version Number" }, value: "1.2.0" },
    { action: "upload", target: "screenshot-input", files: [{ name: "a.png", url: "https://r2/a.png" }] },
    { action: "expectVisible", target: "submit-btn" },
  ],
});

Auth-agnostic — storageState in, PNG bytes out

The engine never fetches a mint endpoint. To capture behind a login, the consumer supplies a storageState — either a resolved object or an async resolver — which the engine applies to a fresh browser context before navigation:

await capture({ url, storageState: myStorageStateObject });
await capture({ url, storageState: async () => await myMint() });   // resolver form

An optional consumer helper fetchStorageState({ adapter: "mintEndpoint", url, secret }) ships in the package for hosted services that want to turn a mint endpoint into a storageState — but the engine core never calls it. Storage, serving, and the Bearer-auth guard are the consumer's job; the engine returns PNG bytes and structured reports.

Self-healing locators

A step target is a plain string (CSS selector or a bare data-testid value) or a LocateSpec tried in fixed priority order — first unique, visible match wins:

testid → css → role → label → placeholder → text → vision

Priority holds even when the element has not rendered yet (0.8.1). While waiting, the layers are raced against one shared budget — but the layer that answers first is not necessarily the one you listed first, so the winner's timing is taken and the layer is then decided by priority. One element rendering makes all of its layers true at the same instant, and that ordinary case is now deterministic instead of depending on which locator answered a few milliseconds sooner.

What priority does not promise: a higher-priority layer that becomes present strictly later than a lower-priority one still loses. Waiting to find out whether testid eventually shows up would cost the full budget on every self-heal. Layers are meant to describe one element, so layers arriving at different times means they matched different elements — check the spec.

vision is the Set-of-Marks fallback (via @broberg/ai-sdk). It ships dark: visionEnabled() is false unless both LENS_VISION_ENABLED and a provider key (MISTRAL_API_KEY / OPENROUTER_API_KEY) are set. A vision-only DOM-miss fails cleanly — it never guesses.

The bare-string form is ambiguous, on purpose

A target string with no CSS punctuation is read as a data-testid value, not as a selector — so "save-button" becomes [data-testid="save-button"]. That convenience has one consequence worth knowing before it costs you an hour:

"save-button"  →  [data-testid="save-button"]     ← what you wanted
"#save"        →  #save                            ← untouched, it has punctuation
"body"         →  [data-testid="body"]             ← NOT the <body> element

A bare element name (body, main, form, h1, section, table, …) carries no punctuation either, so it takes the test-id reading too. This is not a bug that was fixed — it is a property of the shorthand, and it cannot be resolved by a smarter rule: main is as plausible a test id as it is a tag name, so any rule that got one right would get the other wrong.

Use the explicit forms when it matters{ css: "body" } for the element, { testid: "body" } for the test id. Both are unambiguous and neither is rewritten.

Since 0.7.1, when a rewritten element name resolves to nothing the failure says so, names the selector it used, and names the alternative — instead of a bare Timeout 30000ms exceeded about a locator you never wrote. The hint is attached only when the target really matched zero elements, so a genuine test-id miss and a slow-but-present element never collect it.

Flow step grammar (frozen, Zod-validated)

goto · click · fill · type · press · select · upload · waitFor · assert · expectText · expectVisible · expectEditable · screenshot. Reuse the exported Zod schemas (captureBodySchema, flowBodySchema, locateSpecSchema, uploadFileSchema, …) to validate at your own HTTP boundary. Every step also accepts an optional timeout_ms, and since v0.7.0 an unknown key is rejected rather than silently deleted — see below.

v0.9.0 — four verbs the engine could not express, and a label that lied

Additive: nothing existing changes. waitForUrl, expectAbsent, check and uncheck close the four genuine gaps between this engine's grammar and the cardmem daemon's — 248 of the 517 verb-occurrences across the fleet's flow runs that could not migrate.

{ "action": "waitForUrl",   "url": "/dashboard" }      // substring of the FULL url
{ "action": "expectAbsent", "target": "toast" }        // waits for it to be GONE
{ "action": "check",        "target": "agree" }        // idempotent: on -> stays on
{ "action": "uncheck",      "target": "newsletter" }   // idempotent: off -> stays off

Migrating a daemon flow — all four verbs at a glance

| daemon verb | engine step | what changes | |---|---|---| | waitForUrl | { action: "waitForUrl", url } | nothing in matching (substring, full URL, both sides). Default timeout 8000 ms → step/flow/30 s: slower to fail, never faster. Set timeout_ms to keep the old feel. | | expectAbsent | { action: "expectAbsent", target } | hidden still counts as absent and never-existed is still a pass. New: all layers of a LocateSpec must be gone, not just the first. No page-settle discount — a late expectAbsent costs one poll rather than nothing. | | check | { action: "check", target } | Nothing, measured. The daemon has driven a real locator.check() since its F074.23, and all 28 recorded calls passed under it. A <label> works (Playwright follows the label→control association); only a wrapper with no label semantics (<div>, <span>) throws. | | uncheck | { action: "uncheck", target } | same as check. | | clickSelector | { action: "click", target: "<css>" } | pure rename — a bare string target already is a CSS selector. | | fillSelector | { action: "fill", target, value } | pure rename. | | clear | { action: "fill", target, value: "" } | pure rename. | | uploadFile | { action: "upload", target, files } | pure rename. |

inspect, autocomplete, capture, drag, loop, conditional and waitForBuild stay daemon-only by decision — debugging and composite authoring sugar that belongs to an authoring surface, not to a frozen package grammar.

waitForUrl matches a SUBSTRING of the full URL — not a glob

That is a measurement, not a preference. Playwright's page.waitForURL() takes a glob by default, and waitForURL('/dashboard') does not match http://localhost:3000/dashboard without a configured baseURL — it hangs to the timeout. Run against every argument the fleet has actually sent — 38 distinct across 149 runs — they fall into four families:

path-like, with a slash   /app 51 · /dashboard 7 · / 6 · /tak 4 · /platform 4
bare host or fragment     broberg.ai 6 · wp-admin 5 · google.com/maps 4 · maps 2
query fragment            folder= · project=fd-sundhed · status=godkendt · status=afvist
a full URL                https://xrt81.com/  (1)

Playwright's rule is that a pattern with no wildcard must equal the URL exactly, and not one of the 38 carries a wildcard. So under glob, 37 of the 38 would never match anything — only the single full URL survives. The query fragments can only ever work as substrings; / alone, 6 runs, means "any URL" here and "the root" under glob.

The 13 remaining recorded values are the manuscript author's own prose ("redirect tilbage til kvittering"), not arguments — the store overwrites the label when one is set. They are listed in the corpus test so the exclusion is auditable rather than a quiet trim.

The failure names both sides, because a failed login is the commonest use and where it actually went is the whole question:

waitForUrl: never reached a URL containing "/dashboard"
            — still at https://app.example.com/login?error=bad_password

Default timeout differs from the daemon's on purpose: the daemon defaults to 8000 ms, this engine to step -> flow -> 30 s. Longer, not shorter — a failing waitForUrl becomes slower to fail, never faster.

expectAbsent waits for absence, and it is cheap

expectVisible has no negative, and this is not one: it must wait for the element to go, which is how you assert a toast closed or a row was deleted.

  • an element that never existed is a PASS, not an error
  • an element that is attached but hidden counts as absent
  • all layers of a LocateSpec must be gone. Presence is one layer hits, so absence must be no layer hits — otherwise it reports green while the element stands there under a different layer
  • the failure names which layer still matched
  • nth is honoured: "the third row is gone" is "fewer than three visible"

It deliberately does not use the patient resolve (v0.8.0). That exists to wait for something to appear; running an absence check through it would spend the whole budget hunting for the thing you are asserting is gone, so every passing expectAbsent would cost the full timeout. Green, just slow — the kind of cost nobody traces back. An already-absent element under a 5000 ms budget returns in under 300 ms.

A step's detail now names the layer that actually matched

Filed by cardmem after it cost them a wrong conclusion. Every resolving step reported a label built from the spec's first key by priority, whatever actually matched:

0.8.x   "detail": "by-testid"          "resolved_via": "css"
0.9.0   "detail": "#by-css (css)"      "resolved_via": "css"

The danger was not imprecision. The label was constructed from the request, so it could never contradict the caller — a field that cannot disagree with you looks like confirmation and carries no information. resolved_via was always correct; it just sat below the field a human reads first.

A bare-string target is unchanged (the string is the selector), and resolved_via itself is untouched.

check and uncheck — because a click cannot assert a state

Two new steps. Additive: nothing existing changes.

{ "action": "check",   "target": "agree" }      // idempotent: already on → stays on
{ "action": "uncheck", "target": "newsletter" } // idempotent: already off → stays off

They drive locator.check() / locator.uncheck(), which read the control's current state first and return immediately if it is already where you asked. A click toggles — and the caller who reaches for check is precisely the one who does not know the current state, otherwise they would have written click.

Why this is a real verb and not an alias

Measured in a real browser, with two checkboxes in opposite starting states:

verb     scenario                       action  assert
check    box was OFF  → expect ON       ok      ok
check    box ALREADY ON → expect ON     ok      FAIL    ← click toggled it OFF
uncheck  box was ON   → expect OFF      ok      ok
uncheck  box ALREADY OFF → expect OFF   ok      FAIL    ← click toggled it ON

The finding is in the action column: ok in both failing rows. The click succeeded. Only an assertion on the resulting state caught that it was inverted.

Migrating an existing check — measured, and there is nothing to migrate

The hazard people expect here is a runner that executed check as a plain click (which works on anything wrapping the box) handing over to a real locator.check() (which does not). Worth stating because it is the obvious worry — and worth measuring, because in this fleet it is empty.

The cardmem daemon has driven locator.check() / locator.uncheck() since its F074.23, not a click. Its stored history carries Playwright's own .check() messages ("Clicking the checkbox did not change its state"), which a click cannot produce. So all 28 recorded check/uncheck calls in fleet history already ran through a real .check()and every one of them passed. A pass under .check() is itself proof that the target reaches a checkable element, since it throws otherwise. 28 of 28, resolved by their own green status.

What follows is therefore the contract, not a migration warning.

<label> wrapping the checkbox        ok      box → true
<label for="…"> pointing at it       ok      box → true
the <input> itself                   ok      box → true
<div> wrapper (no label semantics)   THROWS  "Not a checkbox or radio button"
<label> wrapping nothing checkable   THROWS  same

A <label> is fine, either association. Playwright follows the label→control relationship. Only a plain wrapper refuses.

An earlier version of this section said a <label> would throw. It does not — and that error failed in the green direction: it promised a throw that never comes, so anyone whose testid sits on a label would have believed themselves caught by a trap they were not in and moved an attribute for nothing.

Where it does throw, the failure names what it found, so the fix is one line:

Error: Not a checkbox or radio button

"agree" resolved to <div data-testid="agree">. check/uncheck drive the control
itself and assert the resulting state, so the target must reach an
<input type="checkbox"> … A <label> is fine … but a plain wrapper (<div>,
<span>) is not. Move the target onto the input, or onto its label.

There is no fallback to a click, because falling back is exactly the defect above: the action reports ok and the box ends up in the opposite state.

Two independent confirmations that the fleet's existing targets are fine: cardmem resolved 26 of the 28 against their own source (all on the <input>, inside a <label>), storeform resolved check-terms the same way — and then the stronger argument made both unnecessary, since all 28 had already passed under a real .check().

v0.8.1 — the race decided which layer, and it should only decide when

Not breaking, but it changes which element a multi-layer LocateSpec acts on.

0.8.0 raced every layer with a bare Promise.any, which settles on whichever check finishes first. Nothing ordered two layers that became true in the same instant — and that is the ordinary case, because one element rendering makes all of its layers true at once. So the layer that came back depended on which locator's machinery answered sooner:

spec { testid, css } · both present from t+200ms · css answers in 5ms, testid in 30ms
  0.8.0  →  resolved_via "css"        ← the caller listed testid FIRST
  0.8.1  →  resolved_via "testid"

With four layers settling in reverse order, 0.8.0 returned text — the lowest priority one. When the layers happen to match different elements, that acts on the wrong element and reports success.

The fix keeps the race for timing and re-asks the priority question once there is something to look at. It waits for nothing extra: a resolve whose element lands at 200ms out of a 3000ms budget still returns at ~200ms.

The residual is deliberate and documented under Self-healing locators above: a higher-priority layer arriving strictly later still loses.

v0.8.0 — a LocateSpec object finally waits (BREAKING: resolveTarget)

Read this before upgrading if you call resolveTarget directly. It now takes a required timeoutMs and returns a third field:

// 0.7.x
const { locator, resolved_via } = await resolveTarget(page, target, { action });
await locator.click({ timeout: timeoutMs });

// 0.8.0
const { locator, resolved_via, remaining_ms } =
  await resolveTarget(page, target, { action, timeoutMs });
await locator.click({ timeout: remaining_ms });   // ← the REMAINDER, not the original

Required rather than optional on cardmem's own request, against their own build: an optional budget is an absent guard that looks like a present one — the fix would read as landed in the shared resolver while every consumer kept the defect.

What was broken

A LocateSpec object never waited at all. Measured in a real browser against 0.7.1, element injected at t+800ms with a 5000ms timeout, against a control that never injects:

                arrives at t+800ms        never arrives
{ css: … }      FAIL   457ms             fail   331ms
{ testid: … }   FAIL   318ms             fail   316ms
"#css"          ok    1124ms             fail  5377ms
"testid"        ok    1341ms             fail  5252ms

The object form's two columns are indistinguishable — same verdict, same time, same message for not there yet and never there. Every layer was gated on await loc.count() > nth, an instantaneous probe; the bare-string form was fine only because it skips the probe and lets Playwright auto-wait.

Which made the advice in the section below actively harmful: it tells you to prefer { css: "body" } when precision matters, and that was the form that could not wait. A consumer following it got a flakier flow.

What changed

Resolution is now two passes under one shared budget:

| pass | question | cost | |---|---|---| | 1 | identity — was it renamed? Snapshot, all layers, strict priority. | ~0 | | 2 | time — has it rendered yet? Races every layer against the remaining budget. | ≤ timeout_ms, once |

Racing rather than serialising is the point: a waitFor per layer would turn a total miss into n × timeout. Four layers that all miss now cost one timeout.

Self-heal got better as a side effect. A first layer matching only a hidden element used to match and then time out; it now falls through to the next layer, which is what "fallback" promised all along.

The per-verb criterion — and why upload is exempt

visible for every verb except upload, which uses attached. setInputFiles deliberately does not require visibility, and a display:none file input is the standard pattern behind every styled upload control — a blanket visible would break uploads everywhere.

visible is safe for the others by construction, not by corpus: click/fill/type/press/select require Playwright actionability, and expectVisible/expectEditable/screenshot/waitFor/expectText each wait for visibility right after resolving. So the old probe's inclusion of hidden elements could never produce a passing step — only a worse message.

remaining_ms, and the zero that means "forever"

The verb waits on what is left, never the original. Otherwise resolve and action each spend the full budget: ask for 5000ms against a missing element and you wait 10s while being told 5000ms — which is the exact defect timeout_ms was built to remove, reproduced inside its own fix.

remainingBudget(budget, spent) is exported and floored at 1, never 0: Playwright reads timeout: 0 as disable the timeout, so an exhausted budget would become an infinite wait. Third time that inversion has appeared in this epic, after badge = 0 meaning remove the badge and timeout_ms: 0 meaning never time out.

v0.7.0 — unknown keys are now REJECTED, and timeout_ms finally works

Read this before upgrading: a flow that parses today can stop parsing. That is the change, not a side effect.

flowBodySchema and every member of flowStepSchema are now .strict(). Until 0.6.1 they were Zod's default .strip(), which deletes an unknown key silently, before the engine ever sees it:

// 0.6.1
flowStepSchema.safeParse({ action:'click', target:'#save', timeout_ms:1000 })
//  → ok: true,  data: { action:'click', target:'#save' }     ← the field is gone

// 0.7.0
//  → ok: false, "Unrecognized key(s) in object: 'timeout_ms'"

Nobody decided that behaviour, which is exactly why nobody caught it. cardmem's formulation, adopted here: a missing capability fails visibly; an ignored field lies. The migration is mechanical — the error names the key.

Since v0.7.2 the error names the ESCAPE HATCH too, not only the key:

Unrecognized key(s) in object: 'project'. This schema is strict — an unknown key
is refused rather than silently deleted. If it is YOUR field, carry it with
flowBodySchema.extend({ project: … }), which stays strict and still refuses
everything else. If it was meant to be one of ours, check the spelling.

That exists because this section did not help the consumer it was written for. cardmem's cloud path was rejected by 0.7.0 and they found .extend() by reading the source, not this file — a consumer running pnpm update does not read release notes, and the person who needs this line is holding a stack trace. A rejected key on a step points at the body instead, since a discriminated union cannot be extended.

The evidence this is worth the break. cardmem mined 1258 real request bodies from fleet session transcripts. One flow sent baseUrl instead of base_url; the key was deleted and the flow ran against the wrong origin. base, project, url and label are the same shape of bug. Every one is caught at the boundary now.

Adding your own key is supported — .extend() survives strict:

const myBody = flowBodySchema.extend({ auth: myAuthSchema.optional() });
myBody.safeParse({ …, auth })   // true  — your key is admitted
myBody.safeParse({ …, junk: 1 }) // false — everything else still refused

That is how Cloud Lens's 874 auth-carrying calls keep working untouched, and it is where a consumer-owned field belongs. It does not belong in this schema: the engine would be promising a key nothing here acts on, which is the same lie in the other direction.

timeout_ms — per step, or per flow

runFlow({
  base_url: 'https://example.com',
  timeout_ms: 5_000,                                   // default for every step
  steps: [
    { action: 'goto', url: '/login' },
    { action: 'click', target: '#save', timeout_ms: 1_000 },   // this one wins
  ],
});

Step beats flow beats the built-in 30s. With neither set, behaviour is unchanged. resolveStepTimeout(step, flow) is exported so a consumer running its own loop resolves it the same way instead of rebuilding the rule.

The plumbing was already there — every Playwright call took the timeout; only the inlet was missing. And the value you choose is the value that fails: the motivating incident (cardmem F074.51) was a caller asking for 1000 ms and being told "Timeout 15000ms exceeded", which cost storeform two days believing Google Play Console was slow.

timeout_ms: 0 is rejected. Playwright reads timeout: 0 as disable the timeout, so a caller who means "fail immediately" would get "wait forever" — the exact inversion this field exists to prevent. Minimum is 1; a whole-flow deadline is a different mechanism and is not in this release.

Assert a field is editable (v0.4.0) — prove click-to-edit worked

expectEditable asserts that a resolved element is editable right now — the proof that a @broberg/cms-inline-edit click-to-edit field actually turned editable (instead of the hand-rolled assert({ js }) escape-hatch). Compose it after a click:

await runFlow({
  base_url: "https://site.example",
  storageState,
  steps: [
    { action: "click", target: "bio-field" },       // enter edit mode
    { action: "expectEditable", target: "bio-field" }, // ← passes only if now editable
  ],
});

Editable = contenteditable (the nearest ancestor carrying the attribute wins — ""/true/plaintext-only ⇒ editable, false ⇒ not, inherited counts) or an enabled, non-readonly native form control (<input>/<textarea> not disabled + not readOnly, or a <select> not disabled). A present-but-not -editable target throws, naming the target. The predicate is exported as the pure isEditableElement(el) (offline-testable; the SAME function is serialized into the page at runtime).

Page-read primitives (v0.2.0) — token-frugal reads

Automation (capture / runFlow) is already token-free. These three readers close the other gap: pulling a live page into an agent's own LLM context without swallowing 15–30k tokens of raw HTML. Each is deterministic and spends zero LLM tokens in the extraction itself.

import { read, extract, network } from "@broberg/lens-engine";

// 1) Clean markdown of the MAIN content only (nav/header/footer/chrome stripped)
const { title, markdown } = await read("https://example.com/post");

// 2) Repeating structures (tables + explicit lists) → structured JSON
const { regions } = await extract("https://example.com/pricing");
// regions: [{ kind:'table'|'list', columns, rows, totalRows, truncated, confidence, selector }]

// 3) Capture the page's own XHR/fetch API responses — skip the HTML entirely
const { responses } = await network("https://example.com/app", { urlPattern: "/api/" });
// responses: [{ url, status, method, contentType, json? | text? }]

Auth: a string URL opens an anonymous context; to read behind a login pass a live (already-authed) Page — the caller owns its lifecycle (never navigated or closed here). This keeps the reader signatures minimal and the locked types stable.

extract() v1 fence (deterministic, no LLM): <table> + role=table|grid (columns from <th>, confidence:'high'); explicit lists <ul>/<ol>{text, href?}, <dl>{term, definition} ('high'); and a repeated-sibling-grid — ≥ minRows (default 3) siblings sharing a non-empty class-signature → {text, href?} (confidence:'medium'). It does not decompose arbitrary "cards" into sub-fields (that heuristic is the noise this fence omits). regions: [] means nothing qualified — fall back to read(). Hints: selector (scope) · kind (auto|table|list) · mustHaveColumns (disambiguate) · columns (positional rename + drop the rest) · minRows (grid gate) · limit (row cap → truncated + totalRows).

Inline-edit coverage (v0.3.0) — prove you tagged every editable field

coverage() proves click-to-edit completeness for @broberg/cms-inline-edit sites: it enumerates every [data-cms-field] on a page, groups by (data-cms-collection, data-cms-slug), and diffs against the CMS schema.

import { coverage } from "@broberg/lens-engine";

const schema = { page: { fields: ["title", "body", "hero"] } };   // parsed by the caller (I/O-free)
const report = await coverage(page, schema, { ignoreFields: ["computedAt"] });
// report.pages[i] → { collection, slug, present[], expected[], missing[], orphans[], coveragePct }
//   missing  = editable fields you forgot to tag (the actionable list)
//   orphans  = tagged but not in the schema (incl. elements with no collection/slug)

Pure + offline-testable (computeCoverage(html, schema, opts) over jsdom). ignoreFields is removed from both present and expected before the diff; an unknown collection yields expected: [] (all present become orphans, never a crash). cardmem's lens_coverage MCP tool drives the authed page + feeds the parsed schema; the engine never fetches.

API

function capture(opts: CaptureOptions): Promise<CaptureResult>;   // { png, dom_hash, dims, title }
function runFlow(opts: FlowOptions): Promise<FlowResult>;         // step reports + resolution layers
function plannedLayers(spec: LocateSpec): string[];               // the locator layers, in order
function applyStorageState(ctx, state): Promise<void>;            // core (used by capture/flow)
function fetchStorageState(auth: MintAuth): Promise<StorageState>;// OPTIONAL consumer helper
function visionEnabled(): boolean;                                // dark-ship gate
// v0.2.0 readers:
function read(target: string | Page, opts?: ReadOptions): Promise<ReadResult>;       // { url, title, markdown }
function extract(target: string | Page, hint?: ExtractHint): Promise<ExtractResult>; // { url, regions[] }
function network(target: string | Page, opts?: NetworkOptions): Promise<NetworkResult>; // { url, responses[] }
// pure, offline-testable cores: htmlToMarkdown, extractRegions, matchesUrlPattern, shapeResponseParts
// + resolveSelector, resolveViewport, getBrowser, closeBrowser, armIdleTimer, and all Zod schemas

v0.5.0 — fail at boot when the browser is missing

If you bake browsers into an image (a Docker/Fly deploy rather than a dev machine), call this once at startup:

import { assertBrowserAvailable } from '@broberg/lens-engine';
assertBrowserAvailable(); // throws if the Chromium build Playwright expects is absent

It resolves chromium.executablePath() and stats it — no launch, no network — and throws with the expected path plus PLAYWRIGHT_BROWSERS_PATH, which are the two lines that actually diagnose it. getBrowser() calls it too, so you get the clear error either way; calling it yourself just moves the failure from the first capture in production to a failed deploy.

Why a path check and not a version check. Playwright encodes the browser revision in the path (…/ms-playwright/chromium-1228/…), so this is exact where a version compare is a proxy. It avoids a false alarm when two Playwright versions share one revision, and — the one that matters — it catches the case a version compare cannot see: the version matching while the browser is gone, because a base image changed and the package did not.

Note for image builders: playwright is an ordinary dependency here, not a peer, deliberately. Making it a peer was tried and measured: pnpm auto-installs peers by default, so an undeclared peer produced no warning at all and silently resolved a newer Playwright than the one baked into the image — worse than the problem it was meant to solve. A Playwright bump in this package is still a breaking change for you; it ships in its own minor (a caret on 0.x locks the minor, so you are never upgraded into one by accident).

v0.6.0 — an assert that returns a bag of data asserted nothing

An assert body may return a boolean, or { pass, detail }. As of 0.6.0 a plain object with no pass key is a hard error instead of a silent pass:

{ action: 'assert', js: "return { passed: drawer.open, detail: '…' }" }
// ✗ assert returned an object with no 'pass' key (got: passed, detail)
//   — did you mean { pass }? Return a boolean, or { pass, detail }

{ action: 'assert', js: 'return {}' }
// ✗ assert returned an empty object, so nothing was asserted — this is usually
//   a template that was never filled in.

Before this, no pass key meant bare-truthy, so {passed:…} {ok:…} {found:…} — any near-miss on the verdict key — was green forever, whatever the page did. Measured in two months of real fleet history: the worst example computed the answer and then handed it back as a data field (return {picker_left, sidebar_right, behind}) without ever using it as the verdict. That form is more dangerous than a bare true, because it looks careful and no reviewer stops at it.

Narrow by design. The discriminator is the prototype, so only plain objects are rejected. A DOM Element, an array, a Date, a Map, a class instance — all still pass bare-truthy, which is why assert: document.querySelector('#drawer.open') keeps working. {pass:false} remains an ordinary assert failure, not an error.

Not covered: a body ending in a literal return true is still green whatever happened. No engine can see the difference between that and a real verdict — that one is on the author. (Measured caveat: return true is only vacuous when it stands alone. If the chain above it can throw — a dead fetch, a missing node — the assert is thin but real. The genuinely empty ones fire work without awaiting it and return before the answer arrives.)

⚠️ Upgrading from 0.5.x — AssertOutcome gained a member

Who this is for: repos with lens-engine in node_modules — i.e. anyone who embeds the engine. Not the much larger set that drives Lens as a service through the cardmem daemon or MCP: they only ever see the finished status field, and are unaffected at any version. The two groups are easy to confuse — one repo ran 144 Lens runs without ever having had the package.

AssertOutcome is now a four-way union. If you switch on kind or read .value, handle no-verdict explicitly:

if (out.kind === 'no-verdict') fail(noVerdictMessage(out.keys, body));

TypeScript catches this at compile time. Plain JS does notout.value is undefined on a no-verdict, which is falsy, so the assert fails with no explanation and the named message never reaches the author. That throws away the entire point of the release. Reported by cardmem, whose typecheck caught it on both of their call-sites.

noVerdictMessage(keys, body?) is exported (0.6.1) so there is one definition of those two sentences — rebuild them by hand in a consumer and the engine's wording and yours drift apart, which is the same mistake as two copies of the verdict logic.

Runtime deps: playwright, zod, @broberg/ai-sdk (vision only), and — for the readers — jsdom + @mozilla/readability + turndown. MIT · part of the @broberg/* shared-library family.