@ingcreators/annot-playwright 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,221 @@
1
1
  # @ingcreators/annot-playwright
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 266b05a: **Fix `writeFile is not a function` crash on `page.screenshot({ annot:
8
+ { path } })`** — adds `/^node:/` to the Vite library build's
9
+ `rollupOptions.external` so Node builtins (`node:fs/promises`, …)
10
+ stay as native ESM imports in `dist/index.js` instead of being
11
+ inlined under Vite's browser-compat externalisation.
12
+
13
+ ### Root cause
14
+
15
+ `screenshot-patch.ts` imports `writeFile` from `node:fs/promises`
16
+ and calls it when `path` is set on the screenshot opts. Vite's
17
+ default behaviour for Node builtins in a library build is to
18
+ externalise them with a browser-compat shim that destructures
19
+ incorrectly at the consumer side — the destructured name resolves
20
+ to `undefined` and the runtime call throws:
21
+
22
+ ```
23
+ TypeError: (0 , d.writeFile) is not a function
24
+ at _Page.N (.../node_modules/@ingcreators/annot-playwright/dist/index.js:156:36)
25
+ ```
26
+
27
+ The local build emitted a `Module 'node:fs/promises' has been
28
+ externalized for browser compatibility` warning — same root cause.
29
+
30
+ ### Fix
31
+
32
+ `packages/playwright/vite.config.ts` now mirrors the pattern used
33
+ by `@ingcreators/annot-product-docs`, `-product-docs-astro`,
34
+ `-product-docs-xlsx`, and `-mcp`: `/^node:/` in `external` keeps
35
+ Node builtins as `import { writeFile } from "node:fs/promises"`
36
+ in the output bundle, which Node resolves natively at runtime.
37
+
38
+ ### Affected consumers
39
+
40
+ Every consumer of `@ingcreators/annot-playwright@0.4.0` that passes
41
+ `path` on `page.screenshot({ annot: { … } })` /
42
+ `locator.screenshot({ annot: { … } })`. The workflow-app docs-tour
43
+ CI workflow (`.github/workflows/docs-tour.yml` in the `annot` repo,
44
+ plus the dogfooded `examples/workflow-app/` tour) is the first
45
+ known case in the wild.
46
+
47
+ ### Verification
48
+ - `pnpm --filter @ingcreators/annot-playwright build` succeeds
49
+ without the `externalized for browser compatibility` warning.
50
+ - `dist/index.js` line 4 reads
51
+ `import { writeFile as d } from "node:fs/promises";` (literal
52
+ native import — no compat shim).
53
+ - `pnpm --filter @ingcreators/annot-playwright typecheck` passes.
54
+
55
+ - Updated dependencies [6124d59]
56
+ - Updated dependencies [0c7ac26]
57
+ - Updated dependencies [64dc6e8]
58
+ - Updated dependencies [691bec5]
59
+ - Updated dependencies [9697f27]
60
+ - @ingcreators/annot-annotator@0.6.0
61
+ - @ingcreators/annot-core@0.3.0
62
+
63
+ ## 0.4.0
64
+
65
+ ### Minor Changes
66
+
67
+ - f979374: **`page.screenshot({ annot: { … } })` patch relocated to
68
+ annot-playwright** — Phase 1 of
69
+ `docs/plans/playwright-screenshot-fixture-relayer.md`. The
70
+ prototype patch that lets Playwright tests emit annotated PNGs by
71
+ adding a nested `annot:` option to `page.screenshot()` /
72
+ `locator.screenshot()` lives here now (it shipped first in
73
+ `@ingcreators/annot-product-docs-astro/playwright` but is not
74
+ Astro-specific). VRT / marketing-screenshot / AI-agent flows pick
75
+ up the API without taking an Astro dependency.
76
+
77
+ ```ts
78
+ import { test, expect } from "@ingcreators/annot-playwright";
79
+
80
+ test("login form callouts", async ({ page }) => {
81
+ await page.goto("/login");
82
+ await page.screenshot({
83
+ path: "login.png",
84
+ annot: {
85
+ overlays: [
86
+ {
87
+ type: "rect",
88
+ bbox: { x: 10, y: 10, width: 200, height: 30 },
89
+ intent: "warning",
90
+ },
91
+ {
92
+ type: "numberedBadge",
93
+ bbox: { x: 10, y: 10, width: 24, height: 24 },
94
+ number: 1,
95
+ },
96
+ ],
97
+ tags: { source: "vrt-failure", testId: "login" },
98
+ // editable: true is the default → output PNG is re-editable
99
+ // in Annot Cloud.
100
+ },
101
+ });
102
+ });
103
+ ```
104
+
105
+ Three independent contributions compose into the output PNG:
106
+ - `overlays: BboxAnnotation[]` — inline annotations (rect /
107
+ circle / arrow / text / callout / numberedBadge / raw SVG).
108
+ - `tags: Record<string, string>` — provenance metadata
109
+ serialised into the PNG's XMP (or iTXt sidecar when no
110
+ overlays are present).
111
+ - `editable: boolean` (default `true`) — re-editable wrap
112
+ (annotations + original capture in XMP) vs. flat baked PNG.
113
+
114
+ `Locator.prototype.screenshot` and `Page.prototype.screenshot({
115
+ clip })` both go through the same pipeline; page-space overlay
116
+ coordinates are automatically rebased into the clipped image's
117
+ coordinate space. Overlays whose bbox falls outside the clip are
118
+ dropped with a warning annotation on `test.info()`.
119
+
120
+ **Extension hook registry** — `annotSourceResolvers` is a
121
+ module-level array of resolvers that downstream packages push
122
+ into to claim extra `annot.*` fields:
123
+
124
+ ```ts
125
+ import { annotSourceResolvers } from "@ingcreators/annot-playwright";
126
+
127
+ annotSourceResolvers.push(async ({ annot, page }) => {
128
+ if (!annot.figma) return null;
129
+ return {
130
+ prepare: () => refreshFigmaCache(annot.figma),
131
+ resolveAnnotations: (dims) => readFigmaOverlays(annot.figma, dims),
132
+ };
133
+ });
134
+ ```
135
+
136
+ The resolver's `prepare()` hook fires before the raw screenshot
137
+ is taken (so MDX-aware adapters can refresh `annot:snapshot`
138
+ blocks against the live DOM); `resolveAnnotations(dims)` runs
139
+ after with the page-space dimensions and returns
140
+ `BboxAnnotation[]` to merge into the output. annot-playwright
141
+ stays MDX-unaware — the matching MDX resolver moves into
142
+ `@ingcreators/annot-product-docs` in Phase 2 of the parent plan.
143
+
144
+ **Coordinate-rebase API** — `rebaseAnnotations` /
145
+ `describeAnnotation` / `Clip` / `RebaseResult` exported for
146
+ callers who want to rebase annotations themselves without going
147
+ through the patch (e.g. building custom test reporters):
148
+
149
+ ```ts
150
+ import { rebaseAnnotations } from "@ingcreators/annot-playwright";
151
+
152
+ const { kept, dropped } = rebaseAnnotations(annotations, clip);
153
+ ```
154
+
155
+ **Compatibility** — additive. The existing
156
+ `@ingcreators/annot-product-docs-astro/playwright` subpath stays
157
+ working unchanged for this release; Phase 4 of the parent plan
158
+ converts it into a deprecated re-export.
159
+
160
+ ### Patch Changes
161
+
162
+ - 85d40e6: **Docs + CLAUDE.md + plan archive** — Phase 5 (final) of
163
+ `docs/plans/_done/playwright-screenshot-fixture-relayer.md`.
164
+ Refreshes the doc surfaces, the operational CLAUDE.md notes,
165
+ and archives the relayer plan into `_done/`.
166
+
167
+ ## Doc surface updates
168
+ - `packages/docs-site/src/content/docs/product-docs/playwright-fixture.mdx`
169
+ — recommended import paths updated; new "Choosing your import"
170
+ section covers the `@ingcreators/annot-product-docs` (MDX)
171
+ vs. `@ingcreators/annot-playwright` (no MDX) split; companion
172
+ helpers section now imports from the canonical homes; codegen
173
+ workflow example swapped to `@ingcreators/annot-product-docs`.
174
+ - `packages/docs-site/src/content/docs/api/create-annotator.mdx`
175
+ — "From a Playwright test" example imports from the canonical
176
+ home; mentions the no-MDX alternative.
177
+ - `packages/playwright/README.md` — adds the
178
+ `page.screenshot({ annot: { … } })` (recommended) section
179
+ above the existing `annotator.annotateScreenshot(...)` flow;
180
+ documents the `annotSourceResolvers` extension hook + the
181
+ coordinate-rebase helpers.
182
+ - `packages/product-docs/README.md` — adds a `page.screenshot({
183
+ annot })` Playwright fixture section explaining the
184
+ productDocs.sync + MDX-resolver bundle.
185
+ - `packages/product-docs-astro/README.md` — replaces the
186
+ Playwright fixture section with a Migration note pointing at
187
+ the canonical homes; documents the `0.5.0` removal target.
188
+
189
+ ## CLAUDE.md monorepo layout
190
+ - `playwright/` entry now describes the canonical
191
+ `page.screenshot({ annot })` patch + `annotSourceResolvers`
192
+ extension hook. Version bumped to 0.4.0.
193
+ - `product-docs/` entry mentions the `productDocs` fixture
194
+ rename + the MDX-aware resolver registration via the hook
195
+ registry. Version bumped to 0.3.0.
196
+ - `product-docs-astro/` entry calls out the deprecated
197
+ `/playwright` re-export shim + 0.5.0 removal target.
198
+ `@playwright/test` no longer a peer dep. Version bumped to
199
+ 0.3.0.
200
+
201
+ ## Plan archive
202
+ - `docs/plans/playwright-screenshot-fixture-relayer.md` →
203
+ `docs/plans/_done/playwright-screenshot-fixture-relayer.md`.
204
+ Status header switched to `Done` with the four landing PRs
205
+ enumerated. Internal `./_done/...` link paths updated to
206
+ `./...` (the plan is itself inside `_done/` now).
207
+ - `docs/plans/README.md` — removed the active entry, added a
208
+ "Recently landed plans" row pointing at the archived plan
209
+ with a multi-phase summary covering all four landing PRs.
210
+ - `docs/plans/living-spec-authoring-roadmap.md` — three link
211
+ references updated to point at the archived path; the "How
212
+ this relates" line switched from "Already Draft" to "Landed
213
+ 2026-05-22 (PRs #962 / #963 / #964 / #966)".
214
+
215
+ No source code changes; verified via `pnpm -r typecheck`,
216
+ `pnpm test`, `pnpm lint` regardless to confirm the doc/comment
217
+ edits parse cleanly.
218
+
3
219
  ## 0.3.1
4
220
 
5
221
  ### Patch Changes
package/README.md CHANGED
@@ -52,6 +52,94 @@ The `test` import is `@playwright/test`'s `test`, extended with an
52
52
  `annotator` fixture. Everything else (`test.describe`,
53
53
  `test.beforeEach`, `expect`, …) passes straight through.
54
54
 
55
+ ## `page.screenshot({ annot: { … } })` (recommended)
56
+
57
+ The `test` fixture also patches `Page.prototype.screenshot` and
58
+ `Locator.prototype.screenshot` so any call carrying an
59
+ `annot: { … }` option runs the annot pipeline inline — no
60
+ separate `annotateScreenshot` call, no manual file write:
61
+
62
+ ```ts
63
+ import { test } from "@ingcreators/annot-playwright";
64
+
65
+ test("login form callouts", async ({ page }) => {
66
+ await page.goto("/login");
67
+ await page.screenshot({
68
+ path: "login.png",
69
+ annot: {
70
+ overlays: [
71
+ { type: "rect", bbox: { x: 10, y: 10, width: 200, height: 30 }, intent: "warning" },
72
+ { type: "numberedBadge", bbox: { x: 10, y: 10, width: 24, height: 24 }, number: 1 },
73
+ ],
74
+ tags: { source: "vrt-failure", testId: "login" },
75
+ // editable: true (default) → output PNG is re-editable in Annot Cloud.
76
+ },
77
+ });
78
+ });
79
+ ```
80
+
81
+ Three independent contributions compose into the output:
82
+
83
+ - `overlays: BboxAnnotation[]` — inline annotations (rect /
84
+ circle / arrow / text / callout / numberedBadge / raw SVG).
85
+ - `tags: Record<string, string>` — provenance metadata
86
+ serialised into the PNG's XMP (or iTXt sidecar when no
87
+ overlays are present).
88
+ - `editable: boolean` (default `true`) — re-editable wrap
89
+ (annotations + original capture in XMP) vs. flat baked PNG.
90
+
91
+ `Locator.screenshot` + `page.screenshot({ clip, annot })` both
92
+ go through the same pipeline; page-space overlay coordinates are
93
+ automatically rebased into the clipped image's coordinate space.
94
+ Overlays whose bbox falls outside the clip drop with a
95
+ `test.info()` warning.
96
+
97
+ Calls without `annot` (or with `annot: true` / `{}`) fall
98
+ through to vanilla Playwright byte-for-byte — codegen-emitted
99
+ specs work unedited.
100
+
101
+ ### Extension hook registry
102
+
103
+ `annotSourceResolvers` is a module-level array that downstream
104
+ packages push into to claim extra `annot.*` fields:
105
+
106
+ ```ts
107
+ import { annotSourceResolvers } from "@ingcreators/annot-playwright";
108
+
109
+ annotSourceResolvers.push(async ({ annot, page }) => {
110
+ if (!annot.figma) return null;
111
+ return {
112
+ prepare: () => refreshFigmaCache(annot.figma),
113
+ resolveAnnotations: (dims) => readFigmaOverlays(annot.figma, dims),
114
+ };
115
+ });
116
+ ```
117
+
118
+ The resolver's `prepare()` hook fires before the raw screenshot
119
+ is taken; `resolveAnnotations(dims)` runs after with the
120
+ page-space dimensions and returns `BboxAnnotation[]` to merge
121
+ into the output.
122
+ [`@ingcreators/annot-product-docs`](https://www.npmjs.com/package/@ingcreators/annot-product-docs)
123
+ ships an MDX-aware resolver via this hook — importing its `test`
124
+ adds an `annot.mdx?: { id, path }` field that bundles the
125
+ refresh-MDX + take-screenshot + bake-PNG sequence into one
126
+ `page.screenshot()` call.
127
+
128
+ ### Coordinate-rebase helpers
129
+
130
+ ```ts
131
+ import {
132
+ rebaseAnnotations,
133
+ describeAnnotation,
134
+ } from "@ingcreators/annot-playwright";
135
+
136
+ const { kept, dropped } = rebaseAnnotations(annotations, clip);
137
+ ```
138
+
139
+ Use these when you want to drive the rebase logic without going
140
+ through the patch (custom test reporters, third-party tools that
141
+ ship with their own composer).
142
+
55
143
  ## Compose annotations with the helpers
56
144
 
57
145
  ```ts
@@ -0,0 +1,134 @@
1
+ import { ElementTree, ElementTreeViewport } from '@ingcreators/annot-core/element-tree';
2
+ /**
3
+ * Options for the pure YAML → tree conversion.
4
+ */
5
+ export interface PlaywrightYamlToElementTreeOptions {
6
+ /** Raw output of `page.locator(...).ariaSnapshot({ mode: "ai",
7
+ * boxes: true })`. Required. */
8
+ yaml: string;
9
+ /** Viewport dimensions + DPR at capture time. Required. */
10
+ viewport: ElementTreeViewport;
11
+ /** Page URL at capture time. Stored in `ElementTree.source.url`. */
12
+ url?: string;
13
+ /** Tool identifier (e.g. `"annot-playwright@0.4.0"`). Stored in
14
+ * `ElementTree.source.agent`. */
15
+ agent?: string;
16
+ /** Capture timestamp. Defaults to `new Date().toISOString()`. */
17
+ capturedAt?: string;
18
+ }
19
+ /**
20
+ * Convert Playwright's aria-snapshot YAML into an `ElementTree`.
21
+ *
22
+ * The YAML format Playwright emits in `mode: "ai"` is a
23
+ * 2-space-per-level indented bullet list:
24
+ *
25
+ * ```yaml
26
+ * - main:
27
+ * - heading "Sign in" [level=1] [ref=e2]
28
+ * - form [ref=e3]:
29
+ * - textbox "Email" [ref=e4] [box=100,200,300,40]
30
+ * - button "Sign in" [active] [ref=e6]
31
+ * ```
32
+ *
33
+ * Each line:
34
+ * - starts with `-` at some indent
35
+ * - role token (lowercase letters)
36
+ * - optional `"name"` (double-quoted)
37
+ * - zero or more `[token]` annotations: `[ref=eN]`, `[box=x,y,w,h]`,
38
+ * `[state]`, `[key=value]`
39
+ * - optional trailing `:` indicating a container
40
+ *
41
+ * Nodes without a `[ref=…]` (decorative containers) get a synthetic
42
+ * `ref` so every node in the resulting tree has a unique identifier.
43
+ */
44
+ export declare function playwrightYamlToElementTree(opts: PlaywrightYamlToElementTreeOptions): ElementTree;
45
+ /**
46
+ * Options for `attachAttributes` — the convenience that walks an
47
+ * already-built `ElementTree` and fills each node's `attributes` by
48
+ * resolving its `match: { role, name }` against the live page.
49
+ */
50
+ export interface AttachAttributesOptions {
51
+ /** Whitelist of HTML attribute names to capture. Same shape as
52
+ * `DEFAULT_ATTR_WHITELIST` in `@ingcreators/annot-product-docs`. */
53
+ whitelist: readonly string[];
54
+ }
55
+ /**
56
+ * Walk an ElementTree and attach HTML attribute snapshots to each
57
+ * node by resolving its role+name against the live page. Mutates
58
+ * the tree in place AND returns it for fluent chaining.
59
+ *
60
+ * Resolution strategy: for each node with both `role` and `name`,
61
+ * call `page.getByRole(role, { name, exact: true })`. When the
62
+ * locator resolves to exactly one element, evaluate the whitelist
63
+ * against it. Ambiguous / missing resolves are silently skipped —
64
+ * the drift detector raises those (intentionally not duplicating
65
+ * the diagnostic here).
66
+ */
67
+ export declare function attachAttributes(tree: ElementTree, page: AttachAttributesPage, options: AttachAttributesOptions): Promise<ElementTree>;
68
+ /**
69
+ * Options for `captureElementTree` — the high-level composition that
70
+ * runs an aria-snapshot, converts to an ElementTree, optionally
71
+ * attaches HTML attributes, and returns the canonical tree. Wraps
72
+ * `playwrightYamlToElementTree` + `attachAttributes` in one call so
73
+ * downstream callers (productDocs sync flows, screenshot resolvers,
74
+ * MCP tools) don't have to plumb the intermediate YAML.
75
+ *
76
+ * Phase 1e of `docs/plans/living-spec-authoring-roadmap.md`.
77
+ */
78
+ export interface CaptureElementTreeOptions {
79
+ /** Root locator for the snapshot. Defaults to the page body. */
80
+ rootLocator?: CaptureElementTreeLocator;
81
+ /** Page URL stored in `ElementTree.source.url`. Defaults to
82
+ * `page.url()` when the host page exposes that method. */
83
+ url?: string;
84
+ /** Agent string stored in `ElementTree.source.agent`. */
85
+ agent?: string;
86
+ /** Capture timestamp override (defaults to `new Date().toISOString()`). */
87
+ capturedAt?: string;
88
+ /** When set, walks the resulting tree and fills per-node `attributes`
89
+ * via `attachAttributes`. Pass `[]` to skip attribute capture
90
+ * entirely; pass a name list to opt in. */
91
+ attributeWhitelist?: readonly string[];
92
+ }
93
+ /**
94
+ * Structural subset of Playwright `Page` that `captureElementTree`
95
+ * needs: viewport reader + ariaSnapshot dispatch on a root locator
96
+ * + the role-resolver `attachAttributes` uses for attribute
97
+ * collection. Real Playwright `Page` instances satisfy this.
98
+ */
99
+ export interface CaptureElementTreePage extends AttachAttributesPage {
100
+ viewportSize(): {
101
+ width: number;
102
+ height: number;
103
+ } | null;
104
+ locator(selector: string): CaptureElementTreeLocator;
105
+ url(): string;
106
+ }
107
+ export interface CaptureElementTreeLocator {
108
+ ariaSnapshot(options: {
109
+ mode: "ai";
110
+ boxes: boolean;
111
+ }): Promise<string>;
112
+ }
113
+ /**
114
+ * One-shot capture composition: snapshot → ElementTree → attributes →
115
+ * return. The returned tree carries `source.kind: "playwright"` and
116
+ * `source.url` populated from the page when not overridden.
117
+ */
118
+ export declare function captureElementTree(page: CaptureElementTreePage, options?: CaptureElementTreeOptions): Promise<ElementTree>;
119
+ /**
120
+ * Structural subset of Playwright `Page` that `attachAttributes`
121
+ * needs. Real Playwright `Page` instances satisfy this; the trimmed
122
+ * shape lets unit tests pass a minimal mock without pulling in
123
+ * Playwright's full type graph.
124
+ */
125
+ export interface AttachAttributesPage {
126
+ getByRole(role: string, options: {
127
+ name: string;
128
+ exact: true;
129
+ }): AttachAttributesLocator;
130
+ }
131
+ export interface AttachAttributesLocator {
132
+ count(): Promise<number>;
133
+ evaluate<R, A>(fn: (el: Element, arg: A) => R, arg: A): Promise<R>;
134
+ }
package/dist/fixture.d.ts CHANGED
@@ -54,9 +54,20 @@ export interface PlaywrightAnnotator {
54
54
  annotateScreenshot(page: PageLike, opts: AnnotateScreenshotOptions): Promise<Uint8Array>;
55
55
  }
56
56
  /**
57
- * `test = base.extend({ annotator })` — drop-in replacement for
58
- * `@playwright/test`'s `test` with an `annotator` fixture available
59
- * in every test.
57
+ * `test = base.extend({ annotator, page })` — drop-in replacement
58
+ * for `@playwright/test`'s `test` with:
59
+ *
60
+ * - an `annotator` fixture for the legacy
61
+ * `annotator.annotateScreenshot(page, opts)` convenience;
62
+ * - a `page` fixture override that one-time-patches
63
+ * `Page.prototype.screenshot` AND `Locator.prototype.screenshot`
64
+ * per worker so calls carrying `annot: { ... }` run through the
65
+ * patch pipeline in [`./screenshot-patch.ts`](./screenshot-patch.ts).
66
+ *
67
+ * The patch is idempotent (guarded by the `ANNOT_PATCHED` symbol
68
+ * exported from [`./screenshot-hooks.ts`](./screenshot-hooks.ts)),
69
+ * so multiple `test.extend({ page })` layers in the fixture chain
70
+ * coexist safely.
60
71
  */
61
72
  export declare const test: import('@playwright/test').TestType<import('@playwright/test').PlaywrightTestArgs & import('@playwright/test').PlaywrightTestOptions & {
62
73
  annotator: PlaywrightAnnotator;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export type { AnnotationStyle, BBox, BboxAnnotation, BboxArrowAnnotation, BboxCalloutAnnotation, BboxCircleAnnotation, BboxRectAnnotation, BboxRedactRegion, BboxTextAnnotation, Intent, Point, RawAnnotation, RedactStyle, } from '@ingcreators/annot-annotator';
2
2
  export { bboxAnnotationsToSvg } from '@ingcreators/annot-annotator';
3
3
  export { expect } from '@playwright/test';
4
+ export { type AttachAttributesLocator, type AttachAttributesOptions, type AttachAttributesPage, attachAttributes, type CaptureElementTreeLocator, type CaptureElementTreeOptions, type CaptureElementTreePage, captureElementTree, type PlaywrightYamlToElementTreeOptions, playwrightYamlToElementTree, } from './element-tree-adapter.js';
4
5
  export { type AnnotateScreenshotOptions, annotateScreenshot, type PageLike, type PlaywrightAnnotator, test, } from './fixture.js';
5
6
  export { type ArrowOptions, arrowBetween, type BoundingBox, type RectOptions, rectForBoundingBox, type TextOptions, textAt, } from './helpers.js';
7
+ export { type Clip, describeAnnotation, type RebaseResult, rebaseAnnotations, } from './rebase.js';
8
+ export { ANNOT_PATCHED, type AnnotScreenshotOptions, type AnnotSourceContext, type AnnotSourceContribution, type AnnotSourceResolver, annotSourceResolvers, } from './screenshot-hooks.js';
9
+ export { patchScreenshot } from './screenshot-patch.js';
package/dist/index.js CHANGED
@@ -1,15 +1,384 @@
1
1
  import { DEFAULT_ENCODE_OPTIONS as e, arrowBetween as t, bboxAnnotationsToSvg as n, bboxAnnotationsToSvg as r, createAnnotator as i, decodeAndEncodeImage as a, rectForBoundingBox as o, textAt as s } from "@ingcreators/annot-annotator";
2
2
  import { expect as c, test as l } from "@playwright/test";
3
- //#region src/fixture.ts
4
- var u = l.extend({ annotator: async ({}, e) => {
5
- let t = i();
6
- await e({
7
- raw: t,
8
- annotateScreenshot: (e, n) => d(t, e, n)
3
+ import { walkTree as u } from "@ingcreators/annot-core/element-tree";
4
+ import { writeFile as d } from "node:fs/promises";
5
+ import { writePngWithTagsOnly as f } from "@ingcreators/annot-core/xmp-bytes";
6
+ //#region src/element-tree-adapter.ts
7
+ function p(e) {
8
+ let t = e.yaml.split(/\r?\n/), n = 0, r = () => (n++, `e9${String(n).padStart(5, "0")}`), i = [], a = [];
9
+ for (let e of t) {
10
+ if (!e.trim()) continue;
11
+ let t = e.trimStart(), n = e.length - t.length;
12
+ if (!t.startsWith("- ")) continue;
13
+ let s = t.slice(2), c = 0;
14
+ for (; c < s.length;) {
15
+ let e = s.charCodeAt(c);
16
+ if (e < 97 || e > 122) break;
17
+ c++;
18
+ }
19
+ if (c === 0) continue;
20
+ let l = s.slice(0, c);
21
+ s = s.slice(c);
22
+ let u;
23
+ if (s.startsWith(" \"")) {
24
+ let e = s.indexOf("\"", 2);
25
+ e > 1 && (u = s.slice(2, e), s = s.slice(e + 1));
26
+ }
27
+ let d = s.trimEnd(), f = /:$/.test(d);
28
+ f && (d = d.slice(0, -1));
29
+ let p = [], m, h, g = d.match(/\[ref=(e\d+)\]/);
30
+ g && (m = g[1]);
31
+ let _ = d.match(/\[box=(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\]/);
32
+ _ && (h = {
33
+ x: Number(_[1]),
34
+ y: Number(_[2]),
35
+ width: Number(_[3]),
36
+ height: Number(_[4])
37
+ });
38
+ let v = /\[([A-Za-z][A-Za-z0-9_=,.-]*)\]/g, y;
39
+ for (; (y = v.exec(d)) !== null;) {
40
+ let e = y[1] ?? "";
41
+ e.startsWith("ref=") || e.startsWith("box=") || p.push(e);
42
+ }
43
+ let b = {
44
+ ref: m ?? r(),
45
+ role: l,
46
+ ...u !== void 0 && u.length > 0 ? { name: u } : {},
47
+ ...h === void 0 ? {} : { bbox: h },
48
+ ...p.length > 0 ? { states: p } : {}
49
+ };
50
+ for (; a.length > 0 && (a[a.length - 1]?.indent ?? -1) >= n;) o(a.pop());
51
+ let x = {
52
+ indent: n,
53
+ node: b,
54
+ children: []
55
+ };
56
+ a.length === 0 ? i.push(b) : a[a.length - 1].children.push(b), f && a.push(x);
57
+ }
58
+ for (; a.length > 0;) o(a.pop());
59
+ function o(e) {
60
+ e.children.length > 0 && (e.node.children = e.children);
61
+ }
62
+ let s;
63
+ return s = i.length === 1 ? i[0] : {
64
+ ref: r(),
65
+ role: "generic",
66
+ children: i
67
+ }, {
68
+ version: 1,
69
+ source: {
70
+ kind: "playwright",
71
+ capturedAt: e.capturedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
72
+ ...e.agent === void 0 ? {} : { agent: e.agent },
73
+ ...e.url === void 0 ? {} : { url: e.url }
74
+ },
75
+ viewport: e.viewport,
76
+ root: s
77
+ };
78
+ }
79
+ async function m(e, t, n) {
80
+ let r = [];
81
+ u(e, (e) => {
82
+ !e.role || !e.name || r.push({
83
+ node: e,
84
+ role: e.role,
85
+ name: e.name
86
+ });
87
+ });
88
+ for (let { node: e, role: i, name: a } of r) {
89
+ let r = t.getByRole(i, {
90
+ name: a,
91
+ exact: !0
92
+ });
93
+ if (await r.count() !== 1) continue;
94
+ let o = await r.evaluate((e, t) => {
95
+ let n = {};
96
+ for (let r of t) {
97
+ let t = e.getAttribute(r);
98
+ t !== null && (n[r] = t);
99
+ }
100
+ return n;
101
+ }, n.whitelist);
102
+ Object.keys(o).length !== 0 && (e.attributes = o);
103
+ }
104
+ return e;
105
+ }
106
+ async function h(e, t = {}) {
107
+ let n = await (t.rootLocator ?? e.locator("body")).ariaSnapshot({
108
+ mode: "ai",
109
+ boxes: !0
110
+ }), r = e.viewportSize(), i = p({
111
+ yaml: n,
112
+ viewport: r ? {
113
+ width: r.width,
114
+ height: r.height,
115
+ scale: 1
116
+ } : {
117
+ width: 0,
118
+ height: 0,
119
+ scale: 1
120
+ },
121
+ url: t.url ?? e.url(),
122
+ ...t.agent === void 0 ? {} : { agent: t.agent },
123
+ ...t.capturedAt === void 0 ? {} : { capturedAt: t.capturedAt }
9
124
  });
10
- } });
11
- async function d(t, n, i) {
12
- let o = await n.screenshot({ fullPage: i.fullPage }), { width: s, height: c } = f(o), l = `data:image/png;base64,${o.toString("base64")}`, u = "annotations" in i && i.annotations !== void 0 ? r(i.annotations) : i.annotationsSvg ?? "", d = t.toPng({
125
+ return t.attributeWhitelist !== void 0 && t.attributeWhitelist.length > 0 && await m(i, e, { whitelist: t.attributeWhitelist }), i;
126
+ }
127
+ //#endregion
128
+ //#region src/rebase.ts
129
+ function g(e, t) {
130
+ let n = [], r = [];
131
+ for (let i of e) {
132
+ let e = _(i, t);
133
+ e ? n.push(e) : r.push(i);
134
+ }
135
+ return {
136
+ kept: n,
137
+ dropped: r
138
+ };
139
+ }
140
+ function _(e, t) {
141
+ switch (e.type) {
142
+ case "rect": return v(e, t);
143
+ case "numberedBadge": return y(e, t);
144
+ case "callout": return b(e, t);
145
+ case "circle": return x(e, t);
146
+ case "arrow": return S(e, t);
147
+ case "text": return C(e, t);
148
+ case "freehand": return T(e);
149
+ case "focusMask": return E(e, t);
150
+ case "raw": return w(e);
151
+ }
152
+ }
153
+ function v(e, t) {
154
+ return k(e.bbox, t) ? {
155
+ ...e,
156
+ bbox: D(e.bbox, t)
157
+ } : null;
158
+ }
159
+ function y(e, t) {
160
+ return k(e.bbox, t) ? {
161
+ ...e,
162
+ bbox: D(e.bbox, t),
163
+ imageWidth: t.width,
164
+ imageHeight: t.height
165
+ } : null;
166
+ }
167
+ function b(e, t) {
168
+ return !k(e.targetBbox, t) || !A(e.at, t) ? null : {
169
+ ...e,
170
+ at: O(e.at, t),
171
+ targetBbox: D(e.targetBbox, t)
172
+ };
173
+ }
174
+ function x(e, t) {
175
+ return k({
176
+ x: e.center.x - e.radius,
177
+ y: e.center.y - e.radius,
178
+ width: e.radius * 2,
179
+ height: e.radius * 2
180
+ }, t) ? {
181
+ ...e,
182
+ center: O(e.center, t)
183
+ } : null;
184
+ }
185
+ function S(e, t) {
186
+ return !A(e.from, t) || !A(e.to, t) ? null : {
187
+ ...e,
188
+ from: O(e.from, t),
189
+ to: O(e.to, t)
190
+ };
191
+ }
192
+ function C(e, t) {
193
+ return A(e.at, t) ? {
194
+ ...e,
195
+ at: O(e.at, t)
196
+ } : null;
197
+ }
198
+ function w(e) {
199
+ return e;
200
+ }
201
+ function T(e) {
202
+ return e;
203
+ }
204
+ function E(e, t) {
205
+ return k(e.cutout, t) ? {
206
+ ...e,
207
+ cutout: D(e.cutout, t),
208
+ imageWidth: t.width,
209
+ imageHeight: t.height
210
+ } : null;
211
+ }
212
+ function D(e, t) {
213
+ return {
214
+ x: e.x - t.x,
215
+ y: e.y - t.y,
216
+ width: e.width,
217
+ height: e.height
218
+ };
219
+ }
220
+ function O(e, t) {
221
+ return {
222
+ x: e.x - t.x,
223
+ y: e.y - t.y
224
+ };
225
+ }
226
+ function k(e, t) {
227
+ return e.x >= t.x && e.y >= t.y && e.x + e.width <= t.x + t.width && e.y + e.height <= t.y + t.height;
228
+ }
229
+ function A(e, t) {
230
+ return e.x >= t.x && e.y >= t.y && e.x <= t.x + t.width && e.y <= t.y + t.height;
231
+ }
232
+ function j(e) {
233
+ switch (e.type) {
234
+ case "rect":
235
+ case "numberedBadge": return `${e.type}@(${e.bbox.x},${e.bbox.y},${e.bbox.width},${e.bbox.height})`;
236
+ case "callout": return `callout@(${e.targetBbox.x},${e.targetBbox.y},${e.targetBbox.width},${e.targetBbox.height})`;
237
+ case "circle": return `circle@(${e.center.x},${e.center.y},r=${e.radius})`;
238
+ case "arrow": return `arrow@(${e.from.x},${e.from.y})→(${e.to.x},${e.to.y})`;
239
+ case "text": return `text@(${e.at.x},${e.at.y})`;
240
+ case "freehand": return `freehand[d=${e.path.slice(0, 32)}${e.path.length > 32 ? "…" : ""}]`;
241
+ case "focusMask": return `focusMask@(${e.cutout.x},${e.cutout.y},${e.cutout.width},${e.cutout.height})`;
242
+ case "raw": return "raw[<svg fragment>]";
243
+ }
244
+ }
245
+ //#endregion
246
+ //#region src/screenshot-hooks.ts
247
+ var M = Symbol.for("@ingcreators/annot:screenshot-patched"), N = [], P = /* @__PURE__ */ new Set([
248
+ "overlays",
249
+ "tags",
250
+ "editable"
251
+ ]);
252
+ function F(e) {
253
+ let t = e;
254
+ if (t[M]) return;
255
+ let n = e.screenshot;
256
+ e.screenshot = async function(e = {}) {
257
+ let t = e?.annot;
258
+ return I(t) ? z.call(this, n, e) : n.call(this, e);
259
+ }, t[M] = !0;
260
+ }
261
+ function I(e) {
262
+ if (!e || e === !0) return !1;
263
+ if (e.overlays && e.overlays.length > 0 || e.tags) return !0;
264
+ for (let t of Object.keys(e)) if (!P.has(t)) return !0;
265
+ return !1;
266
+ }
267
+ function L(e) {
268
+ return typeof e.boundingBox == "function";
269
+ }
270
+ function R(e) {
271
+ return L(e) ? e.page() : e;
272
+ }
273
+ async function z(e, t) {
274
+ let { annot: n, path: r, ...i } = t, a = i, o = n.editable ?? !0, s = {
275
+ annot: n,
276
+ page: R(this),
277
+ receiver: this
278
+ }, c = (await Promise.all(N.map((e) => e(s)))).filter((e) => e !== null), l = (n.overlays?.length ?? 0) > 0 || !!n.tags;
279
+ if (c.length === 0 && !l) return e.call(this, t);
280
+ for (let e of c) e.prepare && await e.prepare();
281
+ let u = await B(this, t.clip), f = await H({
282
+ rawBytes: V(await e.call(this, {
283
+ ...a,
284
+ path: void 0
285
+ })),
286
+ annot: n,
287
+ editable: o,
288
+ clip: u,
289
+ contributions: c
290
+ });
291
+ return r && await d(r, f), Buffer.from(f);
292
+ }
293
+ async function B(e, t) {
294
+ if (L(e)) {
295
+ let t = await e.boundingBox();
296
+ if (!t) throw Error("locator.screenshot({ annot }): locator has no bounding box (probably not visible). Re-test with a stable selector / waitFor().");
297
+ return t;
298
+ }
299
+ return t ?? null;
300
+ }
301
+ function V(e) {
302
+ return e instanceof Uint8Array && !(e instanceof Buffer) ? e : new Uint8Array(e.buffer, e.byteOffset, e.byteLength);
303
+ }
304
+ async function H(e) {
305
+ let { rawBytes: t, annot: n, editable: r, clip: a, contributions: o } = e, s = K(t), c = [], l = a ? {
306
+ width: a.x + a.width,
307
+ height: a.y + a.height
308
+ } : s;
309
+ for (let e of o) {
310
+ let t = await e.resolveAnnotations(l);
311
+ c.push(...t);
312
+ }
313
+ n.overlays && c.push(...n.overlays);
314
+ let u;
315
+ if (a) {
316
+ let { kept: e, dropped: t } = g(c, a);
317
+ u = e, t.length > 0 && W(t);
318
+ } else u = c;
319
+ let d = o.length > 0 || !!n.overlays;
320
+ if (d && !r) {
321
+ let e = U(u), r = `data:image/png;base64,${Buffer.from(t).toString("base64")}`, a = i({ loadSystemFonts: !0 }).toPng({
322
+ originalDataUrl: r,
323
+ annotationsSvg: e,
324
+ width: s.width,
325
+ height: s.height
326
+ });
327
+ return n.tags ? f(a, n.tags) : a;
328
+ }
329
+ if (d) {
330
+ let e = U(u), r = `data:image/png;base64,${Buffer.from(t).toString("base64")}`;
331
+ return i({ loadSystemFonts: !0 }).toEditablePng({
332
+ originalDataUrl: r,
333
+ annotationsSvg: e,
334
+ width: s.width,
335
+ height: s.height,
336
+ tags: n.tags
337
+ });
338
+ }
339
+ return n.tags ? f(t, n.tags) : t;
340
+ }
341
+ function U(e) {
342
+ return e.length === 0 ? "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>" : `<svg xmlns="http://www.w3.org/2000/svg">${r(e)}</svg>`;
343
+ }
344
+ function W(e) {
345
+ let t = e.map(j).join(", "), n = `annot fixture: dropped ${e.length} overlay(s) outside the screenshot clip — ${t}`;
346
+ console.warn(n), G(n, e);
347
+ }
348
+ function G(e, t) {
349
+ try {
350
+ let n = l.info?.();
351
+ if (!n?.annotations) return;
352
+ n.annotations.push({
353
+ type: "warning",
354
+ description: `${e} (${t.length} dropped)`
355
+ });
356
+ } catch {}
357
+ }
358
+ function K(e) {
359
+ if (e.length < 24) throw Error("Playwright annot fixture: raw screenshot too short to contain PNG IHDR.");
360
+ let t = new DataView(e.buffer, e.byteOffset, e.byteLength);
361
+ return {
362
+ width: t.getUint32(16, !1),
363
+ height: t.getUint32(20, !1)
364
+ };
365
+ }
366
+ //#endregion
367
+ //#region src/fixture.ts
368
+ var q = l.extend({
369
+ annotator: async ({}, e) => {
370
+ let t = i();
371
+ await e({
372
+ raw: t,
373
+ annotateScreenshot: (e, n) => J(t, e, n)
374
+ });
375
+ },
376
+ page: async ({ page: e }, t) => {
377
+ F(Object.getPrototypeOf(e)), F(Object.getPrototypeOf(e.locator("html"))), await t(e);
378
+ }
379
+ });
380
+ async function J(t, n, i) {
381
+ let o = await n.screenshot({ fullPage: i.fullPage }), { width: s, height: c } = Y(o), l = `data:image/png;base64,${o.toString("base64")}`, u = "annotations" in i && i.annotations !== void 0 ? r(i.annotations) : i.annotationsSvg ?? "", d = t.toPng({
13
382
  originalDataUrl: l,
14
383
  annotationsSvg: u,
15
384
  width: s,
@@ -20,7 +389,7 @@ async function d(t, n, i) {
20
389
  ...i.encode
21
390
  })).bytes;
22
391
  }
23
- function f(e) {
392
+ function Y(e) {
24
393
  if (e.length < 24) throw Error("PNG too short to contain IHDR chunk");
25
394
  let t = new DataView(e.buffer, e.byteOffset, e.byteLength);
26
395
  return {
@@ -29,4 +398,4 @@ function f(e) {
29
398
  };
30
399
  }
31
400
  //#endregion
32
- export { d as annotateScreenshot, t as arrowBetween, n as bboxAnnotationsToSvg, c as expect, o as rectForBoundingBox, u as test, s as textAt };
401
+ export { M as ANNOT_PATCHED, N as annotSourceResolvers, J as annotateScreenshot, t as arrowBetween, m as attachAttributes, n as bboxAnnotationsToSvg, h as captureElementTree, j as describeAnnotation, c as expect, F as patchScreenshot, p as playwrightYamlToElementTree, g as rebaseAnnotations, o as rectForBoundingBox, q as test, s as textAt };
@@ -0,0 +1,35 @@
1
+ import { BboxAnnotation } from '@ingcreators/annot-annotator';
2
+ export interface Clip {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height: number;
7
+ }
8
+ export interface RebaseResult {
9
+ /** Annotations whose coordinates were rebased into clip-space. */
10
+ kept: BboxAnnotation[];
11
+ /** Annotations that fell outside `clip` and were dropped. The
12
+ * caller surfaces these as `test.info().annotations` warnings. */
13
+ dropped: BboxAnnotation[];
14
+ }
15
+ /**
16
+ * Rebase + filter a `BboxAnnotation[]` against a clip rectangle.
17
+ *
18
+ * - `kept` = annotations whose visible coords were translated by
19
+ * `(-clip.x, -clip.y)`. The new coords are in the clipped image's
20
+ * coordinate space (0,0 at the top-left of the screenshot).
21
+ * - `dropped` = annotations whose bbox / endpoints fell outside the
22
+ * clip. Returned verbatim (un-rebased) so the caller can log
23
+ * which originals were skipped.
24
+ *
25
+ * `numberedBadge`'s `imageWidth` / `imageHeight` are also rebased
26
+ * to match the clip dimensions, so `placement: "auto"` picks the
27
+ * corner against the cropped image edge rather than the page edge.
28
+ */
29
+ export declare function rebaseAnnotations(annotations: BboxAnnotation[], clip: Clip): RebaseResult;
30
+ /**
31
+ * Format a short identifier for an annotation so dropped diagnostics
32
+ * stay readable. We don't have a stable id field on the DSL types —
33
+ * use `type` + first bbox coords as a heuristic.
34
+ */
35
+ export declare function describeAnnotation(ann: BboxAnnotation): string;
@@ -0,0 +1,134 @@
1
+ import { BboxAnnotation } from '@ingcreators/annot-annotator';
2
+ import { Locator, Page } from '@playwright/test';
3
+ /**
4
+ * Idempotency guard for the prototype patch — checked + set inside
5
+ * `patchScreenshot`. `Symbol.for(...)` cross-module realm-stable so
6
+ * re-importing annot-playwright in the same worker process picks up
7
+ * the existing patch instead of double-wrapping.
8
+ */
9
+ export declare const ANNOT_PATCHED: unique symbol;
10
+ /**
11
+ * Compositional options for `page.screenshot({ annot })` /
12
+ * `locator.screenshot({ annot })`. Each known field is an
13
+ * independent contribution to the output:
14
+ *
15
+ * - `overlays` — inline `BboxAnnotation[]` (the DSL accepted by
16
+ * `@ingcreators/annot-annotator`)
17
+ * - `tags` — provenance metadata written verbatim into the XMP
18
+ * - `editable` — bake-vs-preserve toggle (default `true`)
19
+ *
20
+ * Extra fields (e.g. `mdx`, future `figma`) are claimed by resolvers
21
+ * registered in [`annotSourceResolvers`](#annotSourceResolvers).
22
+ * Downstream packages declare those fields via TypeScript module
23
+ * augmentation:
24
+ *
25
+ * ```ts
26
+ * declare module "@ingcreators/annot-playwright" {
27
+ * interface AnnotScreenshotOptions {
28
+ * mdx?: { id: string; path: string };
29
+ * }
30
+ * }
31
+ * ```
32
+ *
33
+ * `annot: true` / `annot: {}` is treated as a no-op shorthand — the
34
+ * patch detects no contribution and the screenshot falls through to
35
+ * vanilla Playwright behaviour.
36
+ */
37
+ export interface AnnotScreenshotOptions {
38
+ /** Caller-supplied annotations — merged with any resolver-derived
39
+ * ones. Same DSL `@ingcreators/annot-annotator` accepts. */
40
+ overlays?: BboxAnnotation[];
41
+ /** Provenance metadata written verbatim into the PNG's XMP. The
42
+ * fixture adds no defaults; callers who want `WELL_KNOWN_TAG_KEYS`
43
+ * (`source` / `screen` / `capturedAt` / `commit`) write them. */
44
+ tags?: Record<string, string>;
45
+ /** When `true` (default): annotations stored as SVG in XMP +
46
+ * original capture embedded → re-editable in Annot Cloud. When
47
+ * `false`: annotations baked into the visible pixels, no XMP
48
+ * layer, no embedded original — flat PNG, no round-trip. */
49
+ editable?: boolean;
50
+ }
51
+ declare module "@playwright/test" {
52
+ interface PageScreenshotOptions {
53
+ annot?: AnnotScreenshotOptions;
54
+ }
55
+ interface LocatorScreenshotOptions {
56
+ annot?: AnnotScreenshotOptions;
57
+ }
58
+ }
59
+ /**
60
+ * Context passed to every resolver registered in
61
+ * [`annotSourceResolvers`](#annotSourceResolvers).
62
+ */
63
+ export interface AnnotSourceContext {
64
+ /** The `annot` option as supplied by the caller. Resolvers
65
+ * inspect this for the fields they're responsible for (e.g. an
66
+ * MDX resolver reads `annot.mdx`). */
67
+ annot: AnnotScreenshotOptions;
68
+ /** `Page` for `page.screenshot()`; `Locator.page()` for
69
+ * `locator.screenshot()`. Resolvers should always operate
70
+ * against the full page — clip rebasing happens in the patch
71
+ * pipeline after resolvers return. */
72
+ page: Page;
73
+ /** The receiver of the original `screenshot(...)` call. Either
74
+ * `Page` (for `page.screenshot`) or `Locator` (for
75
+ * `locator.screenshot`). Most resolvers only need
76
+ * [`page`](#page), but the locator handle is exposed for
77
+ * resolvers that want to read its bounding box etc. */
78
+ receiver: Page | Locator;
79
+ }
80
+ /**
81
+ * One source-of-annotations contribution returned from a resolver.
82
+ *
83
+ * `prepare()` runs serially in registration order BEFORE the
84
+ * screenshot is taken — e.g. an MDX resolver rewrites the
85
+ * `annot:snapshot` block here so the resolved bboxes match the
86
+ * about-to-be-captured DOM.
87
+ *
88
+ * `resolveAnnotations(dims)` runs AFTER the raw screenshot is taken
89
+ * and receives the page-space dimensions (clip-aware:
90
+ * `{ width: clip.x + clip.width, height: clip.y + clip.height }`).
91
+ * Returned annotations are in page-space; the patch pipeline
92
+ * rebases them onto the clipped image afterwards.
93
+ */
94
+ export interface AnnotSourceContribution {
95
+ /** Optional side effect to run BEFORE the raw screenshot is
96
+ * taken. Runs serially in registration order. */
97
+ prepare?: () => Promise<void>;
98
+ /** Resolver-derived page-space annotations. The patch pipeline
99
+ * rebases them onto clip-space if a clip is in effect. */
100
+ resolveAnnotations: (dims: {
101
+ width: number;
102
+ height: number;
103
+ }) => Promise<BboxAnnotation[]>;
104
+ }
105
+ /**
106
+ * A resolver inspects `ctx.annot` for fields it cares about and
107
+ * returns a contribution, or `null` if the call carries nothing it
108
+ * recognises. The registry is walked once per `page.screenshot({
109
+ * annot })` call; resolvers MUST be idempotent (no side effects
110
+ * unless triggered by their own field's presence).
111
+ */
112
+ export type AnnotSourceResolver = (ctx: AnnotSourceContext) => Promise<AnnotSourceContribution | null>;
113
+ /**
114
+ * Module-level resolver registry. Packages that extend the patch
115
+ * (e.g. `@ingcreators/annot-product-docs` for MDX-derived overlays)
116
+ * push their resolver at module load time:
117
+ *
118
+ * ```ts
119
+ * import { annotSourceResolvers } from "@ingcreators/annot-playwright";
120
+ *
121
+ * annotSourceResolvers.push(async ({ annot, page }) => {
122
+ * if (!annot.mdx) return null;
123
+ * return {
124
+ * prepare: () => refreshMdxSnapshot(page, annot.mdx),
125
+ * resolveAnnotations: (dims) => readMdxOverlays(annot.mdx, dims),
126
+ * };
127
+ * });
128
+ * ```
129
+ *
130
+ * One singleton per process — the `Symbol.for(...)` lookup makes
131
+ * the registry stable across realm boundaries the way the
132
+ * idempotency symbol on the prototype is.
133
+ */
134
+ export declare const annotSourceResolvers: AnnotSourceResolver[];
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Idempotent prototype patch — wrap `screenshot` to intercept the
3
+ * `annot` opt while falling through to the original method when
4
+ * absent / empty. Exported for unit tests; production usage flows
5
+ * through the fixture `extend({ page })` body which calls this once
6
+ * per worker on the `Page` AND `Locator` prototypes.
7
+ */
8
+ export declare function patchScreenshot(proto: {
9
+ screenshot: (opts?: unknown) => Promise<Buffer>;
10
+ }): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingcreators/annot-playwright",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Playwright fixture for annotated screenshots — emit annotated PNGs from failing tests without leaving the test file. Pairs `test.extend({ annotator })` with the headless renderer from @ingcreators/annot-annotator.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "ingcreators",
@@ -42,13 +42,14 @@
42
42
  "access": "public"
43
43
  },
44
44
  "devDependencies": {
45
- "@playwright/test": "^1.50.0",
45
+ "@playwright/test": "^1.61.1",
46
46
  "typescript": "^6.0.3",
47
- "vite": "^8.0.13",
48
- "vite-plugin-dts": "^5.0.1"
47
+ "vite": "^8.0.16",
48
+ "vite-plugin-dts": "^5.0.3"
49
49
  },
50
50
  "dependencies": {
51
- "@ingcreators/annot-annotator": "0.5.0"
51
+ "@ingcreators/annot-annotator": "0.6.0",
52
+ "@ingcreators/annot-core": "0.3.0"
52
53
  },
53
54
  "peerDependencies": {
54
55
  "@playwright/test": "^1.40.0"