@ingcreators/annot-playwright 0.3.1 → 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 +156 -0
- package/README.md +88 -0
- package/dist/fixture.d.ts +14 -3
- package/dist/index.d.ts +3 -0
- package/dist/index.js +245 -11
- package/dist/rebase.d.ts +35 -0
- package/dist/screenshot-hooks.d.ts +134 -0
- package/dist/screenshot-patch.d.ts +10 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,161 @@
|
|
|
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
|
+
|
|
3
159
|
## 0.3.1
|
|
4
160
|
|
|
5
161
|
### 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
|
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
|
|
58
|
-
* `@playwright/test`'s `test` with
|
|
59
|
-
*
|
|
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,15 +1,249 @@
|
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
9
155
|
});
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
}
|
|
244
|
+
});
|
|
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({
|
|
13
247
|
originalDataUrl: l,
|
|
14
248
|
annotationsSvg: u,
|
|
15
249
|
width: s,
|
|
@@ -20,7 +254,7 @@ async function d(t, n, i) {
|
|
|
20
254
|
...i.encode
|
|
21
255
|
})).bytes;
|
|
22
256
|
}
|
|
23
|
-
function
|
|
257
|
+
function U(e) {
|
|
24
258
|
if (e.length < 24) throw Error("PNG too short to contain IHDR chunk");
|
|
25
259
|
let t = new DataView(e.buffer, e.byteOffset, e.byteLength);
|
|
26
260
|
return {
|
|
@@ -29,4 +263,4 @@ function f(e) {
|
|
|
29
263
|
};
|
|
30
264
|
}
|
|
31
265
|
//#endregion
|
|
32
|
-
export {
|
|
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 };
|
package/dist/rebase.d.ts
ADDED
|
@@ -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
|
+
"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",
|
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
"vite-plugin-dts": "^5.0.1"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@ingcreators/annot-annotator": "0.5.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"
|