@ingcreators/annot-playwright 0.3.0 → 0.4.0

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,169 @@
1
1
  # @ingcreators/annot-playwright
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f979374: **`page.screenshot({ annot: { … } })` patch relocated to
8
+ annot-playwright** — Phase 1 of
9
+ `docs/plans/playwright-screenshot-fixture-relayer.md`. The
10
+ prototype patch that lets Playwright tests emit annotated PNGs by
11
+ adding a nested `annot:` option to `page.screenshot()` /
12
+ `locator.screenshot()` lives here now (it shipped first in
13
+ `@ingcreators/annot-product-docs-astro/playwright` but is not
14
+ Astro-specific). VRT / marketing-screenshot / AI-agent flows pick
15
+ up the API without taking an Astro dependency.
16
+
17
+ ```ts
18
+ import { test, expect } from "@ingcreators/annot-playwright";
19
+
20
+ test("login form callouts", async ({ page }) => {
21
+ await page.goto("/login");
22
+ await page.screenshot({
23
+ path: "login.png",
24
+ annot: {
25
+ overlays: [
26
+ {
27
+ type: "rect",
28
+ bbox: { x: 10, y: 10, width: 200, height: 30 },
29
+ intent: "warning",
30
+ },
31
+ {
32
+ type: "numberedBadge",
33
+ bbox: { x: 10, y: 10, width: 24, height: 24 },
34
+ number: 1,
35
+ },
36
+ ],
37
+ tags: { source: "vrt-failure", testId: "login" },
38
+ // editable: true is the default → output PNG is re-editable
39
+ // in Annot Cloud.
40
+ },
41
+ });
42
+ });
43
+ ```
44
+
45
+ Three independent contributions compose into the output PNG:
46
+ - `overlays: BboxAnnotation[]` — inline annotations (rect /
47
+ circle / arrow / text / callout / numberedBadge / raw SVG).
48
+ - `tags: Record<string, string>` — provenance metadata
49
+ serialised into the PNG's XMP (or iTXt sidecar when no
50
+ overlays are present).
51
+ - `editable: boolean` (default `true`) — re-editable wrap
52
+ (annotations + original capture in XMP) vs. flat baked PNG.
53
+
54
+ `Locator.prototype.screenshot` and `Page.prototype.screenshot({
55
+ clip })` both go through the same pipeline; page-space overlay
56
+ coordinates are automatically rebased into the clipped image's
57
+ coordinate space. Overlays whose bbox falls outside the clip are
58
+ dropped with a warning annotation on `test.info()`.
59
+
60
+ **Extension hook registry** — `annotSourceResolvers` is a
61
+ module-level array of resolvers that downstream packages push
62
+ into to claim extra `annot.*` fields:
63
+
64
+ ```ts
65
+ import { annotSourceResolvers } from "@ingcreators/annot-playwright";
66
+
67
+ annotSourceResolvers.push(async ({ annot, page }) => {
68
+ if (!annot.figma) return null;
69
+ return {
70
+ prepare: () => refreshFigmaCache(annot.figma),
71
+ resolveAnnotations: (dims) => readFigmaOverlays(annot.figma, dims),
72
+ };
73
+ });
74
+ ```
75
+
76
+ The resolver's `prepare()` hook fires before the raw screenshot
77
+ is taken (so MDX-aware adapters can refresh `annot:snapshot`
78
+ blocks against the live DOM); `resolveAnnotations(dims)` runs
79
+ after with the page-space dimensions and returns
80
+ `BboxAnnotation[]` to merge into the output. annot-playwright
81
+ stays MDX-unaware — the matching MDX resolver moves into
82
+ `@ingcreators/annot-product-docs` in Phase 2 of the parent plan.
83
+
84
+ **Coordinate-rebase API** — `rebaseAnnotations` /
85
+ `describeAnnotation` / `Clip` / `RebaseResult` exported for
86
+ callers who want to rebase annotations themselves without going
87
+ through the patch (e.g. building custom test reporters):
88
+
89
+ ```ts
90
+ import { rebaseAnnotations } from "@ingcreators/annot-playwright";
91
+
92
+ const { kept, dropped } = rebaseAnnotations(annotations, clip);
93
+ ```
94
+
95
+ **Compatibility** — additive. The existing
96
+ `@ingcreators/annot-product-docs-astro/playwright` subpath stays
97
+ working unchanged for this release; Phase 4 of the parent plan
98
+ converts it into a deprecated re-export.
99
+
100
+ ### Patch Changes
101
+
102
+ - 85d40e6: **Docs + CLAUDE.md + plan archive** — Phase 5 (final) of
103
+ `docs/plans/_done/playwright-screenshot-fixture-relayer.md`.
104
+ Refreshes the doc surfaces, the operational CLAUDE.md notes,
105
+ and archives the relayer plan into `_done/`.
106
+
107
+ ## Doc surface updates
108
+ - `packages/docs-site/src/content/docs/product-docs/playwright-fixture.mdx`
109
+ — recommended import paths updated; new "Choosing your import"
110
+ section covers the `@ingcreators/annot-product-docs` (MDX)
111
+ vs. `@ingcreators/annot-playwright` (no MDX) split; companion
112
+ helpers section now imports from the canonical homes; codegen
113
+ workflow example swapped to `@ingcreators/annot-product-docs`.
114
+ - `packages/docs-site/src/content/docs/api/create-annotator.mdx`
115
+ — "From a Playwright test" example imports from the canonical
116
+ home; mentions the no-MDX alternative.
117
+ - `packages/playwright/README.md` — adds the
118
+ `page.screenshot({ annot: { … } })` (recommended) section
119
+ above the existing `annotator.annotateScreenshot(...)` flow;
120
+ documents the `annotSourceResolvers` extension hook + the
121
+ coordinate-rebase helpers.
122
+ - `packages/product-docs/README.md` — adds a `page.screenshot({
123
+ annot })` Playwright fixture section explaining the
124
+ productDocs.sync + MDX-resolver bundle.
125
+ - `packages/product-docs-astro/README.md` — replaces the
126
+ Playwright fixture section with a Migration note pointing at
127
+ the canonical homes; documents the `0.5.0` removal target.
128
+
129
+ ## CLAUDE.md monorepo layout
130
+ - `playwright/` entry now describes the canonical
131
+ `page.screenshot({ annot })` patch + `annotSourceResolvers`
132
+ extension hook. Version bumped to 0.4.0.
133
+ - `product-docs/` entry mentions the `productDocs` fixture
134
+ rename + the MDX-aware resolver registration via the hook
135
+ registry. Version bumped to 0.3.0.
136
+ - `product-docs-astro/` entry calls out the deprecated
137
+ `/playwright` re-export shim + 0.5.0 removal target.
138
+ `@playwright/test` no longer a peer dep. Version bumped to
139
+ 0.3.0.
140
+
141
+ ## Plan archive
142
+ - `docs/plans/playwright-screenshot-fixture-relayer.md` →
143
+ `docs/plans/_done/playwright-screenshot-fixture-relayer.md`.
144
+ Status header switched to `Done` with the four landing PRs
145
+ enumerated. Internal `./_done/...` link paths updated to
146
+ `./...` (the plan is itself inside `_done/` now).
147
+ - `docs/plans/README.md` — removed the active entry, added a
148
+ "Recently landed plans" row pointing at the archived plan
149
+ with a multi-phase summary covering all four landing PRs.
150
+ - `docs/plans/living-spec-authoring-roadmap.md` — three link
151
+ references updated to point at the archived path; the "How
152
+ this relates" line switched from "Already Draft" to "Landed
153
+ 2026-05-22 (PRs #962 / #963 / #964 / #966)".
154
+
155
+ No source code changes; verified via `pnpm -r typecheck`,
156
+ `pnpm test`, `pnpm lint` regardless to confirm the doc/comment
157
+ edits parse cleanly.
158
+
159
+ ## 0.3.1
160
+
161
+ ### Patch Changes
162
+
163
+ - Updated dependencies [806badc]
164
+ - Updated dependencies [df1a429]
165
+ - @ingcreators/annot-annotator@0.5.0
166
+
3
167
  ## 0.3.0
4
168
 
5
169
  ### Minor 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
@@ -161,7 +249,7 @@ const annotator = createAnnotator({
161
249
  — Phase 0 feasibility report.
162
250
  - [`docs/plans/_done/annot-annotator-package.md`](../../docs/plans/_done/annot-annotator-package.md)
163
251
  — Phase 1 annotator design.
164
- - [`docs/plans/annot-playwright-fixture.md`](../../docs/plans/annot-playwright-fixture.md)
252
+ - [`docs/plans/_done/annot-playwright-fixture.md`](../../docs/plans/_done/annot-playwright-fixture.md)
165
253
  — Phase 2 design (this package).
166
254
  - [`PRODUCT_DIRECTION.md`](../../PRODUCT_DIRECTION.md) — strategic
167
255
  context.
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
@@ -3,3 +3,6 @@ export { bboxAnnotationsToSvg } from '@ingcreators/annot-annotator';
3
3
  export { expect } from '@playwright/test';
4
4
  export { type AnnotateScreenshotOptions, annotateScreenshot, type PageLike, type PlaywrightAnnotator, test, } from './fixture.js';
5
5
  export { type ArrowOptions, arrowBetween, type BoundingBox, type RectOptions, rectForBoundingBox, type TextOptions, textAt, } from './helpers.js';
6
+ export { type Clip, describeAnnotation, type RebaseResult, rebaseAnnotations, } from './rebase.js';
7
+ export { ANNOT_PATCHED, type AnnotScreenshotOptions, type AnnotSourceContext, type AnnotSourceContribution, type AnnotSourceResolver, annotSourceResolvers, } from './screenshot-hooks.js';
8
+ export { patchScreenshot } from './screenshot-patch.js';
package/dist/index.js CHANGED
@@ -1,41 +1,266 @@
1
- import { createAnnotator as d, bboxAnnotationsToSvg as f, DEFAULT_ENCODE_OPTIONS as h, decodeAndEncodeImage as l } from "@ingcreators/annot-annotator";
2
- import { arrowBetween as A, bboxAnnotationsToSvg as D, rectForBoundingBox as O, textAt as E } from "@ingcreators/annot-annotator";
3
- import { test as w } from "@playwright/test";
4
- import { expect as T } from "@playwright/test";
5
- const P = w.extend({
6
- // biome-ignore lint/correctness/noEmptyPattern: Playwright fixture signature requires the empty destructure.
7
- annotator: async ({}, n) => {
8
- const e = d();
9
- await n({
10
- raw: e,
11
- annotateScreenshot: (t, o) => u(e, t, o)
12
- });
13
- }
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
+ import { expect as c, test as l } from "@playwright/test";
3
+ import { writePngWithTagsOnly as u } from "@ingcreators/annot-core/xmp-bytes";
4
+ //#endregion
5
+ //#region src/rebase.ts
6
+ var d = (/* @__PURE__ */ ((e, t) => () => (t || (e((t = { exports: {} }).exports, t), e = null), t.exports))(((e, t) => {
7
+ t.exports = {};
8
+ })))();
9
+ function f(e, t) {
10
+ let n = [], r = [];
11
+ for (let i of e) {
12
+ let e = p(i, t);
13
+ e ? n.push(e) : r.push(i);
14
+ }
15
+ return {
16
+ kept: n,
17
+ dropped: r
18
+ };
19
+ }
20
+ function p(e, t) {
21
+ switch (e.type) {
22
+ case "rect": return m(e, t);
23
+ case "numberedBadge": return h(e, t);
24
+ case "callout": return g(e, t);
25
+ case "circle": return _(e, t);
26
+ case "arrow": return v(e, t);
27
+ case "text": return y(e, t);
28
+ case "raw": return b(e);
29
+ }
30
+ }
31
+ function m(e, t) {
32
+ return C(e.bbox, t) ? {
33
+ ...e,
34
+ bbox: x(e.bbox, t)
35
+ } : null;
36
+ }
37
+ function h(e, t) {
38
+ return C(e.bbox, t) ? {
39
+ ...e,
40
+ bbox: x(e.bbox, t),
41
+ imageWidth: t.width,
42
+ imageHeight: t.height
43
+ } : null;
44
+ }
45
+ function g(e, t) {
46
+ return !C(e.targetBbox, t) || !w(e.at, t) ? null : {
47
+ ...e,
48
+ at: S(e.at, t),
49
+ targetBbox: x(e.targetBbox, t)
50
+ };
51
+ }
52
+ function _(e, t) {
53
+ return C({
54
+ x: e.center.x - e.radius,
55
+ y: e.center.y - e.radius,
56
+ width: e.radius * 2,
57
+ height: e.radius * 2
58
+ }, t) ? {
59
+ ...e,
60
+ center: S(e.center, t)
61
+ } : null;
62
+ }
63
+ function v(e, t) {
64
+ return !w(e.from, t) || !w(e.to, t) ? null : {
65
+ ...e,
66
+ from: S(e.from, t),
67
+ to: S(e.to, t)
68
+ };
69
+ }
70
+ function y(e, t) {
71
+ return w(e.at, t) ? {
72
+ ...e,
73
+ at: S(e.at, t)
74
+ } : null;
75
+ }
76
+ function b(e) {
77
+ return e;
78
+ }
79
+ function x(e, t) {
80
+ return {
81
+ x: e.x - t.x,
82
+ y: e.y - t.y,
83
+ width: e.width,
84
+ height: e.height
85
+ };
86
+ }
87
+ function S(e, t) {
88
+ return {
89
+ x: e.x - t.x,
90
+ y: e.y - t.y
91
+ };
92
+ }
93
+ function C(e, t) {
94
+ 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;
95
+ }
96
+ function w(e, t) {
97
+ return e.x >= t.x && e.y >= t.y && e.x <= t.x + t.width && e.y <= t.y + t.height;
98
+ }
99
+ function T(e) {
100
+ switch (e.type) {
101
+ case "rect":
102
+ case "numberedBadge": return `${e.type}@(${e.bbox.x},${e.bbox.y},${e.bbox.width},${e.bbox.height})`;
103
+ case "callout": return `callout@(${e.targetBbox.x},${e.targetBbox.y},${e.targetBbox.width},${e.targetBbox.height})`;
104
+ case "circle": return `circle@(${e.center.x},${e.center.y},r=${e.radius})`;
105
+ case "arrow": return `arrow@(${e.from.x},${e.from.y})→(${e.to.x},${e.to.y})`;
106
+ case "text": return `text@(${e.at.x},${e.at.y})`;
107
+ case "raw": return "raw[<svg fragment>]";
108
+ }
109
+ }
110
+ //#endregion
111
+ //#region src/screenshot-hooks.ts
112
+ var E = Symbol.for("@ingcreators/annot:screenshot-patched"), D = [], O = new Set([
113
+ "overlays",
114
+ "tags",
115
+ "editable"
116
+ ]);
117
+ function k(e) {
118
+ let t = e;
119
+ if (t[E]) return;
120
+ let n = e.screenshot;
121
+ e.screenshot = async function(e = {}) {
122
+ let t = e?.annot;
123
+ return A(t) ? N.call(this, n, e) : n.call(this, e);
124
+ }, t[E] = !0;
125
+ }
126
+ function A(e) {
127
+ if (!e || e === !0) return !1;
128
+ if (e.overlays && e.overlays.length > 0 || e.tags) return !0;
129
+ for (let t of Object.keys(e)) if (!O.has(t)) return !0;
130
+ return !1;
131
+ }
132
+ function j(e) {
133
+ return typeof e.boundingBox == "function";
134
+ }
135
+ function M(e) {
136
+ return j(e) ? e.page() : e;
137
+ }
138
+ async function N(e, t) {
139
+ let { annot: n, path: r, ...i } = t, a = i, o = n.editable ?? !0, s = {
140
+ annot: n,
141
+ page: M(this),
142
+ receiver: this
143
+ }, c = (await Promise.all(D.map((e) => e(s)))).filter((e) => e !== null), l = (n.overlays?.length ?? 0) > 0 || !!n.tags;
144
+ if (c.length === 0 && !l) return e.call(this, t);
145
+ for (let e of c) e.prepare && await e.prepare();
146
+ let u = await P(this, t.clip), f = await I({
147
+ rawBytes: F(await e.call(this, {
148
+ ...a,
149
+ path: void 0
150
+ })),
151
+ annot: n,
152
+ editable: o,
153
+ clip: u,
154
+ contributions: c
155
+ });
156
+ return r && await (0, d.writeFile)(r, f), Buffer.from(f);
157
+ }
158
+ async function P(e, t) {
159
+ if (j(e)) {
160
+ let t = await e.boundingBox();
161
+ if (!t) throw Error("locator.screenshot({ annot }): locator has no bounding box (probably not visible). Re-test with a stable selector / waitFor().");
162
+ return t;
163
+ }
164
+ return t ?? null;
165
+ }
166
+ function F(e) {
167
+ return e instanceof Uint8Array && !(e instanceof Buffer) ? e : new Uint8Array(e.buffer, e.byteOffset, e.byteLength);
168
+ }
169
+ async function I(e) {
170
+ let { rawBytes: t, annot: n, editable: r, clip: a, contributions: o } = e, s = B(t), c = [], l = a ? {
171
+ width: a.x + a.width,
172
+ height: a.y + a.height
173
+ } : s;
174
+ for (let e of o) {
175
+ let t = await e.resolveAnnotations(l);
176
+ c.push(...t);
177
+ }
178
+ n.overlays && c.push(...n.overlays);
179
+ let d;
180
+ if (a) {
181
+ let { kept: e, dropped: t } = f(c, a);
182
+ d = e, t.length > 0 && R(t);
183
+ } else d = c;
184
+ let p = o.length > 0 || !!n.overlays;
185
+ if (p && !r) {
186
+ let e = L(d), r = `data:image/png;base64,${Buffer.from(t).toString("base64")}`, a = i({ loadSystemFonts: !0 }).toPng({
187
+ originalDataUrl: r,
188
+ annotationsSvg: e,
189
+ width: s.width,
190
+ height: s.height
191
+ });
192
+ return n.tags ? u(a, n.tags) : a;
193
+ }
194
+ if (p) {
195
+ let e = L(d), r = `data:image/png;base64,${Buffer.from(t).toString("base64")}`;
196
+ return i({ loadSystemFonts: !0 }).toEditablePng({
197
+ originalDataUrl: r,
198
+ annotationsSvg: e,
199
+ width: s.width,
200
+ height: s.height,
201
+ tags: n.tags
202
+ });
203
+ }
204
+ return n.tags ? u(t, n.tags) : t;
205
+ }
206
+ function L(e) {
207
+ return e.length === 0 ? "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>" : `<svg xmlns="http://www.w3.org/2000/svg">${r(e)}</svg>`;
208
+ }
209
+ function R(e) {
210
+ let t = e.map(T).join(", "), n = `annot fixture: dropped ${e.length} overlay(s) outside the screenshot clip — ${t}`;
211
+ console.warn(n), z(n, e);
212
+ }
213
+ function z(e, t) {
214
+ try {
215
+ let n = l.info?.();
216
+ if (!n?.annotations) return;
217
+ n.annotations.push({
218
+ type: "warning",
219
+ description: `${e} (${t.length} dropped)`
220
+ });
221
+ } catch {}
222
+ }
223
+ function B(e) {
224
+ if (e.length < 24) throw Error("Playwright annot fixture: raw screenshot too short to contain PNG IHDR.");
225
+ let t = new DataView(e.buffer, e.byteOffset, e.byteLength);
226
+ return {
227
+ width: t.getUint32(16, !1),
228
+ height: t.getUint32(20, !1)
229
+ };
230
+ }
231
+ //#endregion
232
+ //#region src/fixture.ts
233
+ var V = l.extend({
234
+ annotator: async ({}, e) => {
235
+ let t = i();
236
+ await e({
237
+ raw: t,
238
+ annotateScreenshot: (e, n) => H(t, e, n)
239
+ });
240
+ },
241
+ page: async ({ page: e }, t) => {
242
+ k(Object.getPrototypeOf(e)), k(Object.getPrototypeOf(e.locator("html"))), await t(e);
243
+ }
14
244
  });
15
- async function u(n, e, t) {
16
- const o = await e.screenshot({ fullPage: t.fullPage }), { width: r, height: i } = b(o), s = `data:image/png;base64,${o.toString("base64")}`, c = "annotations" in t && t.annotations !== void 0 ? f(t.annotations) : t.annotationsSvg ?? "", a = n.toPng({
17
- originalDataUrl: s,
18
- annotationsSvg: c,
19
- width: r,
20
- height: i
21
- });
22
- if (!t.encode || Object.keys(t.encode).length === 0)
23
- return a;
24
- const g = { ...h, ...t.encode };
25
- return (await l(a, g)).bytes;
26
- }
27
- function b(n) {
28
- if (n.length < 24)
29
- throw new Error("PNG too short to contain IHDR chunk");
30
- const e = new DataView(n.buffer, n.byteOffset, n.byteLength), t = e.getUint32(16, !1), o = e.getUint32(20, !1);
31
- return { width: t, height: o };
32
- }
33
- export {
34
- u as annotateScreenshot,
35
- A as arrowBetween,
36
- D as bboxAnnotationsToSvg,
37
- T as expect,
38
- O as rectForBoundingBox,
39
- P as test,
40
- E as textAt
41
- };
245
+ async function H(t, n, i) {
246
+ let o = await n.screenshot({ fullPage: i.fullPage }), { width: s, height: c } = U(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({
247
+ originalDataUrl: l,
248
+ annotationsSvg: u,
249
+ width: s,
250
+ height: c
251
+ });
252
+ return !i.encode || Object.keys(i.encode).length === 0 ? d : (await a(d, {
253
+ ...e,
254
+ ...i.encode
255
+ })).bytes;
256
+ }
257
+ function U(e) {
258
+ if (e.length < 24) throw Error("PNG too short to contain IHDR chunk");
259
+ let t = new DataView(e.buffer, e.byteOffset, e.byteLength);
260
+ return {
261
+ width: t.getUint32(16, !1),
262
+ height: t.getUint32(20, !1)
263
+ };
264
+ }
265
+ //#endregion
266
+ export { E as ANNOT_PATCHED, D as annotSourceResolvers, H as annotateScreenshot, t as arrowBetween, n as bboxAnnotationsToSvg, T as describeAnnotation, c as expect, k as patchScreenshot, f as rebaseAnnotations, o as rectForBoundingBox, V 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.0",
3
+ "version": "0.4.0",
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",
@@ -44,11 +44,12 @@
44
44
  "devDependencies": {
45
45
  "@playwright/test": "^1.50.0",
46
46
  "typescript": "^6.0.3",
47
- "vite": "^6.4.2",
48
- "vite-plugin-dts": "^5.0.0"
47
+ "vite": "^8.0.13",
48
+ "vite-plugin-dts": "^5.0.1"
49
49
  },
50
50
  "dependencies": {
51
- "@ingcreators/annot-annotator": "0.3.0"
51
+ "@ingcreators/annot-annotator": "0.5.0",
52
+ "@ingcreators/annot-core": "0.2.1"
52
53
  },
53
54
  "peerDependencies": {
54
55
  "@playwright/test": "^1.40.0"