uidex 0.6.0 → 0.8.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/README.md +3 -3
- package/dist/cli/cli.cjs +2502 -2253
- package/dist/cli/cli.cjs.map +1 -1
- package/dist/headless/index.cjs +86 -703
- package/dist/headless/index.cjs.map +1 -1
- package/dist/headless/index.d.cts +46 -22
- package/dist/headless/index.d.ts +46 -22
- package/dist/headless/index.js +86 -707
- package/dist/headless/index.js.map +1 -1
- package/dist/index.cjs +712 -4149
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +82 -366
- package/dist/index.d.ts +82 -366
- package/dist/index.js +708 -4156
- package/dist/index.js.map +1 -1
- package/dist/playwright/index.cjs +175 -0
- package/dist/playwright/index.cjs.map +1 -1
- package/dist/playwright/index.d.cts +2 -0
- package/dist/playwright/index.d.ts +2 -0
- package/dist/playwright/index.js +167 -0
- package/dist/playwright/index.js.map +1 -1
- package/dist/playwright/states-reporter.cjs +123 -0
- package/dist/playwright/states-reporter.cjs.map +1 -0
- package/dist/playwright/states-reporter.d.cts +46 -0
- package/dist/playwright/states-reporter.d.ts +46 -0
- package/dist/playwright/states-reporter.js +88 -0
- package/dist/playwright/states-reporter.js.map +1 -0
- package/dist/playwright/states.cjs +118 -0
- package/dist/playwright/states.cjs.map +1 -0
- package/dist/playwright/states.d.cts +120 -0
- package/dist/playwright/states.d.ts +120 -0
- package/dist/playwright/states.js +88 -0
- package/dist/playwright/states.js.map +1 -0
- package/dist/react/index.cjs +750 -4113
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +78 -278
- package/dist/react/index.d.ts +78 -278
- package/dist/react/index.js +748 -4135
- package/dist/react/index.js.map +1 -1
- package/dist/scan/index.cjs +2694 -1541
- package/dist/scan/index.cjs.map +1 -1
- package/dist/scan/index.d.cts +482 -19
- package/dist/scan/index.d.ts +482 -19
- package/dist/scan/index.js +2682 -1540
- package/dist/scan/index.js.map +1 -1
- package/package.json +14 -17
- package/templates/claude/SKILL.md +71 -0
- package/templates/claude/references/audit.md +43 -0
- package/templates/claude/{rules.md → references/conventions.md} +25 -28
- package/dist/cloud/index.cjs +0 -472
- package/dist/cloud/index.cjs.map +0 -1
- package/dist/cloud/index.d.cts +0 -82
- package/dist/cloud/index.d.ts +0 -82
- package/dist/cloud/index.js +0 -445
- package/dist/cloud/index.js.map +0 -1
- package/templates/claude/audit.md +0 -43
- /package/templates/claude/{api.md → references/api.md} +0 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/integrations/playwright/states-reporter.ts
|
|
31
|
+
var states_reporter_exports = {};
|
|
32
|
+
__export(states_reporter_exports, {
|
|
33
|
+
aggregateStates: () => aggregateStates,
|
|
34
|
+
default: () => UidexStatesReporter
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(states_reporter_exports);
|
|
37
|
+
var fs = __toESM(require("fs"), 1);
|
|
38
|
+
var path = __toESM(require("path"), 1);
|
|
39
|
+
|
|
40
|
+
// src/integrations/playwright/states.ts
|
|
41
|
+
var STATES_ATTACHMENT = "uidex-states";
|
|
42
|
+
|
|
43
|
+
// src/integrations/playwright/states-reporter.ts
|
|
44
|
+
function keyOf(r) {
|
|
45
|
+
return JSON.stringify([
|
|
46
|
+
r.kind ?? null,
|
|
47
|
+
r.entity,
|
|
48
|
+
r.state,
|
|
49
|
+
r.route ?? null,
|
|
50
|
+
r.url ?? null,
|
|
51
|
+
r.stateKind ?? null
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
function aggregateStates(variants) {
|
|
55
|
+
const seen = /* @__PURE__ */ new Map();
|
|
56
|
+
for (const v of variants) {
|
|
57
|
+
const k = keyOf(v);
|
|
58
|
+
if (seen.has(k)) continue;
|
|
59
|
+
seen.set(k, {
|
|
60
|
+
entity: v.entity,
|
|
61
|
+
state: v.state,
|
|
62
|
+
...v.kind ? { kind: v.kind } : {},
|
|
63
|
+
...v.stateKind ? { stateKind: v.stateKind } : {},
|
|
64
|
+
...v.route ? { route: v.route } : {},
|
|
65
|
+
...v.url ? { url: v.url } : {}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const captured = [...seen.values()].sort(
|
|
69
|
+
(a, b) => a.entity.localeCompare(b.entity) || a.state.localeCompare(b.state)
|
|
70
|
+
);
|
|
71
|
+
const sortedVariants = [...variants].sort(
|
|
72
|
+
(a, b) => a.entity.localeCompare(b.entity) || a.state.localeCompare(b.state) || (a.theme ?? "").localeCompare(b.theme ?? "") || (a.width ?? "").localeCompare(b.width ?? "")
|
|
73
|
+
);
|
|
74
|
+
return { captured, variants: sortedVariants };
|
|
75
|
+
}
|
|
76
|
+
function parsePayload(raw) {
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(raw);
|
|
79
|
+
if (!parsed || !Array.isArray(parsed.records)) return [];
|
|
80
|
+
return parsed.records.filter(
|
|
81
|
+
(r) => !!r && typeof r.entity === "string" && typeof r.state === "string"
|
|
82
|
+
);
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
var UidexStatesReporter = class {
|
|
88
|
+
outputPath;
|
|
89
|
+
silent;
|
|
90
|
+
variants = [];
|
|
91
|
+
constructor(options = {}) {
|
|
92
|
+
this.outputPath = options.outputPath ?? "uidex-states.json";
|
|
93
|
+
this.silent = options.silent ?? false;
|
|
94
|
+
}
|
|
95
|
+
onTestEnd(_test, result) {
|
|
96
|
+
for (const attachment of result.attachments) {
|
|
97
|
+
if (attachment.name !== STATES_ATTACHMENT) continue;
|
|
98
|
+
if (!attachment.body) continue;
|
|
99
|
+
this.variants.push(...parsePayload(attachment.body.toString()));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async onEnd(_result) {
|
|
103
|
+
const manifest = aggregateStates(this.variants);
|
|
104
|
+
fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {
|
|
105
|
+
recursive: true
|
|
106
|
+
});
|
|
107
|
+
fs.writeFileSync(
|
|
108
|
+
path.resolve(this.outputPath),
|
|
109
|
+
JSON.stringify(manifest, null, 2) + "\n"
|
|
110
|
+
);
|
|
111
|
+
if (!this.silent) {
|
|
112
|
+
const entities = new Set(manifest.captured.map((c) => c.entity)).size;
|
|
113
|
+
console.log(
|
|
114
|
+
`uidex states: captured ${manifest.captured.length} state(s) across ${entities} entit${entities === 1 ? "y" : "ies"} (${manifest.variants.length} variants) \u2192 ${this.outputPath}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
120
|
+
0 && (module.exports = {
|
|
121
|
+
aggregateStates
|
|
122
|
+
});
|
|
123
|
+
//# sourceMappingURL=states-reporter.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/integrations/playwright/states-reporter.ts","../../src/integrations/playwright/states.ts"],"sourcesContent":["import * as fs from \"node:fs\"\nimport * as path from \"node:path\"\nimport type {\n FullResult,\n Reporter,\n TestCase,\n TestResult,\n} from \"@playwright/test/reporter\"\nimport {\n STATES_ATTACHMENT,\n type StateRecord,\n type StatesPayload,\n} from \"./states\"\n\n/**\n * Aggregates `uidex-states` attachments across a run into `uidex-states.json` —\n * the manifest the scanner's state-capture completeness gate reads. The shape\n * matches the scanner's `CapturedStatesManifest` structurally (kept decoupled:\n * no cross-subpath import, so the playwright bundle stays free of scan code).\n *\n * Variants collapse: many theme × width shots of one `entity/state` become a\n * single captured entry, because the gate asks \"was this state produced at\n * all?\", not \"how many variants?\".\n */\n\nexport interface CapturedStateEntry {\n entity: string\n kind?: string\n state: string\n /** Canonical matrix kind (loading/empty/populated/error/variant). */\n stateKind?: string\n /** Observed URL pathname (first variant); the page-coverage gate matches it. */\n url?: string\n /** Explicit route pattern, when the capture supplied one. */\n route?: string\n}\n\nexport interface CapturedStatesManifestOut {\n captured: CapturedStateEntry[]\n /** Every distinct theme/width/path variant, for tooling that wants detail. */\n variants: StateRecord[]\n}\n\nexport interface UidexStatesReporterOptions {\n outputPath?: string\n silent?: boolean\n}\n\nfunction keyOf(r: StateRecord): string {\n // Collapses pure theme/width variants, but keeps distinct routes/urls/kinds\n // so a state captured on two routes (or with vs. without stateKind) is not lost.\n return JSON.stringify([\n r.kind ?? null,\n r.entity,\n r.state,\n r.route ?? null,\n r.url ?? null,\n r.stateKind ?? null,\n ])\n}\n\n/** Pure aggregation — the reporter's core, exported for tests. */\nexport function aggregateStates(\n variants: StateRecord[]\n): CapturedStatesManifestOut {\n const seen = new Map<string, CapturedStateEntry>()\n for (const v of variants) {\n const k = keyOf(v)\n if (seen.has(k)) continue\n seen.set(k, {\n entity: v.entity,\n state: v.state,\n ...(v.kind ? { kind: v.kind } : {}),\n ...(v.stateKind ? { stateKind: v.stateKind } : {}),\n ...(v.route ? { route: v.route } : {}),\n ...(v.url ? { url: v.url } : {}),\n })\n }\n const captured = [...seen.values()].sort(\n (a, b) => a.entity.localeCompare(b.entity) || a.state.localeCompare(b.state)\n )\n const sortedVariants = [...variants].sort(\n (a, b) =>\n a.entity.localeCompare(b.entity) ||\n a.state.localeCompare(b.state) ||\n (a.theme ?? \"\").localeCompare(b.theme ?? \"\") ||\n (a.width ?? \"\").localeCompare(b.width ?? \"\")\n )\n return { captured, variants: sortedVariants }\n}\n\nfunction parsePayload(raw: string): StateRecord[] {\n try {\n const parsed = JSON.parse(raw) as Partial<StatesPayload>\n if (!parsed || !Array.isArray(parsed.records)) return []\n return parsed.records.filter(\n (r): r is StateRecord =>\n !!r && typeof r.entity === \"string\" && typeof r.state === \"string\"\n )\n } catch {\n return []\n }\n}\n\nexport default class UidexStatesReporter implements Reporter {\n private readonly outputPath: string\n private readonly silent: boolean\n private readonly variants: StateRecord[] = []\n\n constructor(options: UidexStatesReporterOptions = {}) {\n this.outputPath = options.outputPath ?? \"uidex-states.json\"\n this.silent = options.silent ?? false\n }\n\n onTestEnd(_test: TestCase, result: TestResult): void {\n for (const attachment of result.attachments) {\n if (attachment.name !== STATES_ATTACHMENT) continue\n if (!attachment.body) continue\n this.variants.push(...parsePayload(attachment.body.toString()))\n }\n }\n\n async onEnd(_result: FullResult): Promise<void> {\n const manifest = aggregateStates(this.variants)\n fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {\n recursive: true,\n })\n fs.writeFileSync(\n path.resolve(this.outputPath),\n JSON.stringify(manifest, null, 2) + \"\\n\"\n )\n if (!this.silent) {\n const entities = new Set(manifest.captured.map((c) => c.entity)).size\n console.log(\n `uidex states: captured ${manifest.captured.length} state(s) across ${entities} entit${entities === 1 ? \"y\" : \"ies\"} (${manifest.variants.length} variants) → ${this.outputPath}`\n )\n }\n }\n}\n","import type { Page, TestInfo } from \"@playwright/test\"\n\n/**\n * Deterministic render-state capture for `uidex/playwright`.\n *\n * Two jobs, deliberately split:\n * 1. RECORD which `entity/state` a test captured (a `uidex-states`\n * attachment) — the reporter aggregates these into `uidex-states.json`,\n * which the scanner's completeness gate cross-checks against the states\n * declared in `export const uidex = { states: [...] }`.\n * 2. SHOOT the pixels — a thin wrapper over native `page.screenshot`. The\n * determinism knobs are Playwright's own: `animations: \"disabled\"` and\n * `caret: \"hide\"` are defaults, `style`/`stylePath` pierces the shadow DOM\n * to hide dev chrome, `mask` covers non-deterministic regions, and the\n * frozen clock / locale / timezone / colour-scheme live in the project's\n * `use` block. uidex does not re-implement `settle()`.\n *\n * The recording is the load-bearing half: even a spec that shoots nothing can\n * call `recordState` so the gate knows the state exists.\n */\n\nexport const STATES_ATTACHMENT = \"uidex-states\"\n\n/** Default light + dark; many surfaces use theme-dependent colours. */\nexport const THEMES = [\"light\", \"dark\"] as const\n/** Desktop is primary; narrow exercises the responsive reflow. */\nexport const WIDTHS = { desktop: 1440, narrow: 430 } as const\n\nexport type Theme = (typeof THEMES)[number]\nexport type WidthLabel = keyof typeof WIDTHS\nexport type StateEntityKind = \"page\" | \"feature\" | \"widget\"\n\n/** The four kinds the state-matrix gate expects every captured route to cover. */\nexport const CORE_STATE_KINDS = [\n \"loading\",\n \"empty\",\n \"populated\",\n \"error\",\n] as const\nexport type CoreStateKind = (typeof CORE_STATE_KINDS)[number]\n/** `variant` = any state outside the matrix (dialog, wizard step, multi-currency…). */\nexport type StateKind = CoreStateKind | \"variant\"\n\n/** One captured `entity/state` variant. */\nexport interface StateRecord {\n /** Registry entity id whose state was rendered (e.g. \"products\"). */\n entity: string\n /** Entity kind, so the gate can disambiguate a page vs feature of the same id. */\n kind?: StateEntityKind\n /** Declared state name (e.g. \"new-filled\"). */\n state: string\n /** Canonical kind for the state-matrix axis (defaults inferred from the name). */\n stateKind?: StateKind\n theme?: string\n width?: string\n /** Screenshot path, when one was written. */\n path?: string\n /**\n * The URL pathname the capture ran against, observed from `page.url()`. The\n * page-coverage gate normalizes it to a derived route pattern — so which route\n * a capture exercised is observed, never hand-declared. A `/shots/*` harness\n * URL matches no real route and gates nothing (correct for component captures).\n */\n url?: string\n /** Explicit route pattern override, when the observed URL can't be trusted. */\n route?: string\n}\n\n/** The `uidex-states` attachment body — an array of records from one test. */\nexport interface StatesPayload {\n records: StateRecord[]\n}\n\ntype TestInfoLike = Pick<TestInfo, \"attach\">\n\n/**\n * Attach one or more captured-state records to the test. Idempotent per call;\n * the reporter concatenates every test's records. Use directly when you shoot\n * with your own screenshot call and only need the state registered for the gate.\n */\nexport async function recordStates(\n testInfo: TestInfoLike,\n records: StateRecord[]\n): Promise<void> {\n if (records.length === 0) return\n const payload: StatesPayload = { records }\n await testInfo.attach(STATES_ATTACHMENT, {\n body: JSON.stringify(payload),\n contentType: \"application/json\",\n })\n}\n\nexport interface CaptureStateOptions {\n /** Registry entity id whose state this is. */\n entity: string\n kind?: StateEntityKind\n /** Declared state name (must match a name in the entity's `states`). */\n state: string\n /**\n * Canonical state kind for the matrix. Defaults to the state name when it is\n * itself a core kind (`loading`/`empty`/`populated`/`error`), else `variant`.\n */\n stateKind?: StateKind\n /**\n * Output dir for PNGs; defaults to `process.env.UIDEX_SHOTS_DIR`. When unset\n * the capture only RECORDS the state (no screenshot) — so a spec left in the\n * normal suite is a no-op beyond the attachment.\n */\n dir?: string\n themes?: readonly Theme[]\n widths?: readonly WidthLabel[]\n /** Awaited after each theme/width change, before the shutter (the state's tell). */\n ready?: (page: Page) => Promise<unknown>\n /**\n * Switch the app's theme. App-specific (next-themes uses localStorage + a\n * reload; others a class or cookie), so it is injected. Defaults to\n * Playwright's `emulateMedia({ colorScheme })`, which suits prefers-color-scheme.\n */\n setTheme?: (page: Page, theme: Theme) => Promise<void>\n /** CSS selectors to mask (opaque box) — for legitimately non-deterministic regions. */\n mask?: string[]\n /** Path to a stylesheet injected before the shot (hide dev chrome; pierces shadow DOM). */\n stylePath?: string\n fullPage?: boolean\n /**\n * Explicit route pattern this capture covers (e.g. \"/[scope]/products\"). By\n * default the observed `page.url()` pathname is recorded and the gate matches\n * it; pass `route` only when the observed URL is unreliable (e.g. a `/shots`\n * harness standing in for a real route).\n */\n route?: string\n}\n\n/** The URL pathname the page is currently on, for route observation. */\nfunction currentPathname(page: Page): string | undefined {\n try {\n return new URL(page.url()).pathname\n } catch {\n return undefined\n }\n}\n\nasync function defaultSetTheme(page: Page, theme: Theme): Promise<void> {\n await page.emulateMedia({ colorScheme: theme })\n}\n\n/** Infer the matrix kind: the state name if it is itself a core kind, else variant. */\nfunction inferStateKind(state: string, explicit?: StateKind): StateKind {\n if (explicit) return explicit\n return (CORE_STATE_KINDS as readonly string[]).includes(state)\n ? (state as StateKind)\n : \"variant\"\n}\n\nfunction shotName(\n opts: CaptureStateOptions,\n theme: Theme,\n label: WidthLabel,\n suffixWidth: boolean\n): string {\n const base = `${opts.entity}/${opts.state}`\n return suffixWidth ? `${base}-${theme}-${label}` : `${base}-${theme}`\n}\n\n/**\n * Capture one declared state across themes × widths, then record every variant\n * for the gate. Screenshot mechanics are native Playwright; determinism knobs\n * come from the project `use` block and the options above. Returns the records\n * it attached (also handy in tests).\n */\nexport async function captureState(\n page: Page,\n testInfo: TestInfoLike,\n opts: CaptureStateOptions\n): Promise<StateRecord[]> {\n const themes = opts.themes ?? THEMES\n const widths = opts.widths ?? ([\"desktop\"] as WidthLabel[])\n const suffixWidth = widths.length > 1\n const dir = opts.dir ?? process.env.UIDEX_SHOTS_DIR\n const setTheme = opts.setTheme ?? defaultSetTheme\n const observedUrl = currentPathname(page)\n const records: StateRecord[] = []\n\n for (const theme of themes) {\n await setTheme(page, theme)\n if (opts.ready) await opts.ready(page)\n for (const label of widths) {\n await page.setViewportSize({ width: WIDTHS[label], height: 900 })\n if (opts.ready) await opts.ready(page)\n const record: StateRecord = {\n entity: opts.entity,\n state: opts.state,\n stateKind: inferStateKind(opts.state, opts.stateKind),\n theme,\n width: label,\n ...(opts.kind ? { kind: opts.kind } : {}),\n ...(opts.route ? { route: opts.route } : {}),\n ...(observedUrl ? { url: observedUrl } : {}),\n }\n if (dir) {\n const path = `${dir}/${shotName(opts, theme, label, suffixWidth)}.png`\n await page.screenshot({\n path,\n fullPage: opts.fullPage ?? true,\n ...(opts.stylePath ? { stylePath: opts.stylePath } : {}),\n ...(opts.mask?.length\n ? {\n mask: opts.mask.map((s) => page.locator(s)),\n maskColor: \"#000000\",\n }\n : {}),\n })\n record.path = path\n }\n records.push(record)\n }\n }\n\n await recordStates(testInfo, records)\n return records\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Reporter, TestCase, TestResult, FullResult } from '@playwright/test/reporter';
|
|
2
|
+
import { StateRecord } from './states.cjs';
|
|
3
|
+
import '@playwright/test';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Aggregates `uidex-states` attachments across a run into `uidex-states.json` —
|
|
7
|
+
* the manifest the scanner's state-capture completeness gate reads. The shape
|
|
8
|
+
* matches the scanner's `CapturedStatesManifest` structurally (kept decoupled:
|
|
9
|
+
* no cross-subpath import, so the playwright bundle stays free of scan code).
|
|
10
|
+
*
|
|
11
|
+
* Variants collapse: many theme × width shots of one `entity/state` become a
|
|
12
|
+
* single captured entry, because the gate asks "was this state produced at
|
|
13
|
+
* all?", not "how many variants?".
|
|
14
|
+
*/
|
|
15
|
+
interface CapturedStateEntry {
|
|
16
|
+
entity: string;
|
|
17
|
+
kind?: string;
|
|
18
|
+
state: string;
|
|
19
|
+
/** Canonical matrix kind (loading/empty/populated/error/variant). */
|
|
20
|
+
stateKind?: string;
|
|
21
|
+
/** Observed URL pathname (first variant); the page-coverage gate matches it. */
|
|
22
|
+
url?: string;
|
|
23
|
+
/** Explicit route pattern, when the capture supplied one. */
|
|
24
|
+
route?: string;
|
|
25
|
+
}
|
|
26
|
+
interface CapturedStatesManifestOut {
|
|
27
|
+
captured: CapturedStateEntry[];
|
|
28
|
+
/** Every distinct theme/width/path variant, for tooling that wants detail. */
|
|
29
|
+
variants: StateRecord[];
|
|
30
|
+
}
|
|
31
|
+
interface UidexStatesReporterOptions {
|
|
32
|
+
outputPath?: string;
|
|
33
|
+
silent?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** Pure aggregation — the reporter's core, exported for tests. */
|
|
36
|
+
declare function aggregateStates(variants: StateRecord[]): CapturedStatesManifestOut;
|
|
37
|
+
declare class UidexStatesReporter implements Reporter {
|
|
38
|
+
private readonly outputPath;
|
|
39
|
+
private readonly silent;
|
|
40
|
+
private readonly variants;
|
|
41
|
+
constructor(options?: UidexStatesReporterOptions);
|
|
42
|
+
onTestEnd(_test: TestCase, result: TestResult): void;
|
|
43
|
+
onEnd(_result: FullResult): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { type CapturedStateEntry, type CapturedStatesManifestOut, type UidexStatesReporterOptions, aggregateStates, UidexStatesReporter as default };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Reporter, TestCase, TestResult, FullResult } from '@playwright/test/reporter';
|
|
2
|
+
import { StateRecord } from './states.js';
|
|
3
|
+
import '@playwright/test';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Aggregates `uidex-states` attachments across a run into `uidex-states.json` —
|
|
7
|
+
* the manifest the scanner's state-capture completeness gate reads. The shape
|
|
8
|
+
* matches the scanner's `CapturedStatesManifest` structurally (kept decoupled:
|
|
9
|
+
* no cross-subpath import, so the playwright bundle stays free of scan code).
|
|
10
|
+
*
|
|
11
|
+
* Variants collapse: many theme × width shots of one `entity/state` become a
|
|
12
|
+
* single captured entry, because the gate asks "was this state produced at
|
|
13
|
+
* all?", not "how many variants?".
|
|
14
|
+
*/
|
|
15
|
+
interface CapturedStateEntry {
|
|
16
|
+
entity: string;
|
|
17
|
+
kind?: string;
|
|
18
|
+
state: string;
|
|
19
|
+
/** Canonical matrix kind (loading/empty/populated/error/variant). */
|
|
20
|
+
stateKind?: string;
|
|
21
|
+
/** Observed URL pathname (first variant); the page-coverage gate matches it. */
|
|
22
|
+
url?: string;
|
|
23
|
+
/** Explicit route pattern, when the capture supplied one. */
|
|
24
|
+
route?: string;
|
|
25
|
+
}
|
|
26
|
+
interface CapturedStatesManifestOut {
|
|
27
|
+
captured: CapturedStateEntry[];
|
|
28
|
+
/** Every distinct theme/width/path variant, for tooling that wants detail. */
|
|
29
|
+
variants: StateRecord[];
|
|
30
|
+
}
|
|
31
|
+
interface UidexStatesReporterOptions {
|
|
32
|
+
outputPath?: string;
|
|
33
|
+
silent?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** Pure aggregation — the reporter's core, exported for tests. */
|
|
36
|
+
declare function aggregateStates(variants: StateRecord[]): CapturedStatesManifestOut;
|
|
37
|
+
declare class UidexStatesReporter implements Reporter {
|
|
38
|
+
private readonly outputPath;
|
|
39
|
+
private readonly silent;
|
|
40
|
+
private readonly variants;
|
|
41
|
+
constructor(options?: UidexStatesReporterOptions);
|
|
42
|
+
onTestEnd(_test: TestCase, result: TestResult): void;
|
|
43
|
+
onEnd(_result: FullResult): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { type CapturedStateEntry, type CapturedStatesManifestOut, type UidexStatesReporterOptions, aggregateStates, UidexStatesReporter as default };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// src/integrations/playwright/states-reporter.ts
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
// src/integrations/playwright/states.ts
|
|
6
|
+
var STATES_ATTACHMENT = "uidex-states";
|
|
7
|
+
|
|
8
|
+
// src/integrations/playwright/states-reporter.ts
|
|
9
|
+
function keyOf(r) {
|
|
10
|
+
return JSON.stringify([
|
|
11
|
+
r.kind ?? null,
|
|
12
|
+
r.entity,
|
|
13
|
+
r.state,
|
|
14
|
+
r.route ?? null,
|
|
15
|
+
r.url ?? null,
|
|
16
|
+
r.stateKind ?? null
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
function aggregateStates(variants) {
|
|
20
|
+
const seen = /* @__PURE__ */ new Map();
|
|
21
|
+
for (const v of variants) {
|
|
22
|
+
const k = keyOf(v);
|
|
23
|
+
if (seen.has(k)) continue;
|
|
24
|
+
seen.set(k, {
|
|
25
|
+
entity: v.entity,
|
|
26
|
+
state: v.state,
|
|
27
|
+
...v.kind ? { kind: v.kind } : {},
|
|
28
|
+
...v.stateKind ? { stateKind: v.stateKind } : {},
|
|
29
|
+
...v.route ? { route: v.route } : {},
|
|
30
|
+
...v.url ? { url: v.url } : {}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const captured = [...seen.values()].sort(
|
|
34
|
+
(a, b) => a.entity.localeCompare(b.entity) || a.state.localeCompare(b.state)
|
|
35
|
+
);
|
|
36
|
+
const sortedVariants = [...variants].sort(
|
|
37
|
+
(a, b) => a.entity.localeCompare(b.entity) || a.state.localeCompare(b.state) || (a.theme ?? "").localeCompare(b.theme ?? "") || (a.width ?? "").localeCompare(b.width ?? "")
|
|
38
|
+
);
|
|
39
|
+
return { captured, variants: sortedVariants };
|
|
40
|
+
}
|
|
41
|
+
function parsePayload(raw) {
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
if (!parsed || !Array.isArray(parsed.records)) return [];
|
|
45
|
+
return parsed.records.filter(
|
|
46
|
+
(r) => !!r && typeof r.entity === "string" && typeof r.state === "string"
|
|
47
|
+
);
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
var UidexStatesReporter = class {
|
|
53
|
+
outputPath;
|
|
54
|
+
silent;
|
|
55
|
+
variants = [];
|
|
56
|
+
constructor(options = {}) {
|
|
57
|
+
this.outputPath = options.outputPath ?? "uidex-states.json";
|
|
58
|
+
this.silent = options.silent ?? false;
|
|
59
|
+
}
|
|
60
|
+
onTestEnd(_test, result) {
|
|
61
|
+
for (const attachment of result.attachments) {
|
|
62
|
+
if (attachment.name !== STATES_ATTACHMENT) continue;
|
|
63
|
+
if (!attachment.body) continue;
|
|
64
|
+
this.variants.push(...parsePayload(attachment.body.toString()));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async onEnd(_result) {
|
|
68
|
+
const manifest = aggregateStates(this.variants);
|
|
69
|
+
fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {
|
|
70
|
+
recursive: true
|
|
71
|
+
});
|
|
72
|
+
fs.writeFileSync(
|
|
73
|
+
path.resolve(this.outputPath),
|
|
74
|
+
JSON.stringify(manifest, null, 2) + "\n"
|
|
75
|
+
);
|
|
76
|
+
if (!this.silent) {
|
|
77
|
+
const entities = new Set(manifest.captured.map((c) => c.entity)).size;
|
|
78
|
+
console.log(
|
|
79
|
+
`uidex states: captured ${manifest.captured.length} state(s) across ${entities} entit${entities === 1 ? "y" : "ies"} (${manifest.variants.length} variants) \u2192 ${this.outputPath}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
export {
|
|
85
|
+
aggregateStates,
|
|
86
|
+
UidexStatesReporter as default
|
|
87
|
+
};
|
|
88
|
+
//# sourceMappingURL=states-reporter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/integrations/playwright/states-reporter.ts","../../src/integrations/playwright/states.ts"],"sourcesContent":["import * as fs from \"node:fs\"\nimport * as path from \"node:path\"\nimport type {\n FullResult,\n Reporter,\n TestCase,\n TestResult,\n} from \"@playwright/test/reporter\"\nimport {\n STATES_ATTACHMENT,\n type StateRecord,\n type StatesPayload,\n} from \"./states\"\n\n/**\n * Aggregates `uidex-states` attachments across a run into `uidex-states.json` —\n * the manifest the scanner's state-capture completeness gate reads. The shape\n * matches the scanner's `CapturedStatesManifest` structurally (kept decoupled:\n * no cross-subpath import, so the playwright bundle stays free of scan code).\n *\n * Variants collapse: many theme × width shots of one `entity/state` become a\n * single captured entry, because the gate asks \"was this state produced at\n * all?\", not \"how many variants?\".\n */\n\nexport interface CapturedStateEntry {\n entity: string\n kind?: string\n state: string\n /** Canonical matrix kind (loading/empty/populated/error/variant). */\n stateKind?: string\n /** Observed URL pathname (first variant); the page-coverage gate matches it. */\n url?: string\n /** Explicit route pattern, when the capture supplied one. */\n route?: string\n}\n\nexport interface CapturedStatesManifestOut {\n captured: CapturedStateEntry[]\n /** Every distinct theme/width/path variant, for tooling that wants detail. */\n variants: StateRecord[]\n}\n\nexport interface UidexStatesReporterOptions {\n outputPath?: string\n silent?: boolean\n}\n\nfunction keyOf(r: StateRecord): string {\n // Collapses pure theme/width variants, but keeps distinct routes/urls/kinds\n // so a state captured on two routes (or with vs. without stateKind) is not lost.\n return JSON.stringify([\n r.kind ?? null,\n r.entity,\n r.state,\n r.route ?? null,\n r.url ?? null,\n r.stateKind ?? null,\n ])\n}\n\n/** Pure aggregation — the reporter's core, exported for tests. */\nexport function aggregateStates(\n variants: StateRecord[]\n): CapturedStatesManifestOut {\n const seen = new Map<string, CapturedStateEntry>()\n for (const v of variants) {\n const k = keyOf(v)\n if (seen.has(k)) continue\n seen.set(k, {\n entity: v.entity,\n state: v.state,\n ...(v.kind ? { kind: v.kind } : {}),\n ...(v.stateKind ? { stateKind: v.stateKind } : {}),\n ...(v.route ? { route: v.route } : {}),\n ...(v.url ? { url: v.url } : {}),\n })\n }\n const captured = [...seen.values()].sort(\n (a, b) => a.entity.localeCompare(b.entity) || a.state.localeCompare(b.state)\n )\n const sortedVariants = [...variants].sort(\n (a, b) =>\n a.entity.localeCompare(b.entity) ||\n a.state.localeCompare(b.state) ||\n (a.theme ?? \"\").localeCompare(b.theme ?? \"\") ||\n (a.width ?? \"\").localeCompare(b.width ?? \"\")\n )\n return { captured, variants: sortedVariants }\n}\n\nfunction parsePayload(raw: string): StateRecord[] {\n try {\n const parsed = JSON.parse(raw) as Partial<StatesPayload>\n if (!parsed || !Array.isArray(parsed.records)) return []\n return parsed.records.filter(\n (r): r is StateRecord =>\n !!r && typeof r.entity === \"string\" && typeof r.state === \"string\"\n )\n } catch {\n return []\n }\n}\n\nexport default class UidexStatesReporter implements Reporter {\n private readonly outputPath: string\n private readonly silent: boolean\n private readonly variants: StateRecord[] = []\n\n constructor(options: UidexStatesReporterOptions = {}) {\n this.outputPath = options.outputPath ?? \"uidex-states.json\"\n this.silent = options.silent ?? false\n }\n\n onTestEnd(_test: TestCase, result: TestResult): void {\n for (const attachment of result.attachments) {\n if (attachment.name !== STATES_ATTACHMENT) continue\n if (!attachment.body) continue\n this.variants.push(...parsePayload(attachment.body.toString()))\n }\n }\n\n async onEnd(_result: FullResult): Promise<void> {\n const manifest = aggregateStates(this.variants)\n fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {\n recursive: true,\n })\n fs.writeFileSync(\n path.resolve(this.outputPath),\n JSON.stringify(manifest, null, 2) + \"\\n\"\n )\n if (!this.silent) {\n const entities = new Set(manifest.captured.map((c) => c.entity)).size\n console.log(\n `uidex states: captured ${manifest.captured.length} state(s) across ${entities} entit${entities === 1 ? \"y\" : \"ies\"} (${manifest.variants.length} variants) → ${this.outputPath}`\n )\n }\n }\n}\n","import type { Page, TestInfo } from \"@playwright/test\"\n\n/**\n * Deterministic render-state capture for `uidex/playwright`.\n *\n * Two jobs, deliberately split:\n * 1. RECORD which `entity/state` a test captured (a `uidex-states`\n * attachment) — the reporter aggregates these into `uidex-states.json`,\n * which the scanner's completeness gate cross-checks against the states\n * declared in `export const uidex = { states: [...] }`.\n * 2. SHOOT the pixels — a thin wrapper over native `page.screenshot`. The\n * determinism knobs are Playwright's own: `animations: \"disabled\"` and\n * `caret: \"hide\"` are defaults, `style`/`stylePath` pierces the shadow DOM\n * to hide dev chrome, `mask` covers non-deterministic regions, and the\n * frozen clock / locale / timezone / colour-scheme live in the project's\n * `use` block. uidex does not re-implement `settle()`.\n *\n * The recording is the load-bearing half: even a spec that shoots nothing can\n * call `recordState` so the gate knows the state exists.\n */\n\nexport const STATES_ATTACHMENT = \"uidex-states\"\n\n/** Default light + dark; many surfaces use theme-dependent colours. */\nexport const THEMES = [\"light\", \"dark\"] as const\n/** Desktop is primary; narrow exercises the responsive reflow. */\nexport const WIDTHS = { desktop: 1440, narrow: 430 } as const\n\nexport type Theme = (typeof THEMES)[number]\nexport type WidthLabel = keyof typeof WIDTHS\nexport type StateEntityKind = \"page\" | \"feature\" | \"widget\"\n\n/** The four kinds the state-matrix gate expects every captured route to cover. */\nexport const CORE_STATE_KINDS = [\n \"loading\",\n \"empty\",\n \"populated\",\n \"error\",\n] as const\nexport type CoreStateKind = (typeof CORE_STATE_KINDS)[number]\n/** `variant` = any state outside the matrix (dialog, wizard step, multi-currency…). */\nexport type StateKind = CoreStateKind | \"variant\"\n\n/** One captured `entity/state` variant. */\nexport interface StateRecord {\n /** Registry entity id whose state was rendered (e.g. \"products\"). */\n entity: string\n /** Entity kind, so the gate can disambiguate a page vs feature of the same id. */\n kind?: StateEntityKind\n /** Declared state name (e.g. \"new-filled\"). */\n state: string\n /** Canonical kind for the state-matrix axis (defaults inferred from the name). */\n stateKind?: StateKind\n theme?: string\n width?: string\n /** Screenshot path, when one was written. */\n path?: string\n /**\n * The URL pathname the capture ran against, observed from `page.url()`. The\n * page-coverage gate normalizes it to a derived route pattern — so which route\n * a capture exercised is observed, never hand-declared. A `/shots/*` harness\n * URL matches no real route and gates nothing (correct for component captures).\n */\n url?: string\n /** Explicit route pattern override, when the observed URL can't be trusted. */\n route?: string\n}\n\n/** The `uidex-states` attachment body — an array of records from one test. */\nexport interface StatesPayload {\n records: StateRecord[]\n}\n\ntype TestInfoLike = Pick<TestInfo, \"attach\">\n\n/**\n * Attach one or more captured-state records to the test. Idempotent per call;\n * the reporter concatenates every test's records. Use directly when you shoot\n * with your own screenshot call and only need the state registered for the gate.\n */\nexport async function recordStates(\n testInfo: TestInfoLike,\n records: StateRecord[]\n): Promise<void> {\n if (records.length === 0) return\n const payload: StatesPayload = { records }\n await testInfo.attach(STATES_ATTACHMENT, {\n body: JSON.stringify(payload),\n contentType: \"application/json\",\n })\n}\n\nexport interface CaptureStateOptions {\n /** Registry entity id whose state this is. */\n entity: string\n kind?: StateEntityKind\n /** Declared state name (must match a name in the entity's `states`). */\n state: string\n /**\n * Canonical state kind for the matrix. Defaults to the state name when it is\n * itself a core kind (`loading`/`empty`/`populated`/`error`), else `variant`.\n */\n stateKind?: StateKind\n /**\n * Output dir for PNGs; defaults to `process.env.UIDEX_SHOTS_DIR`. When unset\n * the capture only RECORDS the state (no screenshot) — so a spec left in the\n * normal suite is a no-op beyond the attachment.\n */\n dir?: string\n themes?: readonly Theme[]\n widths?: readonly WidthLabel[]\n /** Awaited after each theme/width change, before the shutter (the state's tell). */\n ready?: (page: Page) => Promise<unknown>\n /**\n * Switch the app's theme. App-specific (next-themes uses localStorage + a\n * reload; others a class or cookie), so it is injected. Defaults to\n * Playwright's `emulateMedia({ colorScheme })`, which suits prefers-color-scheme.\n */\n setTheme?: (page: Page, theme: Theme) => Promise<void>\n /** CSS selectors to mask (opaque box) — for legitimately non-deterministic regions. */\n mask?: string[]\n /** Path to a stylesheet injected before the shot (hide dev chrome; pierces shadow DOM). */\n stylePath?: string\n fullPage?: boolean\n /**\n * Explicit route pattern this capture covers (e.g. \"/[scope]/products\"). By\n * default the observed `page.url()` pathname is recorded and the gate matches\n * it; pass `route` only when the observed URL is unreliable (e.g. a `/shots`\n * harness standing in for a real route).\n */\n route?: string\n}\n\n/** The URL pathname the page is currently on, for route observation. */\nfunction currentPathname(page: Page): string | undefined {\n try {\n return new URL(page.url()).pathname\n } catch {\n return undefined\n }\n}\n\nasync function defaultSetTheme(page: Page, theme: Theme): Promise<void> {\n await page.emulateMedia({ colorScheme: theme })\n}\n\n/** Infer the matrix kind: the state name if it is itself a core kind, else variant. */\nfunction inferStateKind(state: string, explicit?: StateKind): StateKind {\n if (explicit) return explicit\n return (CORE_STATE_KINDS as readonly string[]).includes(state)\n ? (state as StateKind)\n : \"variant\"\n}\n\nfunction shotName(\n opts: CaptureStateOptions,\n theme: Theme,\n label: WidthLabel,\n suffixWidth: boolean\n): string {\n const base = `${opts.entity}/${opts.state}`\n return suffixWidth ? `${base}-${theme}-${label}` : `${base}-${theme}`\n}\n\n/**\n * Capture one declared state across themes × widths, then record every variant\n * for the gate. Screenshot mechanics are native Playwright; determinism knobs\n * come from the project `use` block and the options above. Returns the records\n * it attached (also handy in tests).\n */\nexport async function captureState(\n page: Page,\n testInfo: TestInfoLike,\n opts: CaptureStateOptions\n): Promise<StateRecord[]> {\n const themes = opts.themes ?? THEMES\n const widths = opts.widths ?? ([\"desktop\"] as WidthLabel[])\n const suffixWidth = widths.length > 1\n const dir = opts.dir ?? process.env.UIDEX_SHOTS_DIR\n const setTheme = opts.setTheme ?? defaultSetTheme\n const observedUrl = currentPathname(page)\n const records: StateRecord[] = []\n\n for (const theme of themes) {\n await setTheme(page, theme)\n if (opts.ready) await opts.ready(page)\n for (const label of widths) {\n await page.setViewportSize({ width: WIDTHS[label], height: 900 })\n if (opts.ready) await opts.ready(page)\n const record: StateRecord = {\n entity: opts.entity,\n state: opts.state,\n stateKind: inferStateKind(opts.state, opts.stateKind),\n theme,\n width: label,\n ...(opts.kind ? { kind: opts.kind } : {}),\n ...(opts.route ? { route: opts.route } : {}),\n ...(observedUrl ? { url: observedUrl } : {}),\n }\n if (dir) {\n const path = `${dir}/${shotName(opts, theme, label, suffixWidth)}.png`\n await page.screenshot({\n path,\n fullPage: opts.fullPage ?? true,\n ...(opts.stylePath ? { stylePath: opts.stylePath } : {}),\n ...(opts.mask?.length\n ? {\n mask: opts.mask.map((s) => page.locator(s)),\n maskColor: \"#000000\",\n }\n : {}),\n })\n record.path = path\n }\n records.push(record)\n }\n }\n\n await recordStates(testInfo, records)\n return records\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/integrations/playwright/states.ts
|
|
21
|
+
var states_exports = {};
|
|
22
|
+
__export(states_exports, {
|
|
23
|
+
CORE_STATE_KINDS: () => CORE_STATE_KINDS,
|
|
24
|
+
STATES_ATTACHMENT: () => STATES_ATTACHMENT,
|
|
25
|
+
THEMES: () => THEMES,
|
|
26
|
+
WIDTHS: () => WIDTHS,
|
|
27
|
+
captureState: () => captureState,
|
|
28
|
+
recordStates: () => recordStates
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(states_exports);
|
|
31
|
+
var STATES_ATTACHMENT = "uidex-states";
|
|
32
|
+
var THEMES = ["light", "dark"];
|
|
33
|
+
var WIDTHS = { desktop: 1440, narrow: 430 };
|
|
34
|
+
var CORE_STATE_KINDS = [
|
|
35
|
+
"loading",
|
|
36
|
+
"empty",
|
|
37
|
+
"populated",
|
|
38
|
+
"error"
|
|
39
|
+
];
|
|
40
|
+
async function recordStates(testInfo, records) {
|
|
41
|
+
if (records.length === 0) return;
|
|
42
|
+
const payload = { records };
|
|
43
|
+
await testInfo.attach(STATES_ATTACHMENT, {
|
|
44
|
+
body: JSON.stringify(payload),
|
|
45
|
+
contentType: "application/json"
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function currentPathname(page) {
|
|
49
|
+
try {
|
|
50
|
+
return new URL(page.url()).pathname;
|
|
51
|
+
} catch {
|
|
52
|
+
return void 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function defaultSetTheme(page, theme) {
|
|
56
|
+
await page.emulateMedia({ colorScheme: theme });
|
|
57
|
+
}
|
|
58
|
+
function inferStateKind(state, explicit) {
|
|
59
|
+
if (explicit) return explicit;
|
|
60
|
+
return CORE_STATE_KINDS.includes(state) ? state : "variant";
|
|
61
|
+
}
|
|
62
|
+
function shotName(opts, theme, label, suffixWidth) {
|
|
63
|
+
const base = `${opts.entity}/${opts.state}`;
|
|
64
|
+
return suffixWidth ? `${base}-${theme}-${label}` : `${base}-${theme}`;
|
|
65
|
+
}
|
|
66
|
+
async function captureState(page, testInfo, opts) {
|
|
67
|
+
const themes = opts.themes ?? THEMES;
|
|
68
|
+
const widths = opts.widths ?? ["desktop"];
|
|
69
|
+
const suffixWidth = widths.length > 1;
|
|
70
|
+
const dir = opts.dir ?? process.env.UIDEX_SHOTS_DIR;
|
|
71
|
+
const setTheme = opts.setTheme ?? defaultSetTheme;
|
|
72
|
+
const observedUrl = currentPathname(page);
|
|
73
|
+
const records = [];
|
|
74
|
+
for (const theme of themes) {
|
|
75
|
+
await setTheme(page, theme);
|
|
76
|
+
if (opts.ready) await opts.ready(page);
|
|
77
|
+
for (const label of widths) {
|
|
78
|
+
await page.setViewportSize({ width: WIDTHS[label], height: 900 });
|
|
79
|
+
if (opts.ready) await opts.ready(page);
|
|
80
|
+
const record = {
|
|
81
|
+
entity: opts.entity,
|
|
82
|
+
state: opts.state,
|
|
83
|
+
stateKind: inferStateKind(opts.state, opts.stateKind),
|
|
84
|
+
theme,
|
|
85
|
+
width: label,
|
|
86
|
+
...opts.kind ? { kind: opts.kind } : {},
|
|
87
|
+
...opts.route ? { route: opts.route } : {},
|
|
88
|
+
...observedUrl ? { url: observedUrl } : {}
|
|
89
|
+
};
|
|
90
|
+
if (dir) {
|
|
91
|
+
const path = `${dir}/${shotName(opts, theme, label, suffixWidth)}.png`;
|
|
92
|
+
await page.screenshot({
|
|
93
|
+
path,
|
|
94
|
+
fullPage: opts.fullPage ?? true,
|
|
95
|
+
...opts.stylePath ? { stylePath: opts.stylePath } : {},
|
|
96
|
+
...opts.mask?.length ? {
|
|
97
|
+
mask: opts.mask.map((s) => page.locator(s)),
|
|
98
|
+
maskColor: "#000000"
|
|
99
|
+
} : {}
|
|
100
|
+
});
|
|
101
|
+
record.path = path;
|
|
102
|
+
}
|
|
103
|
+
records.push(record);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await recordStates(testInfo, records);
|
|
107
|
+
return records;
|
|
108
|
+
}
|
|
109
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
110
|
+
0 && (module.exports = {
|
|
111
|
+
CORE_STATE_KINDS,
|
|
112
|
+
STATES_ATTACHMENT,
|
|
113
|
+
THEMES,
|
|
114
|
+
WIDTHS,
|
|
115
|
+
captureState,
|
|
116
|
+
recordStates
|
|
117
|
+
});
|
|
118
|
+
//# sourceMappingURL=states.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/integrations/playwright/states.ts"],"sourcesContent":["import type { Page, TestInfo } from \"@playwright/test\"\n\n/**\n * Deterministic render-state capture for `uidex/playwright`.\n *\n * Two jobs, deliberately split:\n * 1. RECORD which `entity/state` a test captured (a `uidex-states`\n * attachment) — the reporter aggregates these into `uidex-states.json`,\n * which the scanner's completeness gate cross-checks against the states\n * declared in `export const uidex = { states: [...] }`.\n * 2. SHOOT the pixels — a thin wrapper over native `page.screenshot`. The\n * determinism knobs are Playwright's own: `animations: \"disabled\"` and\n * `caret: \"hide\"` are defaults, `style`/`stylePath` pierces the shadow DOM\n * to hide dev chrome, `mask` covers non-deterministic regions, and the\n * frozen clock / locale / timezone / colour-scheme live in the project's\n * `use` block. uidex does not re-implement `settle()`.\n *\n * The recording is the load-bearing half: even a spec that shoots nothing can\n * call `recordState` so the gate knows the state exists.\n */\n\nexport const STATES_ATTACHMENT = \"uidex-states\"\n\n/** Default light + dark; many surfaces use theme-dependent colours. */\nexport const THEMES = [\"light\", \"dark\"] as const\n/** Desktop is primary; narrow exercises the responsive reflow. */\nexport const WIDTHS = { desktop: 1440, narrow: 430 } as const\n\nexport type Theme = (typeof THEMES)[number]\nexport type WidthLabel = keyof typeof WIDTHS\nexport type StateEntityKind = \"page\" | \"feature\" | \"widget\"\n\n/** The four kinds the state-matrix gate expects every captured route to cover. */\nexport const CORE_STATE_KINDS = [\n \"loading\",\n \"empty\",\n \"populated\",\n \"error\",\n] as const\nexport type CoreStateKind = (typeof CORE_STATE_KINDS)[number]\n/** `variant` = any state outside the matrix (dialog, wizard step, multi-currency…). */\nexport type StateKind = CoreStateKind | \"variant\"\n\n/** One captured `entity/state` variant. */\nexport interface StateRecord {\n /** Registry entity id whose state was rendered (e.g. \"products\"). */\n entity: string\n /** Entity kind, so the gate can disambiguate a page vs feature of the same id. */\n kind?: StateEntityKind\n /** Declared state name (e.g. \"new-filled\"). */\n state: string\n /** Canonical kind for the state-matrix axis (defaults inferred from the name). */\n stateKind?: StateKind\n theme?: string\n width?: string\n /** Screenshot path, when one was written. */\n path?: string\n /**\n * The URL pathname the capture ran against, observed from `page.url()`. The\n * page-coverage gate normalizes it to a derived route pattern — so which route\n * a capture exercised is observed, never hand-declared. A `/shots/*` harness\n * URL matches no real route and gates nothing (correct for component captures).\n */\n url?: string\n /** Explicit route pattern override, when the observed URL can't be trusted. */\n route?: string\n}\n\n/** The `uidex-states` attachment body — an array of records from one test. */\nexport interface StatesPayload {\n records: StateRecord[]\n}\n\ntype TestInfoLike = Pick<TestInfo, \"attach\">\n\n/**\n * Attach one or more captured-state records to the test. Idempotent per call;\n * the reporter concatenates every test's records. Use directly when you shoot\n * with your own screenshot call and only need the state registered for the gate.\n */\nexport async function recordStates(\n testInfo: TestInfoLike,\n records: StateRecord[]\n): Promise<void> {\n if (records.length === 0) return\n const payload: StatesPayload = { records }\n await testInfo.attach(STATES_ATTACHMENT, {\n body: JSON.stringify(payload),\n contentType: \"application/json\",\n })\n}\n\nexport interface CaptureStateOptions {\n /** Registry entity id whose state this is. */\n entity: string\n kind?: StateEntityKind\n /** Declared state name (must match a name in the entity's `states`). */\n state: string\n /**\n * Canonical state kind for the matrix. Defaults to the state name when it is\n * itself a core kind (`loading`/`empty`/`populated`/`error`), else `variant`.\n */\n stateKind?: StateKind\n /**\n * Output dir for PNGs; defaults to `process.env.UIDEX_SHOTS_DIR`. When unset\n * the capture only RECORDS the state (no screenshot) — so a spec left in the\n * normal suite is a no-op beyond the attachment.\n */\n dir?: string\n themes?: readonly Theme[]\n widths?: readonly WidthLabel[]\n /** Awaited after each theme/width change, before the shutter (the state's tell). */\n ready?: (page: Page) => Promise<unknown>\n /**\n * Switch the app's theme. App-specific (next-themes uses localStorage + a\n * reload; others a class or cookie), so it is injected. Defaults to\n * Playwright's `emulateMedia({ colorScheme })`, which suits prefers-color-scheme.\n */\n setTheme?: (page: Page, theme: Theme) => Promise<void>\n /** CSS selectors to mask (opaque box) — for legitimately non-deterministic regions. */\n mask?: string[]\n /** Path to a stylesheet injected before the shot (hide dev chrome; pierces shadow DOM). */\n stylePath?: string\n fullPage?: boolean\n /**\n * Explicit route pattern this capture covers (e.g. \"/[scope]/products\"). By\n * default the observed `page.url()` pathname is recorded and the gate matches\n * it; pass `route` only when the observed URL is unreliable (e.g. a `/shots`\n * harness standing in for a real route).\n */\n route?: string\n}\n\n/** The URL pathname the page is currently on, for route observation. */\nfunction currentPathname(page: Page): string | undefined {\n try {\n return new URL(page.url()).pathname\n } catch {\n return undefined\n }\n}\n\nasync function defaultSetTheme(page: Page, theme: Theme): Promise<void> {\n await page.emulateMedia({ colorScheme: theme })\n}\n\n/** Infer the matrix kind: the state name if it is itself a core kind, else variant. */\nfunction inferStateKind(state: string, explicit?: StateKind): StateKind {\n if (explicit) return explicit\n return (CORE_STATE_KINDS as readonly string[]).includes(state)\n ? (state as StateKind)\n : \"variant\"\n}\n\nfunction shotName(\n opts: CaptureStateOptions,\n theme: Theme,\n label: WidthLabel,\n suffixWidth: boolean\n): string {\n const base = `${opts.entity}/${opts.state}`\n return suffixWidth ? `${base}-${theme}-${label}` : `${base}-${theme}`\n}\n\n/**\n * Capture one declared state across themes × widths, then record every variant\n * for the gate. Screenshot mechanics are native Playwright; determinism knobs\n * come from the project `use` block and the options above. Returns the records\n * it attached (also handy in tests).\n */\nexport async function captureState(\n page: Page,\n testInfo: TestInfoLike,\n opts: CaptureStateOptions\n): Promise<StateRecord[]> {\n const themes = opts.themes ?? THEMES\n const widths = opts.widths ?? ([\"desktop\"] as WidthLabel[])\n const suffixWidth = widths.length > 1\n const dir = opts.dir ?? process.env.UIDEX_SHOTS_DIR\n const setTheme = opts.setTheme ?? defaultSetTheme\n const observedUrl = currentPathname(page)\n const records: StateRecord[] = []\n\n for (const theme of themes) {\n await setTheme(page, theme)\n if (opts.ready) await opts.ready(page)\n for (const label of widths) {\n await page.setViewportSize({ width: WIDTHS[label], height: 900 })\n if (opts.ready) await opts.ready(page)\n const record: StateRecord = {\n entity: opts.entity,\n state: opts.state,\n stateKind: inferStateKind(opts.state, opts.stateKind),\n theme,\n width: label,\n ...(opts.kind ? { kind: opts.kind } : {}),\n ...(opts.route ? { route: opts.route } : {}),\n ...(observedUrl ? { url: observedUrl } : {}),\n }\n if (dir) {\n const path = `${dir}/${shotName(opts, theme, label, suffixWidth)}.png`\n await page.screenshot({\n path,\n fullPage: opts.fullPage ?? true,\n ...(opts.stylePath ? { stylePath: opts.stylePath } : {}),\n ...(opts.mask?.length\n ? {\n mask: opts.mask.map((s) => page.locator(s)),\n maskColor: \"#000000\",\n }\n : {}),\n })\n record.path = path\n }\n records.push(record)\n }\n }\n\n await recordStates(testInfo, records)\n return records\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Page, TestInfo } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic render-state capture for `uidex/playwright`.
|
|
5
|
+
*
|
|
6
|
+
* Two jobs, deliberately split:
|
|
7
|
+
* 1. RECORD which `entity/state` a test captured (a `uidex-states`
|
|
8
|
+
* attachment) — the reporter aggregates these into `uidex-states.json`,
|
|
9
|
+
* which the scanner's completeness gate cross-checks against the states
|
|
10
|
+
* declared in `export const uidex = { states: [...] }`.
|
|
11
|
+
* 2. SHOOT the pixels — a thin wrapper over native `page.screenshot`. The
|
|
12
|
+
* determinism knobs are Playwright's own: `animations: "disabled"` and
|
|
13
|
+
* `caret: "hide"` are defaults, `style`/`stylePath` pierces the shadow DOM
|
|
14
|
+
* to hide dev chrome, `mask` covers non-deterministic regions, and the
|
|
15
|
+
* frozen clock / locale / timezone / colour-scheme live in the project's
|
|
16
|
+
* `use` block. uidex does not re-implement `settle()`.
|
|
17
|
+
*
|
|
18
|
+
* The recording is the load-bearing half: even a spec that shoots nothing can
|
|
19
|
+
* call `recordState` so the gate knows the state exists.
|
|
20
|
+
*/
|
|
21
|
+
declare const STATES_ATTACHMENT = "uidex-states";
|
|
22
|
+
/** Default light + dark; many surfaces use theme-dependent colours. */
|
|
23
|
+
declare const THEMES: readonly ["light", "dark"];
|
|
24
|
+
/** Desktop is primary; narrow exercises the responsive reflow. */
|
|
25
|
+
declare const WIDTHS: {
|
|
26
|
+
readonly desktop: 1440;
|
|
27
|
+
readonly narrow: 430;
|
|
28
|
+
};
|
|
29
|
+
type Theme = (typeof THEMES)[number];
|
|
30
|
+
type WidthLabel = keyof typeof WIDTHS;
|
|
31
|
+
type StateEntityKind = "page" | "feature" | "widget";
|
|
32
|
+
/** The four kinds the state-matrix gate expects every captured route to cover. */
|
|
33
|
+
declare const CORE_STATE_KINDS: readonly ["loading", "empty", "populated", "error"];
|
|
34
|
+
type CoreStateKind = (typeof CORE_STATE_KINDS)[number];
|
|
35
|
+
/** `variant` = any state outside the matrix (dialog, wizard step, multi-currency…). */
|
|
36
|
+
type StateKind = CoreStateKind | "variant";
|
|
37
|
+
/** One captured `entity/state` variant. */
|
|
38
|
+
interface StateRecord {
|
|
39
|
+
/** Registry entity id whose state was rendered (e.g. "products"). */
|
|
40
|
+
entity: string;
|
|
41
|
+
/** Entity kind, so the gate can disambiguate a page vs feature of the same id. */
|
|
42
|
+
kind?: StateEntityKind;
|
|
43
|
+
/** Declared state name (e.g. "new-filled"). */
|
|
44
|
+
state: string;
|
|
45
|
+
/** Canonical kind for the state-matrix axis (defaults inferred from the name). */
|
|
46
|
+
stateKind?: StateKind;
|
|
47
|
+
theme?: string;
|
|
48
|
+
width?: string;
|
|
49
|
+
/** Screenshot path, when one was written. */
|
|
50
|
+
path?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The URL pathname the capture ran against, observed from `page.url()`. The
|
|
53
|
+
* page-coverage gate normalizes it to a derived route pattern — so which route
|
|
54
|
+
* a capture exercised is observed, never hand-declared. A `/shots/*` harness
|
|
55
|
+
* URL matches no real route and gates nothing (correct for component captures).
|
|
56
|
+
*/
|
|
57
|
+
url?: string;
|
|
58
|
+
/** Explicit route pattern override, when the observed URL can't be trusted. */
|
|
59
|
+
route?: string;
|
|
60
|
+
}
|
|
61
|
+
/** The `uidex-states` attachment body — an array of records from one test. */
|
|
62
|
+
interface StatesPayload {
|
|
63
|
+
records: StateRecord[];
|
|
64
|
+
}
|
|
65
|
+
type TestInfoLike = Pick<TestInfo, "attach">;
|
|
66
|
+
/**
|
|
67
|
+
* Attach one or more captured-state records to the test. Idempotent per call;
|
|
68
|
+
* the reporter concatenates every test's records. Use directly when you shoot
|
|
69
|
+
* with your own screenshot call and only need the state registered for the gate.
|
|
70
|
+
*/
|
|
71
|
+
declare function recordStates(testInfo: TestInfoLike, records: StateRecord[]): Promise<void>;
|
|
72
|
+
interface CaptureStateOptions {
|
|
73
|
+
/** Registry entity id whose state this is. */
|
|
74
|
+
entity: string;
|
|
75
|
+
kind?: StateEntityKind;
|
|
76
|
+
/** Declared state name (must match a name in the entity's `states`). */
|
|
77
|
+
state: string;
|
|
78
|
+
/**
|
|
79
|
+
* Canonical state kind for the matrix. Defaults to the state name when it is
|
|
80
|
+
* itself a core kind (`loading`/`empty`/`populated`/`error`), else `variant`.
|
|
81
|
+
*/
|
|
82
|
+
stateKind?: StateKind;
|
|
83
|
+
/**
|
|
84
|
+
* Output dir for PNGs; defaults to `process.env.UIDEX_SHOTS_DIR`. When unset
|
|
85
|
+
* the capture only RECORDS the state (no screenshot) — so a spec left in the
|
|
86
|
+
* normal suite is a no-op beyond the attachment.
|
|
87
|
+
*/
|
|
88
|
+
dir?: string;
|
|
89
|
+
themes?: readonly Theme[];
|
|
90
|
+
widths?: readonly WidthLabel[];
|
|
91
|
+
/** Awaited after each theme/width change, before the shutter (the state's tell). */
|
|
92
|
+
ready?: (page: Page) => Promise<unknown>;
|
|
93
|
+
/**
|
|
94
|
+
* Switch the app's theme. App-specific (next-themes uses localStorage + a
|
|
95
|
+
* reload; others a class or cookie), so it is injected. Defaults to
|
|
96
|
+
* Playwright's `emulateMedia({ colorScheme })`, which suits prefers-color-scheme.
|
|
97
|
+
*/
|
|
98
|
+
setTheme?: (page: Page, theme: Theme) => Promise<void>;
|
|
99
|
+
/** CSS selectors to mask (opaque box) — for legitimately non-deterministic regions. */
|
|
100
|
+
mask?: string[];
|
|
101
|
+
/** Path to a stylesheet injected before the shot (hide dev chrome; pierces shadow DOM). */
|
|
102
|
+
stylePath?: string;
|
|
103
|
+
fullPage?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Explicit route pattern this capture covers (e.g. "/[scope]/products"). By
|
|
106
|
+
* default the observed `page.url()` pathname is recorded and the gate matches
|
|
107
|
+
* it; pass `route` only when the observed URL is unreliable (e.g. a `/shots`
|
|
108
|
+
* harness standing in for a real route).
|
|
109
|
+
*/
|
|
110
|
+
route?: string;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Capture one declared state across themes × widths, then record every variant
|
|
114
|
+
* for the gate. Screenshot mechanics are native Playwright; determinism knobs
|
|
115
|
+
* come from the project `use` block and the options above. Returns the records
|
|
116
|
+
* it attached (also handy in tests).
|
|
117
|
+
*/
|
|
118
|
+
declare function captureState(page: Page, testInfo: TestInfoLike, opts: CaptureStateOptions): Promise<StateRecord[]>;
|
|
119
|
+
|
|
120
|
+
export { CORE_STATE_KINDS, type CaptureStateOptions, type CoreStateKind, STATES_ATTACHMENT, type StateEntityKind, type StateKind, type StateRecord, type StatesPayload, THEMES, type Theme, WIDTHS, type WidthLabel, captureState, recordStates };
|