uidex 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/playwright/index.cjs +19 -0
- package/dist/playwright/index.cjs.map +1 -1
- package/dist/playwright/index.js +19 -0
- package/dist/playwright/index.js.map +1 -1
- package/dist/playwright/reporter.cjs +19 -0
- package/dist/playwright/reporter.cjs.map +1 -1
- package/dist/playwright/reporter.js +19 -0
- package/dist/playwright/reporter.js.map +1 -1
- package/package.json +3 -3
|
@@ -180,6 +180,15 @@ var UidexCoverageReporter = class {
|
|
|
180
180
|
total,
|
|
181
181
|
percentage
|
|
182
182
|
};
|
|
183
|
+
const observedNothing = flows.length === 0 && this.untagged.length === 0 && this.touched.size === 0;
|
|
184
|
+
if (observedNothing && hasExistingReport(path.resolve(this.outputPath))) {
|
|
185
|
+
if (!this.silent) {
|
|
186
|
+
console.log(
|
|
187
|
+
`uidex coverage: run observed no flows/entities \u2014 leaving ${this.outputPath} untouched (filtered run?)`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
183
192
|
fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {
|
|
184
193
|
recursive: true
|
|
185
194
|
});
|
|
@@ -193,6 +202,16 @@ var UidexCoverageReporter = class {
|
|
|
193
202
|
}
|
|
194
203
|
}
|
|
195
204
|
};
|
|
205
|
+
function hasExistingReport(resolvedPath) {
|
|
206
|
+
try {
|
|
207
|
+
const existing = JSON.parse(
|
|
208
|
+
fs.readFileSync(resolvedPath, "utf8")
|
|
209
|
+
);
|
|
210
|
+
return (existing.flows?.length ?? 0) > 0 || (existing.untagged?.length ?? 0) > 0 || (existing.touched?.length ?? 0) > 0;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
196
215
|
function parsePayload(raw) {
|
|
197
216
|
try {
|
|
198
217
|
const parsed = JSON.parse(raw);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/integrations/playwright/index.ts","../../src/integrations/playwright/selector.ts","../../src/integrations/playwright/fixture.ts","../../src/integrations/playwright/reporter.ts","../../src/integrations/playwright/states.ts","../../src/integrations/playwright/states-reporter.ts"],"sourcesContent":["export {\n COVERAGE_ATTACHMENT,\n FLOW_TAG,\n NOT_FLOW_TAG,\n UIDEX_ATTRS,\n uidexSelector,\n type CoveragePayload,\n} from \"./selector\"\nexport {\n test,\n expect,\n resolveFlow,\n createUidexFixture,\n uidexCovers,\n} from \"./fixture\"\nexport type { UidexFixtures, UidexLocator, UidexFixtureHandle } from \"./fixture\"\nexport {\n default as UidexCoverageReporter,\n type FlowCoverage,\n type UidexCoverageReport,\n type UidexReporterOptions,\n} from \"./reporter\"\nexport {\n STATES_ATTACHMENT,\n THEMES,\n WIDTHS,\n CORE_STATE_KINDS,\n recordStates,\n captureState,\n type Theme,\n type WidthLabel,\n type StateEntityKind,\n type StateKind,\n type CoreStateKind,\n type StateRecord,\n type StatesPayload,\n type CaptureStateOptions,\n} from \"./states\"\nexport {\n default as UidexStatesReporter,\n aggregateStates,\n type CapturedStateEntry,\n type CapturedStatesManifestOut,\n type UidexStatesReporterOptions,\n} from \"./states-reporter\"\n","const ATTRS = [\n \"data-uidex\",\n \"data-uidex-region\",\n \"data-uidex-widget\",\n \"data-uidex-primitive\",\n] as const\n\nexport const UIDEX_ATTRS = ATTRS\n\nexport function uidexSelector(id: string): string {\n const escaped = id.replace(/\"/g, '\\\\\"')\n return ATTRS.map((a) => `[${a}=\"${escaped}\"]`).join(\", \")\n}\n\nexport function kebab(input: string): string {\n return input\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\")\n}\n\nexport const FLOW_TAG = \"@uidex:flow\"\nexport const NOT_FLOW_TAG = \"@uidex:not-flow\"\nexport const COVERAGE_ATTACHMENT = \"uidex-coverage\"\n\nexport interface CoveragePayload {\n flow: string | null\n notFlow: boolean\n ids: string[]\n title: string\n}\n","import { test as base, expect } from \"@playwright/test\"\nimport type { Locator, TestInfo } from \"@playwright/test\"\nimport {\n COVERAGE_ATTACHMENT,\n FLOW_TAG,\n NOT_FLOW_TAG,\n kebab,\n uidexSelector,\n type CoveragePayload,\n} from \"./selector\"\n\nexport type UidexLocator<T extends string = string> = (id: T) => Locator\n\nexport interface UidexFixtures {\n uidex: UidexLocator\n}\n\ninterface FlowResolution {\n flow: string | null\n notFlow: boolean\n}\n\ntype TestInfoLike = Pick<TestInfo, \"tags\" | \"titlePath\" | \"title\">\n\nexport function resolveFlow(testInfo: TestInfoLike): FlowResolution {\n const tags = testInfo.tags ?? []\n const notFlow = tags.includes(NOT_FLOW_TAG)\n if (notFlow) return { flow: null, notFlow: true }\n if (!tags.includes(FLOW_TAG)) return { flow: null, notFlow: false }\n const describes = describeTitles(testInfo.titlePath ?? [], testInfo.title)\n const source =\n describes.length > 0\n ? describes[describes.length - 1]\n : (testInfo.title ?? \"\")\n return { flow: kebab(source) || null, notFlow: false }\n}\n\nconst FILE_RE = /\\.(spec|test)\\.(t|j)sx?$|\\.(t|j)sx?$|[\\\\/]/\n\nfunction describeTitles(titlePath: string[], testTitle: string): string[] {\n const end =\n titlePath.length > 0 && titlePath[titlePath.length - 1] === testTitle\n ? titlePath.length - 1\n : titlePath.length\n const out: string[] = []\n for (let i = 0; i < end; i++) {\n const entry = titlePath[i]\n if (!entry) continue\n if (FILE_RE.test(entry)) continue\n if (i === 0) continue // project name\n out.push(entry)\n }\n return out\n}\n\ninterface PageLike {\n locator: (selector: string) => Locator\n}\n\nexport interface UidexFixtureHandle {\n locator: UidexLocator\n buildPayload: () => CoveragePayload\n}\n\nexport function createUidexFixture(\n page: PageLike,\n testInfo: TestInfoLike\n): UidexFixtureHandle {\n const used = new Set<string>()\n const locator: UidexLocator = (id: string) => {\n used.add(id)\n return page.locator(uidexSelector(id))\n }\n const buildPayload = (): CoveragePayload => {\n const { flow, notFlow } = resolveFlow(testInfo)\n return {\n flow,\n notFlow,\n ids: [...used].sort(),\n title: testInfo.title,\n }\n }\n return { locator, buildPayload }\n}\n\n/**\n * No-op runtime marker for per-criterion acceptance coverage. Written inside a\n * tagged `@uidex:flow` describe as\n * `uidexCovers(\"<entityId>\", \"<criterionId>@<rev>\", ...)`; the scanner reads\n * the call statically (flow-facts) and binds the flow to those criteria at the\n * pinned revs. At runtime it does nothing — the claim is documentation the\n * audit can hold the spec to, not behavior.\n *\n * At least one criterion is REQUIRED (in the signature here and as a\n * `covers-missing-rev` parse diagnostic in the scanner): a bare\n * `uidexCovers(\"entity\")` registers nothing, which is exactly the call an\n * author migrating from the old entity-level model would write.\n */\nexport function uidexCovers(\n _entity: string,\n _firstCriterion: string,\n ..._criteria: string[]\n): void {\n // intentionally empty — statically analysed, never executed for effect\n}\n\nexport const test = base.extend<UidexFixtures>({\n uidex: async ({ page }, use, testInfo) => {\n const handle = createUidexFixture(page, testInfo)\n // eslint-disable-next-line react-hooks/rules-of-hooks\n await use(handle.locator)\n await testInfo.attach(COVERAGE_ATTACHMENT, {\n body: JSON.stringify(handle.buildPayload()),\n contentType: \"application/json\",\n })\n },\n})\n\nexport { expect }\n","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 { COVERAGE_ATTACHMENT, type CoveragePayload } from \"./selector\"\n\nexport interface UidexReporterOptions {\n outputPath?: string\n entityIds?: readonly string[]\n silent?: boolean\n}\n\nexport interface FlowCoverage {\n flow: string\n ids: string[]\n titles: string[]\n}\n\nexport interface UidexCoverageReport {\n flows: FlowCoverage[]\n untagged: { title: string; ids: string[] }[]\n touched: string[]\n untouched: string[]\n total: number\n percentage: number\n}\n\nexport default class UidexCoverageReporter implements Reporter {\n private readonly outputPath: string\n private readonly entityIds: readonly string[]\n private readonly silent: boolean\n private readonly flows = new Map<\n string,\n { ids: Set<string>; titles: Set<string> }\n >()\n private readonly untagged: { title: string; ids: string[] }[] = []\n private readonly touched = new Set<string>()\n\n constructor(options: UidexReporterOptions = {}) {\n this.outputPath = options.outputPath ?? \"uidex-coverage.json\"\n this.entityIds = options.entityIds ?? []\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 !== COVERAGE_ATTACHMENT) continue\n if (!attachment.body) continue\n const payload = parsePayload(attachment.body.toString())\n if (!payload) continue\n if (payload.notFlow) continue\n for (const id of payload.ids) this.touched.add(id)\n if (payload.flow) {\n const entry = this.flows.get(payload.flow) ?? {\n ids: new Set<string>(),\n titles: new Set<string>(),\n }\n for (const id of payload.ids) entry.ids.add(id)\n entry.titles.add(payload.title)\n this.flows.set(payload.flow, entry)\n } else {\n this.untagged.push({ title: payload.title, ids: payload.ids })\n }\n }\n }\n\n async onEnd(_result: FullResult): Promise<void> {\n const flows: FlowCoverage[] = [...this.flows.entries()]\n .map(([flow, entry]) => ({\n flow,\n ids: [...entry.ids].sort(),\n titles: [...entry.titles].sort(),\n }))\n .sort((a, b) => a.flow.localeCompare(b.flow))\n\n const all = [...this.entityIds]\n const touched = all.filter((id) => this.touched.has(id)).sort()\n const untouched = all.filter((id) => !this.touched.has(id)).sort()\n const total = all.length\n const percentage =\n total > 0 ? Math.round((touched.length / total) * 100) : 0\n\n const report: UidexCoverageReport = {\n flows,\n untagged: this.untagged,\n touched,\n untouched,\n total,\n percentage,\n }\n\n fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {\n recursive: true,\n })\n fs.writeFileSync(\n path.resolve(this.outputPath),\n JSON.stringify(report, null, 2) + \"\\n\"\n )\n\n if (!this.silent) {\n const line =\n total > 0\n ? `uidex coverage: ${touched.length}/${total} entities (${percentage}%) across ${flows.length} flow(s)`\n : `uidex coverage: ${flows.length} flow(s), ${this.touched.size} entity id(s) touched`\n\n console.log(line)\n }\n }\n}\n\nfunction parsePayload(raw: string): CoveragePayload | null {\n try {\n const parsed = JSON.parse(raw) as Partial<CoveragePayload>\n if (!parsed || !Array.isArray(parsed.ids)) return null\n return {\n flow: typeof parsed.flow === \"string\" ? parsed.flow : null,\n notFlow: Boolean(parsed.notFlow),\n ids: parsed.ids.filter((x): x is string => typeof x === \"string\"),\n title: typeof parsed.title === \"string\" ? parsed.title : \"\",\n }\n } catch {\n return null\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\n/**\n * The declared state space a generated `UidexStates` describes: entity kind →\n * entity id → that entity's own state names.\n */\nexport type StateSpace = {\n [K in StateEntityKind]: Record<string, string> | never\n}\n\n/**\n * Capture options narrowed to ONE entity of one kind, with only the states that\n * entity actually declares. Built as a union over (kind, entity) pairs so the\n * three fields are checked together — picking `widget`/`gate-badge` restricts\n * `state` to gate-badge's own names, and a page id is not assignable at all.\n */\nexport type TypedCaptureOptions<M> = {\n [K in keyof M & StateEntityKind]: M[K] extends Record<string, string>\n ? {\n [E in keyof M[K] & string]: Omit<\n CaptureStateOptions,\n \"entity\" | \"kind\" | \"state\"\n > & { kind: K; entity: E; state: M[K][E] }\n }[keyof M[K] & string]\n : never\n}[keyof M & StateEntityKind]\n\nexport interface TypedStateCapture<M> {\n captureState(\n page: Page,\n testInfo: TestInfoLike,\n opts: TypedCaptureOptions<M>\n ): Promise<StateRecord[]>\n recordStates(testInfo: TestInfoLike, records: StateRecord[]): Promise<void>\n}\n\n/**\n * Bind the capture helpers to a project's generated state space:\n *\n * ```ts\n * // e2e/states/capture.ts — the ONE place e2e meets the registry\n * import { createStateCapture } from \"uidex/playwright\"\n * import type { UidexStates } from \"@/uidex.gen\"\n * export const { captureState } = createStateCapture<UidexStates>()\n * ```\n *\n * Purely a type-level narrowing — the runtime is the untyped `captureState`.\n * Its value is closing the loop that let capture ids drift from the registry:\n * before this, `entity` was a free string, so a spec could invent a name the\n * registry had never heard of and nothing failed until a lint pass noticed the\n * mismatch (or, when the entity kind was also wrong, never noticed at all).\n */\nexport function createStateCapture<\n M extends Partial<StateSpace>,\n>(): TypedStateCapture<M> {\n return {\n captureState: (page, testInfo, opts) =>\n captureState(page, testInfo, opts as unknown as CaptureStateOptions),\n recordStates,\n }\n}\n","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 * ISO timestamp of the run that wrote this manifest. The scanner compares it\n * to the mtimes of the files declaring states, so a manifest that predates a\n * declaration edit is reported as stale rather than silently trusted.\n */\n generatedAt?: string\n}\n\nexport interface UidexStatesReporterOptions {\n outputPath?: string\n silent?: boolean\n /**\n * Write to `<outputPath>` even when the run captured FEWER states than the\n * manifest already on disk. Off by default: a filtered (`--grep`) or flaky\n * run legitimately produces a subset, and blindly overwriting turns that into\n * silent coverage loss — the run exits non-zero, but the manifest is already\n * gone. See the shrink guard in `onEnd`.\n */\n allowShrink?: boolean\n}\n\n/** Identity of a captured state for shrink comparison. */\nfunction capturedKey(c: CapturedStateEntry): string {\n return `${c.kind ?? \"page\"}\u0000${c.entity}\u0000${c.state}`\n}\n\nfunction readExisting(p: string): CapturedStatesManifestOut | null {\n try {\n const parsed = JSON.parse(fs.readFileSync(p, \"utf8\")) as unknown\n if (!parsed || typeof parsed !== \"object\") return null\n const m = parsed as Partial<CapturedStatesManifestOut>\n return Array.isArray(m.captured)\n ? { captured: m.captured, variants: m.variants ?? [] }\n : null\n } catch {\n return null // absent or unreadable → nothing to protect\n }\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 allowShrink: 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 this.allowShrink = options.allowShrink ?? 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 manifest.generatedAt = new Date().toISOString()\n const target = path.resolve(this.outputPath)\n fs.mkdirSync(path.dirname(target), { recursive: true })\n\n // ---- Shrink guard ----\n // A filtered or flaky run captures a SUBSET. Writing it over the committed\n // manifest silently deletes coverage: the states simply vanish, the gate\n // then agrees nothing is missing, and the only signal is a state count you\n // had to be watching. So when the run would drop states, divert it to\n // `<name>.partial.json`, leave the committed manifest untouched, and name\n // the dropped keys — the caller decides whether it was flake or a real\n // removal (for a deliberate removal, copy the partial over, or re-run with\n // `allowShrink`).\n const existing = this.allowShrink ? null : readExisting(target)\n const dropped = existing\n ? (() => {\n const now = new Set(manifest.captured.map(capturedKey))\n return existing.captured\n .map(capturedKey)\n .filter((k) => !now.has(k))\n .sort()\n })()\n : []\n\n const shrunk = dropped.length > 0\n const out = shrunk\n ? target.replace(/\\.json$/i, \"\") + \".partial.json\"\n : target\n fs.writeFileSync(out, JSON.stringify(manifest, null, 2) + \"\\n\")\n\n if (shrunk) {\n console.error(\n `uidex states: ⚠ run captured ${manifest.captured.length} state(s) but ${existing!.captured.length} are committed — ${dropped.length} would be DROPPED, so ${path.basename(target)} was left untouched and this run went to ${path.basename(out)}.`\n )\n console.error(` dropped: ${dropped.join(\", \")}`)\n console.error(\n ` A consistent count across two runs is a genuinely broken spec; a varying one is flake. For a deliberate removal, copy the partial over or set allowShrink.`\n )\n return\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"],"mappings":"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","names":["base","base","path","fs","path","parsePayload"]}
|
|
1
|
+
{"version":3,"sources":["../../src/integrations/playwright/index.ts","../../src/integrations/playwright/selector.ts","../../src/integrations/playwright/fixture.ts","../../src/integrations/playwright/reporter.ts","../../src/integrations/playwright/states.ts","../../src/integrations/playwright/states-reporter.ts"],"sourcesContent":["export {\n COVERAGE_ATTACHMENT,\n FLOW_TAG,\n NOT_FLOW_TAG,\n UIDEX_ATTRS,\n uidexSelector,\n type CoveragePayload,\n} from \"./selector\"\nexport {\n test,\n expect,\n resolveFlow,\n createUidexFixture,\n uidexCovers,\n} from \"./fixture\"\nexport type { UidexFixtures, UidexLocator, UidexFixtureHandle } from \"./fixture\"\nexport {\n default as UidexCoverageReporter,\n type FlowCoverage,\n type UidexCoverageReport,\n type UidexReporterOptions,\n} from \"./reporter\"\nexport {\n STATES_ATTACHMENT,\n THEMES,\n WIDTHS,\n CORE_STATE_KINDS,\n recordStates,\n captureState,\n type Theme,\n type WidthLabel,\n type StateEntityKind,\n type StateKind,\n type CoreStateKind,\n type StateRecord,\n type StatesPayload,\n type CaptureStateOptions,\n} from \"./states\"\nexport {\n default as UidexStatesReporter,\n aggregateStates,\n type CapturedStateEntry,\n type CapturedStatesManifestOut,\n type UidexStatesReporterOptions,\n} from \"./states-reporter\"\n","const ATTRS = [\n \"data-uidex\",\n \"data-uidex-region\",\n \"data-uidex-widget\",\n \"data-uidex-primitive\",\n] as const\n\nexport const UIDEX_ATTRS = ATTRS\n\nexport function uidexSelector(id: string): string {\n const escaped = id.replace(/\"/g, '\\\\\"')\n return ATTRS.map((a) => `[${a}=\"${escaped}\"]`).join(\", \")\n}\n\nexport function kebab(input: string): string {\n return input\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\")\n}\n\nexport const FLOW_TAG = \"@uidex:flow\"\nexport const NOT_FLOW_TAG = \"@uidex:not-flow\"\nexport const COVERAGE_ATTACHMENT = \"uidex-coverage\"\n\nexport interface CoveragePayload {\n flow: string | null\n notFlow: boolean\n ids: string[]\n title: string\n}\n","import { test as base, expect } from \"@playwright/test\"\nimport type { Locator, TestInfo } from \"@playwright/test\"\nimport {\n COVERAGE_ATTACHMENT,\n FLOW_TAG,\n NOT_FLOW_TAG,\n kebab,\n uidexSelector,\n type CoveragePayload,\n} from \"./selector\"\n\nexport type UidexLocator<T extends string = string> = (id: T) => Locator\n\nexport interface UidexFixtures {\n uidex: UidexLocator\n}\n\ninterface FlowResolution {\n flow: string | null\n notFlow: boolean\n}\n\ntype TestInfoLike = Pick<TestInfo, \"tags\" | \"titlePath\" | \"title\">\n\nexport function resolveFlow(testInfo: TestInfoLike): FlowResolution {\n const tags = testInfo.tags ?? []\n const notFlow = tags.includes(NOT_FLOW_TAG)\n if (notFlow) return { flow: null, notFlow: true }\n if (!tags.includes(FLOW_TAG)) return { flow: null, notFlow: false }\n const describes = describeTitles(testInfo.titlePath ?? [], testInfo.title)\n const source =\n describes.length > 0\n ? describes[describes.length - 1]\n : (testInfo.title ?? \"\")\n return { flow: kebab(source) || null, notFlow: false }\n}\n\nconst FILE_RE = /\\.(spec|test)\\.(t|j)sx?$|\\.(t|j)sx?$|[\\\\/]/\n\nfunction describeTitles(titlePath: string[], testTitle: string): string[] {\n const end =\n titlePath.length > 0 && titlePath[titlePath.length - 1] === testTitle\n ? titlePath.length - 1\n : titlePath.length\n const out: string[] = []\n for (let i = 0; i < end; i++) {\n const entry = titlePath[i]\n if (!entry) continue\n if (FILE_RE.test(entry)) continue\n if (i === 0) continue // project name\n out.push(entry)\n }\n return out\n}\n\ninterface PageLike {\n locator: (selector: string) => Locator\n}\n\nexport interface UidexFixtureHandle {\n locator: UidexLocator\n buildPayload: () => CoveragePayload\n}\n\nexport function createUidexFixture(\n page: PageLike,\n testInfo: TestInfoLike\n): UidexFixtureHandle {\n const used = new Set<string>()\n const locator: UidexLocator = (id: string) => {\n used.add(id)\n return page.locator(uidexSelector(id))\n }\n const buildPayload = (): CoveragePayload => {\n const { flow, notFlow } = resolveFlow(testInfo)\n return {\n flow,\n notFlow,\n ids: [...used].sort(),\n title: testInfo.title,\n }\n }\n return { locator, buildPayload }\n}\n\n/**\n * No-op runtime marker for per-criterion acceptance coverage. Written inside a\n * tagged `@uidex:flow` describe as\n * `uidexCovers(\"<entityId>\", \"<criterionId>@<rev>\", ...)`; the scanner reads\n * the call statically (flow-facts) and binds the flow to those criteria at the\n * pinned revs. At runtime it does nothing — the claim is documentation the\n * audit can hold the spec to, not behavior.\n *\n * At least one criterion is REQUIRED (in the signature here and as a\n * `covers-missing-rev` parse diagnostic in the scanner): a bare\n * `uidexCovers(\"entity\")` registers nothing, which is exactly the call an\n * author migrating from the old entity-level model would write.\n */\nexport function uidexCovers(\n _entity: string,\n _firstCriterion: string,\n ..._criteria: string[]\n): void {\n // intentionally empty — statically analysed, never executed for effect\n}\n\nexport const test = base.extend<UidexFixtures>({\n uidex: async ({ page }, use, testInfo) => {\n const handle = createUidexFixture(page, testInfo)\n // eslint-disable-next-line react-hooks/rules-of-hooks\n await use(handle.locator)\n await testInfo.attach(COVERAGE_ATTACHMENT, {\n body: JSON.stringify(handle.buildPayload()),\n contentType: \"application/json\",\n })\n },\n})\n\nexport { expect }\n","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 { COVERAGE_ATTACHMENT, type CoveragePayload } from \"./selector\"\n\nexport interface UidexReporterOptions {\n outputPath?: string\n entityIds?: readonly string[]\n silent?: boolean\n}\n\nexport interface FlowCoverage {\n flow: string\n ids: string[]\n titles: string[]\n}\n\nexport interface UidexCoverageReport {\n flows: FlowCoverage[]\n untagged: { title: string; ids: string[] }[]\n touched: string[]\n untouched: string[]\n total: number\n percentage: number\n}\n\nexport default class UidexCoverageReporter implements Reporter {\n private readonly outputPath: string\n private readonly entityIds: readonly string[]\n private readonly silent: boolean\n private readonly flows = new Map<\n string,\n { ids: Set<string>; titles: Set<string> }\n >()\n private readonly untagged: { title: string; ids: string[] }[] = []\n private readonly touched = new Set<string>()\n\n constructor(options: UidexReporterOptions = {}) {\n this.outputPath = options.outputPath ?? \"uidex-coverage.json\"\n this.entityIds = options.entityIds ?? []\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 !== COVERAGE_ATTACHMENT) continue\n if (!attachment.body) continue\n const payload = parsePayload(attachment.body.toString())\n if (!payload) continue\n if (payload.notFlow) continue\n for (const id of payload.ids) this.touched.add(id)\n if (payload.flow) {\n const entry = this.flows.get(payload.flow) ?? {\n ids: new Set<string>(),\n titles: new Set<string>(),\n }\n for (const id of payload.ids) entry.ids.add(id)\n entry.titles.add(payload.title)\n this.flows.set(payload.flow, entry)\n } else {\n this.untagged.push({ title: payload.title, ids: payload.ids })\n }\n }\n }\n\n async onEnd(_result: FullResult): Promise<void> {\n const flows: FlowCoverage[] = [...this.flows.entries()]\n .map(([flow, entry]) => ({\n flow,\n ids: [...entry.ids].sort(),\n titles: [...entry.titles].sort(),\n }))\n .sort((a, b) => a.flow.localeCompare(b.flow))\n\n const all = [...this.entityIds]\n const touched = all.filter((id) => this.touched.has(id)).sort()\n const untouched = all.filter((id) => !this.touched.has(id)).sort()\n const total = all.length\n const percentage =\n total > 0 ? Math.round((touched.length / total) * 100) : 0\n\n const report: UidexCoverageReport = {\n flows,\n untagged: this.untagged,\n touched,\n untouched,\n total,\n percentage,\n }\n\n // An EMPTY run must not truncate a committed report. A filtered invocation\n // (`--grep` matching nothing in this project) still reaches onEnd, and\n // unconditionally writing would replace real flow coverage with an empty\n // shell — the same footgun the states reporter guards with its shrink\n // check. Zero observations + a non-empty report on disk → leave the file\n // alone and say so; a genuinely empty project (no prior report) still\n // writes, so initial generation is unaffected.\n const observedNothing =\n flows.length === 0 &&\n this.untagged.length === 0 &&\n this.touched.size === 0\n if (observedNothing && hasExistingReport(path.resolve(this.outputPath))) {\n if (!this.silent) {\n console.log(\n `uidex coverage: run observed no flows/entities — leaving ${this.outputPath} untouched (filtered run?)`\n )\n }\n return\n }\n\n fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {\n recursive: true,\n })\n fs.writeFileSync(\n path.resolve(this.outputPath),\n JSON.stringify(report, null, 2) + \"\\n\"\n )\n\n if (!this.silent) {\n const line =\n total > 0\n ? `uidex coverage: ${touched.length}/${total} entities (${percentage}%) across ${flows.length} flow(s)`\n : `uidex coverage: ${flows.length} flow(s), ${this.touched.size} entity id(s) touched`\n\n console.log(line)\n }\n }\n}\n\n/** Whether a prior report with actual content exists at the output path. */\nfunction hasExistingReport(resolvedPath: string): boolean {\n try {\n const existing = JSON.parse(\n fs.readFileSync(resolvedPath, \"utf8\")\n ) as Partial<UidexCoverageReport>\n return (\n (existing.flows?.length ?? 0) > 0 ||\n (existing.untagged?.length ?? 0) > 0 ||\n (existing.touched?.length ?? 0) > 0\n )\n } catch {\n return false\n }\n}\n\nfunction parsePayload(raw: string): CoveragePayload | null {\n try {\n const parsed = JSON.parse(raw) as Partial<CoveragePayload>\n if (!parsed || !Array.isArray(parsed.ids)) return null\n return {\n flow: typeof parsed.flow === \"string\" ? parsed.flow : null,\n notFlow: Boolean(parsed.notFlow),\n ids: parsed.ids.filter((x): x is string => typeof x === \"string\"),\n title: typeof parsed.title === \"string\" ? parsed.title : \"\",\n }\n } catch {\n return null\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\n/**\n * The declared state space a generated `UidexStates` describes: entity kind →\n * entity id → that entity's own state names.\n */\nexport type StateSpace = {\n [K in StateEntityKind]: Record<string, string> | never\n}\n\n/**\n * Capture options narrowed to ONE entity of one kind, with only the states that\n * entity actually declares. Built as a union over (kind, entity) pairs so the\n * three fields are checked together — picking `widget`/`gate-badge` restricts\n * `state` to gate-badge's own names, and a page id is not assignable at all.\n */\nexport type TypedCaptureOptions<M> = {\n [K in keyof M & StateEntityKind]: M[K] extends Record<string, string>\n ? {\n [E in keyof M[K] & string]: Omit<\n CaptureStateOptions,\n \"entity\" | \"kind\" | \"state\"\n > & { kind: K; entity: E; state: M[K][E] }\n }[keyof M[K] & string]\n : never\n}[keyof M & StateEntityKind]\n\nexport interface TypedStateCapture<M> {\n captureState(\n page: Page,\n testInfo: TestInfoLike,\n opts: TypedCaptureOptions<M>\n ): Promise<StateRecord[]>\n recordStates(testInfo: TestInfoLike, records: StateRecord[]): Promise<void>\n}\n\n/**\n * Bind the capture helpers to a project's generated state space:\n *\n * ```ts\n * // e2e/states/capture.ts — the ONE place e2e meets the registry\n * import { createStateCapture } from \"uidex/playwright\"\n * import type { UidexStates } from \"@/uidex.gen\"\n * export const { captureState } = createStateCapture<UidexStates>()\n * ```\n *\n * Purely a type-level narrowing — the runtime is the untyped `captureState`.\n * Its value is closing the loop that let capture ids drift from the registry:\n * before this, `entity` was a free string, so a spec could invent a name the\n * registry had never heard of and nothing failed until a lint pass noticed the\n * mismatch (or, when the entity kind was also wrong, never noticed at all).\n */\nexport function createStateCapture<\n M extends Partial<StateSpace>,\n>(): TypedStateCapture<M> {\n return {\n captureState: (page, testInfo, opts) =>\n captureState(page, testInfo, opts as unknown as CaptureStateOptions),\n recordStates,\n }\n}\n","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 * ISO timestamp of the run that wrote this manifest. The scanner compares it\n * to the mtimes of the files declaring states, so a manifest that predates a\n * declaration edit is reported as stale rather than silently trusted.\n */\n generatedAt?: string\n}\n\nexport interface UidexStatesReporterOptions {\n outputPath?: string\n silent?: boolean\n /**\n * Write to `<outputPath>` even when the run captured FEWER states than the\n * manifest already on disk. Off by default: a filtered (`--grep`) or flaky\n * run legitimately produces a subset, and blindly overwriting turns that into\n * silent coverage loss — the run exits non-zero, but the manifest is already\n * gone. See the shrink guard in `onEnd`.\n */\n allowShrink?: boolean\n}\n\n/** Identity of a captured state for shrink comparison. */\nfunction capturedKey(c: CapturedStateEntry): string {\n return `${c.kind ?? \"page\"}\u0000${c.entity}\u0000${c.state}`\n}\n\nfunction readExisting(p: string): CapturedStatesManifestOut | null {\n try {\n const parsed = JSON.parse(fs.readFileSync(p, \"utf8\")) as unknown\n if (!parsed || typeof parsed !== \"object\") return null\n const m = parsed as Partial<CapturedStatesManifestOut>\n return Array.isArray(m.captured)\n ? { captured: m.captured, variants: m.variants ?? [] }\n : null\n } catch {\n return null // absent or unreadable → nothing to protect\n }\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 allowShrink: 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 this.allowShrink = options.allowShrink ?? 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 manifest.generatedAt = new Date().toISOString()\n const target = path.resolve(this.outputPath)\n fs.mkdirSync(path.dirname(target), { recursive: true })\n\n // ---- Shrink guard ----\n // A filtered or flaky run captures a SUBSET. Writing it over the committed\n // manifest silently deletes coverage: the states simply vanish, the gate\n // then agrees nothing is missing, and the only signal is a state count you\n // had to be watching. So when the run would drop states, divert it to\n // `<name>.partial.json`, leave the committed manifest untouched, and name\n // the dropped keys — the caller decides whether it was flake or a real\n // removal (for a deliberate removal, copy the partial over, or re-run with\n // `allowShrink`).\n const existing = this.allowShrink ? null : readExisting(target)\n const dropped = existing\n ? (() => {\n const now = new Set(manifest.captured.map(capturedKey))\n return existing.captured\n .map(capturedKey)\n .filter((k) => !now.has(k))\n .sort()\n })()\n : []\n\n const shrunk = dropped.length > 0\n const out = shrunk\n ? target.replace(/\\.json$/i, \"\") + \".partial.json\"\n : target\n fs.writeFileSync(out, JSON.stringify(manifest, null, 2) + \"\\n\")\n\n if (shrunk) {\n console.error(\n `uidex states: ⚠ run captured ${manifest.captured.length} state(s) but ${existing!.captured.length} are committed — ${dropped.length} would be DROPPED, so ${path.basename(target)} was left untouched and this run went to ${path.basename(out)}.`\n )\n console.error(` dropped: ${dropped.join(\", \")}`)\n console.error(\n ` A consistent count across two runs is a genuinely broken spec; a varying one is flake. For a deliberate removal, copy the partial over or set allowShrink.`\n )\n return\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"],"mappings":"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","names":["base","base","path","fs","path","parsePayload"]}
|
package/dist/playwright/index.js
CHANGED
|
@@ -126,6 +126,15 @@ var UidexCoverageReporter = class {
|
|
|
126
126
|
total,
|
|
127
127
|
percentage
|
|
128
128
|
};
|
|
129
|
+
const observedNothing = flows.length === 0 && this.untagged.length === 0 && this.touched.size === 0;
|
|
130
|
+
if (observedNothing && hasExistingReport(path.resolve(this.outputPath))) {
|
|
131
|
+
if (!this.silent) {
|
|
132
|
+
console.log(
|
|
133
|
+
`uidex coverage: run observed no flows/entities \u2014 leaving ${this.outputPath} untouched (filtered run?)`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
129
138
|
fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {
|
|
130
139
|
recursive: true
|
|
131
140
|
});
|
|
@@ -139,6 +148,16 @@ var UidexCoverageReporter = class {
|
|
|
139
148
|
}
|
|
140
149
|
}
|
|
141
150
|
};
|
|
151
|
+
function hasExistingReport(resolvedPath) {
|
|
152
|
+
try {
|
|
153
|
+
const existing = JSON.parse(
|
|
154
|
+
fs.readFileSync(resolvedPath, "utf8")
|
|
155
|
+
);
|
|
156
|
+
return (existing.flows?.length ?? 0) > 0 || (existing.untagged?.length ?? 0) > 0 || (existing.touched?.length ?? 0) > 0;
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
142
161
|
function parsePayload(raw) {
|
|
143
162
|
try {
|
|
144
163
|
const parsed = JSON.parse(raw);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/integrations/playwright/selector.ts","../../src/integrations/playwright/fixture.ts","../../src/integrations/playwright/reporter.ts","../../src/integrations/playwright/states.ts","../../src/integrations/playwright/states-reporter.ts"],"sourcesContent":["const ATTRS = [\n \"data-uidex\",\n \"data-uidex-region\",\n \"data-uidex-widget\",\n \"data-uidex-primitive\",\n] as const\n\nexport const UIDEX_ATTRS = ATTRS\n\nexport function uidexSelector(id: string): string {\n const escaped = id.replace(/\"/g, '\\\\\"')\n return ATTRS.map((a) => `[${a}=\"${escaped}\"]`).join(\", \")\n}\n\nexport function kebab(input: string): string {\n return input\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\")\n}\n\nexport const FLOW_TAG = \"@uidex:flow\"\nexport const NOT_FLOW_TAG = \"@uidex:not-flow\"\nexport const COVERAGE_ATTACHMENT = \"uidex-coverage\"\n\nexport interface CoveragePayload {\n flow: string | null\n notFlow: boolean\n ids: string[]\n title: string\n}\n","import { test as base, expect } from \"@playwright/test\"\nimport type { Locator, TestInfo } from \"@playwright/test\"\nimport {\n COVERAGE_ATTACHMENT,\n FLOW_TAG,\n NOT_FLOW_TAG,\n kebab,\n uidexSelector,\n type CoveragePayload,\n} from \"./selector\"\n\nexport type UidexLocator<T extends string = string> = (id: T) => Locator\n\nexport interface UidexFixtures {\n uidex: UidexLocator\n}\n\ninterface FlowResolution {\n flow: string | null\n notFlow: boolean\n}\n\ntype TestInfoLike = Pick<TestInfo, \"tags\" | \"titlePath\" | \"title\">\n\nexport function resolveFlow(testInfo: TestInfoLike): FlowResolution {\n const tags = testInfo.tags ?? []\n const notFlow = tags.includes(NOT_FLOW_TAG)\n if (notFlow) return { flow: null, notFlow: true }\n if (!tags.includes(FLOW_TAG)) return { flow: null, notFlow: false }\n const describes = describeTitles(testInfo.titlePath ?? [], testInfo.title)\n const source =\n describes.length > 0\n ? describes[describes.length - 1]\n : (testInfo.title ?? \"\")\n return { flow: kebab(source) || null, notFlow: false }\n}\n\nconst FILE_RE = /\\.(spec|test)\\.(t|j)sx?$|\\.(t|j)sx?$|[\\\\/]/\n\nfunction describeTitles(titlePath: string[], testTitle: string): string[] {\n const end =\n titlePath.length > 0 && titlePath[titlePath.length - 1] === testTitle\n ? titlePath.length - 1\n : titlePath.length\n const out: string[] = []\n for (let i = 0; i < end; i++) {\n const entry = titlePath[i]\n if (!entry) continue\n if (FILE_RE.test(entry)) continue\n if (i === 0) continue // project name\n out.push(entry)\n }\n return out\n}\n\ninterface PageLike {\n locator: (selector: string) => Locator\n}\n\nexport interface UidexFixtureHandle {\n locator: UidexLocator\n buildPayload: () => CoveragePayload\n}\n\nexport function createUidexFixture(\n page: PageLike,\n testInfo: TestInfoLike\n): UidexFixtureHandle {\n const used = new Set<string>()\n const locator: UidexLocator = (id: string) => {\n used.add(id)\n return page.locator(uidexSelector(id))\n }\n const buildPayload = (): CoveragePayload => {\n const { flow, notFlow } = resolveFlow(testInfo)\n return {\n flow,\n notFlow,\n ids: [...used].sort(),\n title: testInfo.title,\n }\n }\n return { locator, buildPayload }\n}\n\n/**\n * No-op runtime marker for per-criterion acceptance coverage. Written inside a\n * tagged `@uidex:flow` describe as\n * `uidexCovers(\"<entityId>\", \"<criterionId>@<rev>\", ...)`; the scanner reads\n * the call statically (flow-facts) and binds the flow to those criteria at the\n * pinned revs. At runtime it does nothing — the claim is documentation the\n * audit can hold the spec to, not behavior.\n *\n * At least one criterion is REQUIRED (in the signature here and as a\n * `covers-missing-rev` parse diagnostic in the scanner): a bare\n * `uidexCovers(\"entity\")` registers nothing, which is exactly the call an\n * author migrating from the old entity-level model would write.\n */\nexport function uidexCovers(\n _entity: string,\n _firstCriterion: string,\n ..._criteria: string[]\n): void {\n // intentionally empty — statically analysed, never executed for effect\n}\n\nexport const test = base.extend<UidexFixtures>({\n uidex: async ({ page }, use, testInfo) => {\n const handle = createUidexFixture(page, testInfo)\n // eslint-disable-next-line react-hooks/rules-of-hooks\n await use(handle.locator)\n await testInfo.attach(COVERAGE_ATTACHMENT, {\n body: JSON.stringify(handle.buildPayload()),\n contentType: \"application/json\",\n })\n },\n})\n\nexport { expect }\n","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 { COVERAGE_ATTACHMENT, type CoveragePayload } from \"./selector\"\n\nexport interface UidexReporterOptions {\n outputPath?: string\n entityIds?: readonly string[]\n silent?: boolean\n}\n\nexport interface FlowCoverage {\n flow: string\n ids: string[]\n titles: string[]\n}\n\nexport interface UidexCoverageReport {\n flows: FlowCoverage[]\n untagged: { title: string; ids: string[] }[]\n touched: string[]\n untouched: string[]\n total: number\n percentage: number\n}\n\nexport default class UidexCoverageReporter implements Reporter {\n private readonly outputPath: string\n private readonly entityIds: readonly string[]\n private readonly silent: boolean\n private readonly flows = new Map<\n string,\n { ids: Set<string>; titles: Set<string> }\n >()\n private readonly untagged: { title: string; ids: string[] }[] = []\n private readonly touched = new Set<string>()\n\n constructor(options: UidexReporterOptions = {}) {\n this.outputPath = options.outputPath ?? \"uidex-coverage.json\"\n this.entityIds = options.entityIds ?? []\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 !== COVERAGE_ATTACHMENT) continue\n if (!attachment.body) continue\n const payload = parsePayload(attachment.body.toString())\n if (!payload) continue\n if (payload.notFlow) continue\n for (const id of payload.ids) this.touched.add(id)\n if (payload.flow) {\n const entry = this.flows.get(payload.flow) ?? {\n ids: new Set<string>(),\n titles: new Set<string>(),\n }\n for (const id of payload.ids) entry.ids.add(id)\n entry.titles.add(payload.title)\n this.flows.set(payload.flow, entry)\n } else {\n this.untagged.push({ title: payload.title, ids: payload.ids })\n }\n }\n }\n\n async onEnd(_result: FullResult): Promise<void> {\n const flows: FlowCoverage[] = [...this.flows.entries()]\n .map(([flow, entry]) => ({\n flow,\n ids: [...entry.ids].sort(),\n titles: [...entry.titles].sort(),\n }))\n .sort((a, b) => a.flow.localeCompare(b.flow))\n\n const all = [...this.entityIds]\n const touched = all.filter((id) => this.touched.has(id)).sort()\n const untouched = all.filter((id) => !this.touched.has(id)).sort()\n const total = all.length\n const percentage =\n total > 0 ? Math.round((touched.length / total) * 100) : 0\n\n const report: UidexCoverageReport = {\n flows,\n untagged: this.untagged,\n touched,\n untouched,\n total,\n percentage,\n }\n\n fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {\n recursive: true,\n })\n fs.writeFileSync(\n path.resolve(this.outputPath),\n JSON.stringify(report, null, 2) + \"\\n\"\n )\n\n if (!this.silent) {\n const line =\n total > 0\n ? `uidex coverage: ${touched.length}/${total} entities (${percentage}%) across ${flows.length} flow(s)`\n : `uidex coverage: ${flows.length} flow(s), ${this.touched.size} entity id(s) touched`\n\n console.log(line)\n }\n }\n}\n\nfunction parsePayload(raw: string): CoveragePayload | null {\n try {\n const parsed = JSON.parse(raw) as Partial<CoveragePayload>\n if (!parsed || !Array.isArray(parsed.ids)) return null\n return {\n flow: typeof parsed.flow === \"string\" ? parsed.flow : null,\n notFlow: Boolean(parsed.notFlow),\n ids: parsed.ids.filter((x): x is string => typeof x === \"string\"),\n title: typeof parsed.title === \"string\" ? parsed.title : \"\",\n }\n } catch {\n return null\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\n/**\n * The declared state space a generated `UidexStates` describes: entity kind →\n * entity id → that entity's own state names.\n */\nexport type StateSpace = {\n [K in StateEntityKind]: Record<string, string> | never\n}\n\n/**\n * Capture options narrowed to ONE entity of one kind, with only the states that\n * entity actually declares. Built as a union over (kind, entity) pairs so the\n * three fields are checked together — picking `widget`/`gate-badge` restricts\n * `state` to gate-badge's own names, and a page id is not assignable at all.\n */\nexport type TypedCaptureOptions<M> = {\n [K in keyof M & StateEntityKind]: M[K] extends Record<string, string>\n ? {\n [E in keyof M[K] & string]: Omit<\n CaptureStateOptions,\n \"entity\" | \"kind\" | \"state\"\n > & { kind: K; entity: E; state: M[K][E] }\n }[keyof M[K] & string]\n : never\n}[keyof M & StateEntityKind]\n\nexport interface TypedStateCapture<M> {\n captureState(\n page: Page,\n testInfo: TestInfoLike,\n opts: TypedCaptureOptions<M>\n ): Promise<StateRecord[]>\n recordStates(testInfo: TestInfoLike, records: StateRecord[]): Promise<void>\n}\n\n/**\n * Bind the capture helpers to a project's generated state space:\n *\n * ```ts\n * // e2e/states/capture.ts — the ONE place e2e meets the registry\n * import { createStateCapture } from \"uidex/playwright\"\n * import type { UidexStates } from \"@/uidex.gen\"\n * export const { captureState } = createStateCapture<UidexStates>()\n * ```\n *\n * Purely a type-level narrowing — the runtime is the untyped `captureState`.\n * Its value is closing the loop that let capture ids drift from the registry:\n * before this, `entity` was a free string, so a spec could invent a name the\n * registry had never heard of and nothing failed until a lint pass noticed the\n * mismatch (or, when the entity kind was also wrong, never noticed at all).\n */\nexport function createStateCapture<\n M extends Partial<StateSpace>,\n>(): TypedStateCapture<M> {\n return {\n captureState: (page, testInfo, opts) =>\n captureState(page, testInfo, opts as unknown as CaptureStateOptions),\n recordStates,\n }\n}\n","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 * ISO timestamp of the run that wrote this manifest. The scanner compares it\n * to the mtimes of the files declaring states, so a manifest that predates a\n * declaration edit is reported as stale rather than silently trusted.\n */\n generatedAt?: string\n}\n\nexport interface UidexStatesReporterOptions {\n outputPath?: string\n silent?: boolean\n /**\n * Write to `<outputPath>` even when the run captured FEWER states than the\n * manifest already on disk. Off by default: a filtered (`--grep`) or flaky\n * run legitimately produces a subset, and blindly overwriting turns that into\n * silent coverage loss — the run exits non-zero, but the manifest is already\n * gone. See the shrink guard in `onEnd`.\n */\n allowShrink?: boolean\n}\n\n/** Identity of a captured state for shrink comparison. */\nfunction capturedKey(c: CapturedStateEntry): string {\n return `${c.kind ?? \"page\"}\u0000${c.entity}\u0000${c.state}`\n}\n\nfunction readExisting(p: string): CapturedStatesManifestOut | null {\n try {\n const parsed = JSON.parse(fs.readFileSync(p, \"utf8\")) as unknown\n if (!parsed || typeof parsed !== \"object\") return null\n const m = parsed as Partial<CapturedStatesManifestOut>\n return Array.isArray(m.captured)\n ? { captured: m.captured, variants: m.variants ?? [] }\n : null\n } catch {\n return null // absent or unreadable → nothing to protect\n }\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 allowShrink: 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 this.allowShrink = options.allowShrink ?? 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 manifest.generatedAt = new Date().toISOString()\n const target = path.resolve(this.outputPath)\n fs.mkdirSync(path.dirname(target), { recursive: true })\n\n // ---- Shrink guard ----\n // A filtered or flaky run captures a SUBSET. Writing it over the committed\n // manifest silently deletes coverage: the states simply vanish, the gate\n // then agrees nothing is missing, and the only signal is a state count you\n // had to be watching. So when the run would drop states, divert it to\n // `<name>.partial.json`, leave the committed manifest untouched, and name\n // the dropped keys — the caller decides whether it was flake or a real\n // removal (for a deliberate removal, copy the partial over, or re-run with\n // `allowShrink`).\n const existing = this.allowShrink ? null : readExisting(target)\n const dropped = existing\n ? (() => {\n const now = new Set(manifest.captured.map(capturedKey))\n return existing.captured\n .map(capturedKey)\n .filter((k) => !now.has(k))\n .sort()\n })()\n : []\n\n const shrunk = dropped.length > 0\n const out = shrunk\n ? target.replace(/\\.json$/i, \"\") + \".partial.json\"\n : target\n fs.writeFileSync(out, JSON.stringify(manifest, null, 2) + \"\\n\")\n\n if (shrunk) {\n console.error(\n `uidex states: ⚠ run captured ${manifest.captured.length} state(s) but ${existing!.captured.length} are committed — ${dropped.length} would be DROPPED, so ${path.basename(target)} was left untouched and this run went to ${path.basename(out)}.`\n )\n console.error(` dropped: ${dropped.join(\", \")}`)\n console.error(\n ` A consistent count across two runs is a genuinely broken spec; a varying one is flake. For a deliberate removal, copy the partial over or set allowShrink.`\n )\n return\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"],"mappings":"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","names":["base","path","fs","path","parsePayload"]}
|
|
1
|
+
{"version":3,"sources":["../../src/integrations/playwright/selector.ts","../../src/integrations/playwright/fixture.ts","../../src/integrations/playwright/reporter.ts","../../src/integrations/playwright/states.ts","../../src/integrations/playwright/states-reporter.ts"],"sourcesContent":["const ATTRS = [\n \"data-uidex\",\n \"data-uidex-region\",\n \"data-uidex-widget\",\n \"data-uidex-primitive\",\n] as const\n\nexport const UIDEX_ATTRS = ATTRS\n\nexport function uidexSelector(id: string): string {\n const escaped = id.replace(/\"/g, '\\\\\"')\n return ATTRS.map((a) => `[${a}=\"${escaped}\"]`).join(\", \")\n}\n\nexport function kebab(input: string): string {\n return input\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\")\n}\n\nexport const FLOW_TAG = \"@uidex:flow\"\nexport const NOT_FLOW_TAG = \"@uidex:not-flow\"\nexport const COVERAGE_ATTACHMENT = \"uidex-coverage\"\n\nexport interface CoveragePayload {\n flow: string | null\n notFlow: boolean\n ids: string[]\n title: string\n}\n","import { test as base, expect } from \"@playwright/test\"\nimport type { Locator, TestInfo } from \"@playwright/test\"\nimport {\n COVERAGE_ATTACHMENT,\n FLOW_TAG,\n NOT_FLOW_TAG,\n kebab,\n uidexSelector,\n type CoveragePayload,\n} from \"./selector\"\n\nexport type UidexLocator<T extends string = string> = (id: T) => Locator\n\nexport interface UidexFixtures {\n uidex: UidexLocator\n}\n\ninterface FlowResolution {\n flow: string | null\n notFlow: boolean\n}\n\ntype TestInfoLike = Pick<TestInfo, \"tags\" | \"titlePath\" | \"title\">\n\nexport function resolveFlow(testInfo: TestInfoLike): FlowResolution {\n const tags = testInfo.tags ?? []\n const notFlow = tags.includes(NOT_FLOW_TAG)\n if (notFlow) return { flow: null, notFlow: true }\n if (!tags.includes(FLOW_TAG)) return { flow: null, notFlow: false }\n const describes = describeTitles(testInfo.titlePath ?? [], testInfo.title)\n const source =\n describes.length > 0\n ? describes[describes.length - 1]\n : (testInfo.title ?? \"\")\n return { flow: kebab(source) || null, notFlow: false }\n}\n\nconst FILE_RE = /\\.(spec|test)\\.(t|j)sx?$|\\.(t|j)sx?$|[\\\\/]/\n\nfunction describeTitles(titlePath: string[], testTitle: string): string[] {\n const end =\n titlePath.length > 0 && titlePath[titlePath.length - 1] === testTitle\n ? titlePath.length - 1\n : titlePath.length\n const out: string[] = []\n for (let i = 0; i < end; i++) {\n const entry = titlePath[i]\n if (!entry) continue\n if (FILE_RE.test(entry)) continue\n if (i === 0) continue // project name\n out.push(entry)\n }\n return out\n}\n\ninterface PageLike {\n locator: (selector: string) => Locator\n}\n\nexport interface UidexFixtureHandle {\n locator: UidexLocator\n buildPayload: () => CoveragePayload\n}\n\nexport function createUidexFixture(\n page: PageLike,\n testInfo: TestInfoLike\n): UidexFixtureHandle {\n const used = new Set<string>()\n const locator: UidexLocator = (id: string) => {\n used.add(id)\n return page.locator(uidexSelector(id))\n }\n const buildPayload = (): CoveragePayload => {\n const { flow, notFlow } = resolveFlow(testInfo)\n return {\n flow,\n notFlow,\n ids: [...used].sort(),\n title: testInfo.title,\n }\n }\n return { locator, buildPayload }\n}\n\n/**\n * No-op runtime marker for per-criterion acceptance coverage. Written inside a\n * tagged `@uidex:flow` describe as\n * `uidexCovers(\"<entityId>\", \"<criterionId>@<rev>\", ...)`; the scanner reads\n * the call statically (flow-facts) and binds the flow to those criteria at the\n * pinned revs. At runtime it does nothing — the claim is documentation the\n * audit can hold the spec to, not behavior.\n *\n * At least one criterion is REQUIRED (in the signature here and as a\n * `covers-missing-rev` parse diagnostic in the scanner): a bare\n * `uidexCovers(\"entity\")` registers nothing, which is exactly the call an\n * author migrating from the old entity-level model would write.\n */\nexport function uidexCovers(\n _entity: string,\n _firstCriterion: string,\n ..._criteria: string[]\n): void {\n // intentionally empty — statically analysed, never executed for effect\n}\n\nexport const test = base.extend<UidexFixtures>({\n uidex: async ({ page }, use, testInfo) => {\n const handle = createUidexFixture(page, testInfo)\n // eslint-disable-next-line react-hooks/rules-of-hooks\n await use(handle.locator)\n await testInfo.attach(COVERAGE_ATTACHMENT, {\n body: JSON.stringify(handle.buildPayload()),\n contentType: \"application/json\",\n })\n },\n})\n\nexport { expect }\n","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 { COVERAGE_ATTACHMENT, type CoveragePayload } from \"./selector\"\n\nexport interface UidexReporterOptions {\n outputPath?: string\n entityIds?: readonly string[]\n silent?: boolean\n}\n\nexport interface FlowCoverage {\n flow: string\n ids: string[]\n titles: string[]\n}\n\nexport interface UidexCoverageReport {\n flows: FlowCoverage[]\n untagged: { title: string; ids: string[] }[]\n touched: string[]\n untouched: string[]\n total: number\n percentage: number\n}\n\nexport default class UidexCoverageReporter implements Reporter {\n private readonly outputPath: string\n private readonly entityIds: readonly string[]\n private readonly silent: boolean\n private readonly flows = new Map<\n string,\n { ids: Set<string>; titles: Set<string> }\n >()\n private readonly untagged: { title: string; ids: string[] }[] = []\n private readonly touched = new Set<string>()\n\n constructor(options: UidexReporterOptions = {}) {\n this.outputPath = options.outputPath ?? \"uidex-coverage.json\"\n this.entityIds = options.entityIds ?? []\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 !== COVERAGE_ATTACHMENT) continue\n if (!attachment.body) continue\n const payload = parsePayload(attachment.body.toString())\n if (!payload) continue\n if (payload.notFlow) continue\n for (const id of payload.ids) this.touched.add(id)\n if (payload.flow) {\n const entry = this.flows.get(payload.flow) ?? {\n ids: new Set<string>(),\n titles: new Set<string>(),\n }\n for (const id of payload.ids) entry.ids.add(id)\n entry.titles.add(payload.title)\n this.flows.set(payload.flow, entry)\n } else {\n this.untagged.push({ title: payload.title, ids: payload.ids })\n }\n }\n }\n\n async onEnd(_result: FullResult): Promise<void> {\n const flows: FlowCoverage[] = [...this.flows.entries()]\n .map(([flow, entry]) => ({\n flow,\n ids: [...entry.ids].sort(),\n titles: [...entry.titles].sort(),\n }))\n .sort((a, b) => a.flow.localeCompare(b.flow))\n\n const all = [...this.entityIds]\n const touched = all.filter((id) => this.touched.has(id)).sort()\n const untouched = all.filter((id) => !this.touched.has(id)).sort()\n const total = all.length\n const percentage =\n total > 0 ? Math.round((touched.length / total) * 100) : 0\n\n const report: UidexCoverageReport = {\n flows,\n untagged: this.untagged,\n touched,\n untouched,\n total,\n percentage,\n }\n\n // An EMPTY run must not truncate a committed report. A filtered invocation\n // (`--grep` matching nothing in this project) still reaches onEnd, and\n // unconditionally writing would replace real flow coverage with an empty\n // shell — the same footgun the states reporter guards with its shrink\n // check. Zero observations + a non-empty report on disk → leave the file\n // alone and say so; a genuinely empty project (no prior report) still\n // writes, so initial generation is unaffected.\n const observedNothing =\n flows.length === 0 &&\n this.untagged.length === 0 &&\n this.touched.size === 0\n if (observedNothing && hasExistingReport(path.resolve(this.outputPath))) {\n if (!this.silent) {\n console.log(\n `uidex coverage: run observed no flows/entities — leaving ${this.outputPath} untouched (filtered run?)`\n )\n }\n return\n }\n\n fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {\n recursive: true,\n })\n fs.writeFileSync(\n path.resolve(this.outputPath),\n JSON.stringify(report, null, 2) + \"\\n\"\n )\n\n if (!this.silent) {\n const line =\n total > 0\n ? `uidex coverage: ${touched.length}/${total} entities (${percentage}%) across ${flows.length} flow(s)`\n : `uidex coverage: ${flows.length} flow(s), ${this.touched.size} entity id(s) touched`\n\n console.log(line)\n }\n }\n}\n\n/** Whether a prior report with actual content exists at the output path. */\nfunction hasExistingReport(resolvedPath: string): boolean {\n try {\n const existing = JSON.parse(\n fs.readFileSync(resolvedPath, \"utf8\")\n ) as Partial<UidexCoverageReport>\n return (\n (existing.flows?.length ?? 0) > 0 ||\n (existing.untagged?.length ?? 0) > 0 ||\n (existing.touched?.length ?? 0) > 0\n )\n } catch {\n return false\n }\n}\n\nfunction parsePayload(raw: string): CoveragePayload | null {\n try {\n const parsed = JSON.parse(raw) as Partial<CoveragePayload>\n if (!parsed || !Array.isArray(parsed.ids)) return null\n return {\n flow: typeof parsed.flow === \"string\" ? parsed.flow : null,\n notFlow: Boolean(parsed.notFlow),\n ids: parsed.ids.filter((x): x is string => typeof x === \"string\"),\n title: typeof parsed.title === \"string\" ? parsed.title : \"\",\n }\n } catch {\n return null\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\n/**\n * The declared state space a generated `UidexStates` describes: entity kind →\n * entity id → that entity's own state names.\n */\nexport type StateSpace = {\n [K in StateEntityKind]: Record<string, string> | never\n}\n\n/**\n * Capture options narrowed to ONE entity of one kind, with only the states that\n * entity actually declares. Built as a union over (kind, entity) pairs so the\n * three fields are checked together — picking `widget`/`gate-badge` restricts\n * `state` to gate-badge's own names, and a page id is not assignable at all.\n */\nexport type TypedCaptureOptions<M> = {\n [K in keyof M & StateEntityKind]: M[K] extends Record<string, string>\n ? {\n [E in keyof M[K] & string]: Omit<\n CaptureStateOptions,\n \"entity\" | \"kind\" | \"state\"\n > & { kind: K; entity: E; state: M[K][E] }\n }[keyof M[K] & string]\n : never\n}[keyof M & StateEntityKind]\n\nexport interface TypedStateCapture<M> {\n captureState(\n page: Page,\n testInfo: TestInfoLike,\n opts: TypedCaptureOptions<M>\n ): Promise<StateRecord[]>\n recordStates(testInfo: TestInfoLike, records: StateRecord[]): Promise<void>\n}\n\n/**\n * Bind the capture helpers to a project's generated state space:\n *\n * ```ts\n * // e2e/states/capture.ts — the ONE place e2e meets the registry\n * import { createStateCapture } from \"uidex/playwright\"\n * import type { UidexStates } from \"@/uidex.gen\"\n * export const { captureState } = createStateCapture<UidexStates>()\n * ```\n *\n * Purely a type-level narrowing — the runtime is the untyped `captureState`.\n * Its value is closing the loop that let capture ids drift from the registry:\n * before this, `entity` was a free string, so a spec could invent a name the\n * registry had never heard of and nothing failed until a lint pass noticed the\n * mismatch (or, when the entity kind was also wrong, never noticed at all).\n */\nexport function createStateCapture<\n M extends Partial<StateSpace>,\n>(): TypedStateCapture<M> {\n return {\n captureState: (page, testInfo, opts) =>\n captureState(page, testInfo, opts as unknown as CaptureStateOptions),\n recordStates,\n }\n}\n","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 * ISO timestamp of the run that wrote this manifest. The scanner compares it\n * to the mtimes of the files declaring states, so a manifest that predates a\n * declaration edit is reported as stale rather than silently trusted.\n */\n generatedAt?: string\n}\n\nexport interface UidexStatesReporterOptions {\n outputPath?: string\n silent?: boolean\n /**\n * Write to `<outputPath>` even when the run captured FEWER states than the\n * manifest already on disk. Off by default: a filtered (`--grep`) or flaky\n * run legitimately produces a subset, and blindly overwriting turns that into\n * silent coverage loss — the run exits non-zero, but the manifest is already\n * gone. See the shrink guard in `onEnd`.\n */\n allowShrink?: boolean\n}\n\n/** Identity of a captured state for shrink comparison. */\nfunction capturedKey(c: CapturedStateEntry): string {\n return `${c.kind ?? \"page\"}\u0000${c.entity}\u0000${c.state}`\n}\n\nfunction readExisting(p: string): CapturedStatesManifestOut | null {\n try {\n const parsed = JSON.parse(fs.readFileSync(p, \"utf8\")) as unknown\n if (!parsed || typeof parsed !== \"object\") return null\n const m = parsed as Partial<CapturedStatesManifestOut>\n return Array.isArray(m.captured)\n ? { captured: m.captured, variants: m.variants ?? [] }\n : null\n } catch {\n return null // absent or unreadable → nothing to protect\n }\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 allowShrink: 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 this.allowShrink = options.allowShrink ?? 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 manifest.generatedAt = new Date().toISOString()\n const target = path.resolve(this.outputPath)\n fs.mkdirSync(path.dirname(target), { recursive: true })\n\n // ---- Shrink guard ----\n // A filtered or flaky run captures a SUBSET. Writing it over the committed\n // manifest silently deletes coverage: the states simply vanish, the gate\n // then agrees nothing is missing, and the only signal is a state count you\n // had to be watching. So when the run would drop states, divert it to\n // `<name>.partial.json`, leave the committed manifest untouched, and name\n // the dropped keys — the caller decides whether it was flake or a real\n // removal (for a deliberate removal, copy the partial over, or re-run with\n // `allowShrink`).\n const existing = this.allowShrink ? null : readExisting(target)\n const dropped = existing\n ? (() => {\n const now = new Set(manifest.captured.map(capturedKey))\n return existing.captured\n .map(capturedKey)\n .filter((k) => !now.has(k))\n .sort()\n })()\n : []\n\n const shrunk = dropped.length > 0\n const out = shrunk\n ? target.replace(/\\.json$/i, \"\") + \".partial.json\"\n : target\n fs.writeFileSync(out, JSON.stringify(manifest, null, 2) + \"\\n\")\n\n if (shrunk) {\n console.error(\n `uidex states: ⚠ run captured ${manifest.captured.length} state(s) but ${existing!.captured.length} are committed — ${dropped.length} would be DROPPED, so ${path.basename(target)} was left untouched and this run went to ${path.basename(out)}.`\n )\n console.error(` dropped: ${dropped.join(\", \")}`)\n console.error(\n ` A consistent count across two runs is a genuinely broken spec; a varying one is flake. For a deliberate removal, copy the partial over or set allowShrink.`\n )\n return\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"],"mappings":"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","names":["base","path","fs","path","parsePayload"]}
|
|
@@ -92,6 +92,15 @@ var UidexCoverageReporter = class {
|
|
|
92
92
|
total,
|
|
93
93
|
percentage
|
|
94
94
|
};
|
|
95
|
+
const observedNothing = flows.length === 0 && this.untagged.length === 0 && this.touched.size === 0;
|
|
96
|
+
if (observedNothing && hasExistingReport(path.resolve(this.outputPath))) {
|
|
97
|
+
if (!this.silent) {
|
|
98
|
+
console.log(
|
|
99
|
+
`uidex coverage: run observed no flows/entities \u2014 leaving ${this.outputPath} untouched (filtered run?)`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
95
104
|
fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {
|
|
96
105
|
recursive: true
|
|
97
106
|
});
|
|
@@ -105,6 +114,16 @@ var UidexCoverageReporter = class {
|
|
|
105
114
|
}
|
|
106
115
|
}
|
|
107
116
|
};
|
|
117
|
+
function hasExistingReport(resolvedPath) {
|
|
118
|
+
try {
|
|
119
|
+
const existing = JSON.parse(
|
|
120
|
+
fs.readFileSync(resolvedPath, "utf8")
|
|
121
|
+
);
|
|
122
|
+
return (existing.flows?.length ?? 0) > 0 || (existing.untagged?.length ?? 0) > 0 || (existing.touched?.length ?? 0) > 0;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
108
127
|
function parsePayload(raw) {
|
|
109
128
|
try {
|
|
110
129
|
const parsed = JSON.parse(raw);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/integrations/playwright/reporter.ts","../../src/integrations/playwright/selector.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 { COVERAGE_ATTACHMENT, type CoveragePayload } from \"./selector\"\n\nexport interface UidexReporterOptions {\n outputPath?: string\n entityIds?: readonly string[]\n silent?: boolean\n}\n\nexport interface FlowCoverage {\n flow: string\n ids: string[]\n titles: string[]\n}\n\nexport interface UidexCoverageReport {\n flows: FlowCoverage[]\n untagged: { title: string; ids: string[] }[]\n touched: string[]\n untouched: string[]\n total: number\n percentage: number\n}\n\nexport default class UidexCoverageReporter implements Reporter {\n private readonly outputPath: string\n private readonly entityIds: readonly string[]\n private readonly silent: boolean\n private readonly flows = new Map<\n string,\n { ids: Set<string>; titles: Set<string> }\n >()\n private readonly untagged: { title: string; ids: string[] }[] = []\n private readonly touched = new Set<string>()\n\n constructor(options: UidexReporterOptions = {}) {\n this.outputPath = options.outputPath ?? \"uidex-coverage.json\"\n this.entityIds = options.entityIds ?? []\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 !== COVERAGE_ATTACHMENT) continue\n if (!attachment.body) continue\n const payload = parsePayload(attachment.body.toString())\n if (!payload) continue\n if (payload.notFlow) continue\n for (const id of payload.ids) this.touched.add(id)\n if (payload.flow) {\n const entry = this.flows.get(payload.flow) ?? {\n ids: new Set<string>(),\n titles: new Set<string>(),\n }\n for (const id of payload.ids) entry.ids.add(id)\n entry.titles.add(payload.title)\n this.flows.set(payload.flow, entry)\n } else {\n this.untagged.push({ title: payload.title, ids: payload.ids })\n }\n }\n }\n\n async onEnd(_result: FullResult): Promise<void> {\n const flows: FlowCoverage[] = [...this.flows.entries()]\n .map(([flow, entry]) => ({\n flow,\n ids: [...entry.ids].sort(),\n titles: [...entry.titles].sort(),\n }))\n .sort((a, b) => a.flow.localeCompare(b.flow))\n\n const all = [...this.entityIds]\n const touched = all.filter((id) => this.touched.has(id)).sort()\n const untouched = all.filter((id) => !this.touched.has(id)).sort()\n const total = all.length\n const percentage =\n total > 0 ? Math.round((touched.length / total) * 100) : 0\n\n const report: UidexCoverageReport = {\n flows,\n untagged: this.untagged,\n touched,\n untouched,\n total,\n percentage,\n }\n\n fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {\n recursive: true,\n })\n fs.writeFileSync(\n path.resolve(this.outputPath),\n JSON.stringify(report, null, 2) + \"\\n\"\n )\n\n if (!this.silent) {\n const line =\n total > 0\n ? `uidex coverage: ${touched.length}/${total} entities (${percentage}%) across ${flows.length} flow(s)`\n : `uidex coverage: ${flows.length} flow(s), ${this.touched.size} entity id(s) touched`\n\n console.log(line)\n }\n }\n}\n\nfunction parsePayload(raw: string): CoveragePayload | null {\n try {\n const parsed = JSON.parse(raw) as Partial<CoveragePayload>\n if (!parsed || !Array.isArray(parsed.ids)) return null\n return {\n flow: typeof parsed.flow === \"string\" ? parsed.flow : null,\n notFlow: Boolean(parsed.notFlow),\n ids: parsed.ids.filter((x): x is string => typeof x === \"string\"),\n title: typeof parsed.title === \"string\" ? parsed.title : \"\",\n }\n } catch {\n return null\n }\n}\n","const ATTRS = [\n \"data-uidex\",\n \"data-uidex-region\",\n \"data-uidex-widget\",\n \"data-uidex-primitive\",\n] as const\n\nexport const UIDEX_ATTRS = ATTRS\n\nexport function uidexSelector(id: string): string {\n const escaped = id.replace(/\"/g, '\\\\\"')\n return ATTRS.map((a) => `[${a}=\"${escaped}\"]`).join(\", \")\n}\n\nexport function kebab(input: string): string {\n return input\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\")\n}\n\nexport const FLOW_TAG = \"@uidex:flow\"\nexport const NOT_FLOW_TAG = \"@uidex:not-flow\"\nexport const COVERAGE_ATTACHMENT = \"uidex-coverage\"\n\nexport interface CoveragePayload {\n flow: string | null\n notFlow: boolean\n ids: string[]\n title: string\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/integrations/playwright/reporter.ts","../../src/integrations/playwright/selector.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 { COVERAGE_ATTACHMENT, type CoveragePayload } from \"./selector\"\n\nexport interface UidexReporterOptions {\n outputPath?: string\n entityIds?: readonly string[]\n silent?: boolean\n}\n\nexport interface FlowCoverage {\n flow: string\n ids: string[]\n titles: string[]\n}\n\nexport interface UidexCoverageReport {\n flows: FlowCoverage[]\n untagged: { title: string; ids: string[] }[]\n touched: string[]\n untouched: string[]\n total: number\n percentage: number\n}\n\nexport default class UidexCoverageReporter implements Reporter {\n private readonly outputPath: string\n private readonly entityIds: readonly string[]\n private readonly silent: boolean\n private readonly flows = new Map<\n string,\n { ids: Set<string>; titles: Set<string> }\n >()\n private readonly untagged: { title: string; ids: string[] }[] = []\n private readonly touched = new Set<string>()\n\n constructor(options: UidexReporterOptions = {}) {\n this.outputPath = options.outputPath ?? \"uidex-coverage.json\"\n this.entityIds = options.entityIds ?? []\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 !== COVERAGE_ATTACHMENT) continue\n if (!attachment.body) continue\n const payload = parsePayload(attachment.body.toString())\n if (!payload) continue\n if (payload.notFlow) continue\n for (const id of payload.ids) this.touched.add(id)\n if (payload.flow) {\n const entry = this.flows.get(payload.flow) ?? {\n ids: new Set<string>(),\n titles: new Set<string>(),\n }\n for (const id of payload.ids) entry.ids.add(id)\n entry.titles.add(payload.title)\n this.flows.set(payload.flow, entry)\n } else {\n this.untagged.push({ title: payload.title, ids: payload.ids })\n }\n }\n }\n\n async onEnd(_result: FullResult): Promise<void> {\n const flows: FlowCoverage[] = [...this.flows.entries()]\n .map(([flow, entry]) => ({\n flow,\n ids: [...entry.ids].sort(),\n titles: [...entry.titles].sort(),\n }))\n .sort((a, b) => a.flow.localeCompare(b.flow))\n\n const all = [...this.entityIds]\n const touched = all.filter((id) => this.touched.has(id)).sort()\n const untouched = all.filter((id) => !this.touched.has(id)).sort()\n const total = all.length\n const percentage =\n total > 0 ? Math.round((touched.length / total) * 100) : 0\n\n const report: UidexCoverageReport = {\n flows,\n untagged: this.untagged,\n touched,\n untouched,\n total,\n percentage,\n }\n\n // An EMPTY run must not truncate a committed report. A filtered invocation\n // (`--grep` matching nothing in this project) still reaches onEnd, and\n // unconditionally writing would replace real flow coverage with an empty\n // shell — the same footgun the states reporter guards with its shrink\n // check. Zero observations + a non-empty report on disk → leave the file\n // alone and say so; a genuinely empty project (no prior report) still\n // writes, so initial generation is unaffected.\n const observedNothing =\n flows.length === 0 &&\n this.untagged.length === 0 &&\n this.touched.size === 0\n if (observedNothing && hasExistingReport(path.resolve(this.outputPath))) {\n if (!this.silent) {\n console.log(\n `uidex coverage: run observed no flows/entities — leaving ${this.outputPath} untouched (filtered run?)`\n )\n }\n return\n }\n\n fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {\n recursive: true,\n })\n fs.writeFileSync(\n path.resolve(this.outputPath),\n JSON.stringify(report, null, 2) + \"\\n\"\n )\n\n if (!this.silent) {\n const line =\n total > 0\n ? `uidex coverage: ${touched.length}/${total} entities (${percentage}%) across ${flows.length} flow(s)`\n : `uidex coverage: ${flows.length} flow(s), ${this.touched.size} entity id(s) touched`\n\n console.log(line)\n }\n }\n}\n\n/** Whether a prior report with actual content exists at the output path. */\nfunction hasExistingReport(resolvedPath: string): boolean {\n try {\n const existing = JSON.parse(\n fs.readFileSync(resolvedPath, \"utf8\")\n ) as Partial<UidexCoverageReport>\n return (\n (existing.flows?.length ?? 0) > 0 ||\n (existing.untagged?.length ?? 0) > 0 ||\n (existing.touched?.length ?? 0) > 0\n )\n } catch {\n return false\n }\n}\n\nfunction parsePayload(raw: string): CoveragePayload | null {\n try {\n const parsed = JSON.parse(raw) as Partial<CoveragePayload>\n if (!parsed || !Array.isArray(parsed.ids)) return null\n return {\n flow: typeof parsed.flow === \"string\" ? parsed.flow : null,\n notFlow: Boolean(parsed.notFlow),\n ids: parsed.ids.filter((x): x is string => typeof x === \"string\"),\n title: typeof parsed.title === \"string\" ? parsed.title : \"\",\n }\n } catch {\n return null\n }\n}\n","const ATTRS = [\n \"data-uidex\",\n \"data-uidex-region\",\n \"data-uidex-widget\",\n \"data-uidex-primitive\",\n] as const\n\nexport const UIDEX_ATTRS = ATTRS\n\nexport function uidexSelector(id: string): string {\n const escaped = id.replace(/\"/g, '\\\\\"')\n return ATTRS.map((a) => `[${a}=\"${escaped}\"]`).join(\", \")\n}\n\nexport function kebab(input: string): string {\n return input\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\")\n}\n\nexport const FLOW_TAG = \"@uidex:flow\"\nexport const NOT_FLOW_TAG = \"@uidex:not-flow\"\nexport const COVERAGE_ATTACHMENT = \"uidex-coverage\"\n\nexport interface CoveragePayload {\n flow: string | null\n notFlow: boolean\n ids: string[]\n title: string\n}\n"],"mappings":"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","names":[]}
|
|
@@ -58,6 +58,15 @@ var UidexCoverageReporter = class {
|
|
|
58
58
|
total,
|
|
59
59
|
percentage
|
|
60
60
|
};
|
|
61
|
+
const observedNothing = flows.length === 0 && this.untagged.length === 0 && this.touched.size === 0;
|
|
62
|
+
if (observedNothing && hasExistingReport(path.resolve(this.outputPath))) {
|
|
63
|
+
if (!this.silent) {
|
|
64
|
+
console.log(
|
|
65
|
+
`uidex coverage: run observed no flows/entities \u2014 leaving ${this.outputPath} untouched (filtered run?)`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
61
70
|
fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {
|
|
62
71
|
recursive: true
|
|
63
72
|
});
|
|
@@ -71,6 +80,16 @@ var UidexCoverageReporter = class {
|
|
|
71
80
|
}
|
|
72
81
|
}
|
|
73
82
|
};
|
|
83
|
+
function hasExistingReport(resolvedPath) {
|
|
84
|
+
try {
|
|
85
|
+
const existing = JSON.parse(
|
|
86
|
+
fs.readFileSync(resolvedPath, "utf8")
|
|
87
|
+
);
|
|
88
|
+
return (existing.flows?.length ?? 0) > 0 || (existing.untagged?.length ?? 0) > 0 || (existing.touched?.length ?? 0) > 0;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
74
93
|
function parsePayload(raw) {
|
|
75
94
|
try {
|
|
76
95
|
const parsed = JSON.parse(raw);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/integrations/playwright/reporter.ts","../../src/integrations/playwright/selector.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 { COVERAGE_ATTACHMENT, type CoveragePayload } from \"./selector\"\n\nexport interface UidexReporterOptions {\n outputPath?: string\n entityIds?: readonly string[]\n silent?: boolean\n}\n\nexport interface FlowCoverage {\n flow: string\n ids: string[]\n titles: string[]\n}\n\nexport interface UidexCoverageReport {\n flows: FlowCoverage[]\n untagged: { title: string; ids: string[] }[]\n touched: string[]\n untouched: string[]\n total: number\n percentage: number\n}\n\nexport default class UidexCoverageReporter implements Reporter {\n private readonly outputPath: string\n private readonly entityIds: readonly string[]\n private readonly silent: boolean\n private readonly flows = new Map<\n string,\n { ids: Set<string>; titles: Set<string> }\n >()\n private readonly untagged: { title: string; ids: string[] }[] = []\n private readonly touched = new Set<string>()\n\n constructor(options: UidexReporterOptions = {}) {\n this.outputPath = options.outputPath ?? \"uidex-coverage.json\"\n this.entityIds = options.entityIds ?? []\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 !== COVERAGE_ATTACHMENT) continue\n if (!attachment.body) continue\n const payload = parsePayload(attachment.body.toString())\n if (!payload) continue\n if (payload.notFlow) continue\n for (const id of payload.ids) this.touched.add(id)\n if (payload.flow) {\n const entry = this.flows.get(payload.flow) ?? {\n ids: new Set<string>(),\n titles: new Set<string>(),\n }\n for (const id of payload.ids) entry.ids.add(id)\n entry.titles.add(payload.title)\n this.flows.set(payload.flow, entry)\n } else {\n this.untagged.push({ title: payload.title, ids: payload.ids })\n }\n }\n }\n\n async onEnd(_result: FullResult): Promise<void> {\n const flows: FlowCoverage[] = [...this.flows.entries()]\n .map(([flow, entry]) => ({\n flow,\n ids: [...entry.ids].sort(),\n titles: [...entry.titles].sort(),\n }))\n .sort((a, b) => a.flow.localeCompare(b.flow))\n\n const all = [...this.entityIds]\n const touched = all.filter((id) => this.touched.has(id)).sort()\n const untouched = all.filter((id) => !this.touched.has(id)).sort()\n const total = all.length\n const percentage =\n total > 0 ? Math.round((touched.length / total) * 100) : 0\n\n const report: UidexCoverageReport = {\n flows,\n untagged: this.untagged,\n touched,\n untouched,\n total,\n percentage,\n }\n\n fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {\n recursive: true,\n })\n fs.writeFileSync(\n path.resolve(this.outputPath),\n JSON.stringify(report, null, 2) + \"\\n\"\n )\n\n if (!this.silent) {\n const line =\n total > 0\n ? `uidex coverage: ${touched.length}/${total} entities (${percentage}%) across ${flows.length} flow(s)`\n : `uidex coverage: ${flows.length} flow(s), ${this.touched.size} entity id(s) touched`\n\n console.log(line)\n }\n }\n}\n\nfunction parsePayload(raw: string): CoveragePayload | null {\n try {\n const parsed = JSON.parse(raw) as Partial<CoveragePayload>\n if (!parsed || !Array.isArray(parsed.ids)) return null\n return {\n flow: typeof parsed.flow === \"string\" ? parsed.flow : null,\n notFlow: Boolean(parsed.notFlow),\n ids: parsed.ids.filter((x): x is string => typeof x === \"string\"),\n title: typeof parsed.title === \"string\" ? parsed.title : \"\",\n }\n } catch {\n return null\n }\n}\n","const ATTRS = [\n \"data-uidex\",\n \"data-uidex-region\",\n \"data-uidex-widget\",\n \"data-uidex-primitive\",\n] as const\n\nexport const UIDEX_ATTRS = ATTRS\n\nexport function uidexSelector(id: string): string {\n const escaped = id.replace(/\"/g, '\\\\\"')\n return ATTRS.map((a) => `[${a}=\"${escaped}\"]`).join(\", \")\n}\n\nexport function kebab(input: string): string {\n return input\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\")\n}\n\nexport const FLOW_TAG = \"@uidex:flow\"\nexport const NOT_FLOW_TAG = \"@uidex:not-flow\"\nexport const COVERAGE_ATTACHMENT = \"uidex-coverage\"\n\nexport interface CoveragePayload {\n flow: string | null\n notFlow: boolean\n ids: string[]\n title: string\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/integrations/playwright/reporter.ts","../../src/integrations/playwright/selector.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 { COVERAGE_ATTACHMENT, type CoveragePayload } from \"./selector\"\n\nexport interface UidexReporterOptions {\n outputPath?: string\n entityIds?: readonly string[]\n silent?: boolean\n}\n\nexport interface FlowCoverage {\n flow: string\n ids: string[]\n titles: string[]\n}\n\nexport interface UidexCoverageReport {\n flows: FlowCoverage[]\n untagged: { title: string; ids: string[] }[]\n touched: string[]\n untouched: string[]\n total: number\n percentage: number\n}\n\nexport default class UidexCoverageReporter implements Reporter {\n private readonly outputPath: string\n private readonly entityIds: readonly string[]\n private readonly silent: boolean\n private readonly flows = new Map<\n string,\n { ids: Set<string>; titles: Set<string> }\n >()\n private readonly untagged: { title: string; ids: string[] }[] = []\n private readonly touched = new Set<string>()\n\n constructor(options: UidexReporterOptions = {}) {\n this.outputPath = options.outputPath ?? \"uidex-coverage.json\"\n this.entityIds = options.entityIds ?? []\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 !== COVERAGE_ATTACHMENT) continue\n if (!attachment.body) continue\n const payload = parsePayload(attachment.body.toString())\n if (!payload) continue\n if (payload.notFlow) continue\n for (const id of payload.ids) this.touched.add(id)\n if (payload.flow) {\n const entry = this.flows.get(payload.flow) ?? {\n ids: new Set<string>(),\n titles: new Set<string>(),\n }\n for (const id of payload.ids) entry.ids.add(id)\n entry.titles.add(payload.title)\n this.flows.set(payload.flow, entry)\n } else {\n this.untagged.push({ title: payload.title, ids: payload.ids })\n }\n }\n }\n\n async onEnd(_result: FullResult): Promise<void> {\n const flows: FlowCoverage[] = [...this.flows.entries()]\n .map(([flow, entry]) => ({\n flow,\n ids: [...entry.ids].sort(),\n titles: [...entry.titles].sort(),\n }))\n .sort((a, b) => a.flow.localeCompare(b.flow))\n\n const all = [...this.entityIds]\n const touched = all.filter((id) => this.touched.has(id)).sort()\n const untouched = all.filter((id) => !this.touched.has(id)).sort()\n const total = all.length\n const percentage =\n total > 0 ? Math.round((touched.length / total) * 100) : 0\n\n const report: UidexCoverageReport = {\n flows,\n untagged: this.untagged,\n touched,\n untouched,\n total,\n percentage,\n }\n\n // An EMPTY run must not truncate a committed report. A filtered invocation\n // (`--grep` matching nothing in this project) still reaches onEnd, and\n // unconditionally writing would replace real flow coverage with an empty\n // shell — the same footgun the states reporter guards with its shrink\n // check. Zero observations + a non-empty report on disk → leave the file\n // alone and say so; a genuinely empty project (no prior report) still\n // writes, so initial generation is unaffected.\n const observedNothing =\n flows.length === 0 &&\n this.untagged.length === 0 &&\n this.touched.size === 0\n if (observedNothing && hasExistingReport(path.resolve(this.outputPath))) {\n if (!this.silent) {\n console.log(\n `uidex coverage: run observed no flows/entities — leaving ${this.outputPath} untouched (filtered run?)`\n )\n }\n return\n }\n\n fs.mkdirSync(path.dirname(path.resolve(this.outputPath)), {\n recursive: true,\n })\n fs.writeFileSync(\n path.resolve(this.outputPath),\n JSON.stringify(report, null, 2) + \"\\n\"\n )\n\n if (!this.silent) {\n const line =\n total > 0\n ? `uidex coverage: ${touched.length}/${total} entities (${percentage}%) across ${flows.length} flow(s)`\n : `uidex coverage: ${flows.length} flow(s), ${this.touched.size} entity id(s) touched`\n\n console.log(line)\n }\n }\n}\n\n/** Whether a prior report with actual content exists at the output path. */\nfunction hasExistingReport(resolvedPath: string): boolean {\n try {\n const existing = JSON.parse(\n fs.readFileSync(resolvedPath, \"utf8\")\n ) as Partial<UidexCoverageReport>\n return (\n (existing.flows?.length ?? 0) > 0 ||\n (existing.untagged?.length ?? 0) > 0 ||\n (existing.touched?.length ?? 0) > 0\n )\n } catch {\n return false\n }\n}\n\nfunction parsePayload(raw: string): CoveragePayload | null {\n try {\n const parsed = JSON.parse(raw) as Partial<CoveragePayload>\n if (!parsed || !Array.isArray(parsed.ids)) return null\n return {\n flow: typeof parsed.flow === \"string\" ? parsed.flow : null,\n notFlow: Boolean(parsed.notFlow),\n ids: parsed.ids.filter((x): x is string => typeof x === \"string\"),\n title: typeof parsed.title === \"string\" ? parsed.title : \"\",\n }\n } catch {\n return null\n }\n}\n","const ATTRS = [\n \"data-uidex\",\n \"data-uidex-region\",\n \"data-uidex-widget\",\n \"data-uidex-primitive\",\n] as const\n\nexport const UIDEX_ATTRS = ATTRS\n\nexport function uidexSelector(id: string): string {\n const escaped = id.replace(/\"/g, '\\\\\"')\n return ATTRS.map((a) => `[${a}=\"${escaped}\"]`).join(\", \")\n}\n\nexport function kebab(input: string): string {\n return input\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\")\n}\n\nexport const FLOW_TAG = \"@uidex:flow\"\nexport const NOT_FLOW_TAG = \"@uidex:not-flow\"\nexport const COVERAGE_ATTACHMENT = \"uidex-coverage\"\n\nexport interface CoveragePayload {\n flow: string | null\n notFlow: boolean\n ids: string[]\n title: string\n}\n"],"mappings":";AAAA,YAAY,QAAQ;AACpB,YAAY,UAAU;;;ACuBf,IAAM,sBAAsB;;;ADOnC,IAAqB,wBAArB,MAA+D;AAAA,EAC5C;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ,oBAAI,IAG3B;AAAA,EACe,WAA+C,CAAC;AAAA,EAChD,UAAU,oBAAI,IAAY;AAAA,EAE3C,YAAY,UAAgC,CAAC,GAAG;AAC9C,SAAK,aAAa,QAAQ,cAAc;AACxC,SAAK,YAAY,QAAQ,aAAa,CAAC;AACvC,SAAK,SAAS,QAAQ,UAAU;AAAA,EAClC;AAAA,EAEA,UAAU,OAAiB,QAA0B;AACnD,eAAW,cAAc,OAAO,aAAa;AAC3C,UAAI,WAAW,SAAS,oBAAqB;AAC7C,UAAI,CAAC,WAAW,KAAM;AACtB,YAAM,UAAU,aAAa,WAAW,KAAK,SAAS,CAAC;AACvD,UAAI,CAAC,QAAS;AACd,UAAI,QAAQ,QAAS;AACrB,iBAAW,MAAM,QAAQ,IAAK,MAAK,QAAQ,IAAI,EAAE;AACjD,UAAI,QAAQ,MAAM;AAChB,cAAM,QAAQ,KAAK,MAAM,IAAI,QAAQ,IAAI,KAAK;AAAA,UAC5C,KAAK,oBAAI,IAAY;AAAA,UACrB,QAAQ,oBAAI,IAAY;AAAA,QAC1B;AACA,mBAAW,MAAM,QAAQ,IAAK,OAAM,IAAI,IAAI,EAAE;AAC9C,cAAM,OAAO,IAAI,QAAQ,KAAK;AAC9B,aAAK,MAAM,IAAI,QAAQ,MAAM,KAAK;AAAA,MACpC,OAAO;AACL,aAAK,SAAS,KAAK,EAAE,OAAO,QAAQ,OAAO,KAAK,QAAQ,IAAI,CAAC;AAAA,MAC/D;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,MAAM,SAAoC;AAC9C,UAAM,QAAwB,CAAC,GAAG,KAAK,MAAM,QAAQ,CAAC,EACnD,IAAI,CAAC,CAAC,MAAM,KAAK,OAAO;AAAA,MACvB;AAAA,MACA,KAAK,CAAC,GAAG,MAAM,GAAG,EAAE,KAAK;AAAA,MACzB,QAAQ,CAAC,GAAG,MAAM,MAAM,EAAE,KAAK;AAAA,IACjC,EAAE,EACD,KAAK,CAAC,GAAG,MAAM,EAAE,KAAK,cAAc,EAAE,IAAI,CAAC;AAE9C,UAAM,MAAM,CAAC,GAAG,KAAK,SAAS;AAC9B,UAAM,UAAU,IAAI,OAAO,CAAC,OAAO,KAAK,QAAQ,IAAI,EAAE,CAAC,EAAE,KAAK;AAC9D,UAAM,YAAY,IAAI,OAAO,CAAC,OAAO,CAAC,KAAK,QAAQ,IAAI,EAAE,CAAC,EAAE,KAAK;AACjE,UAAM,QAAQ,IAAI;AAClB,UAAM,aACJ,QAAQ,IAAI,KAAK,MAAO,QAAQ,SAAS,QAAS,GAAG,IAAI;AAE3D,UAAM,SAA8B;AAAA,MAClC;AAAA,MACA,UAAU,KAAK;AAAA,MACf;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AASA,UAAM,kBACJ,MAAM,WAAW,KACjB,KAAK,SAAS,WAAW,KACzB,KAAK,QAAQ,SAAS;AACxB,QAAI,mBAAmB,kBAAuB,aAAQ,KAAK,UAAU,CAAC,GAAG;AACvE,UAAI,CAAC,KAAK,QAAQ;AAChB,gBAAQ;AAAA,UACN,iEAA4D,KAAK,UAAU;AAAA,QAC7E;AAAA,MACF;AACA;AAAA,IACF;AAEA,IAAG,aAAe,aAAa,aAAQ,KAAK,UAAU,CAAC,GAAG;AAAA,MACxD,WAAW;AAAA,IACb,CAAC;AACD,IAAG;AAAA,MACI,aAAQ,KAAK,UAAU;AAAA,MAC5B,KAAK,UAAU,QAAQ,MAAM,CAAC,IAAI;AAAA,IACpC;AAEA,QAAI,CAAC,KAAK,QAAQ;AAChB,YAAM,OACJ,QAAQ,IACJ,mBAAmB,QAAQ,MAAM,IAAI,KAAK,cAAc,UAAU,aAAa,MAAM,MAAM,aAC3F,mBAAmB,MAAM,MAAM,aAAa,KAAK,QAAQ,IAAI;AAEnE,cAAQ,IAAI,IAAI;AAAA,IAClB;AAAA,EACF;AACF;AAGA,SAAS,kBAAkB,cAA+B;AACxD,MAAI;AACF,UAAM,WAAW,KAAK;AAAA,MACjB,gBAAa,cAAc,MAAM;AAAA,IACtC;AACA,YACG,SAAS,OAAO,UAAU,KAAK,MAC/B,SAAS,UAAU,UAAU,KAAK,MAClC,SAAS,SAAS,UAAU,KAAK;AAAA,EAEtC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,aAAa,KAAqC;AACzD,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,QAAI,CAAC,UAAU,CAAC,MAAM,QAAQ,OAAO,GAAG,EAAG,QAAO;AAClD,WAAO;AAAA,MACL,MAAM,OAAO,OAAO,SAAS,WAAW,OAAO,OAAO;AAAA,MACtD,SAAS,QAAQ,OAAO,OAAO;AAAA,MAC/B,KAAK,OAAO,IAAI,OAAO,CAAC,MAAmB,OAAO,MAAM,QAAQ;AAAA,MAChE,OAAO,OAAO,OAAO,UAAU,WAAW,OAAO,QAAQ;AAAA,IAC3D;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uidex",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Convention-driven UI element registry and devtools surface for React apps.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -98,8 +98,8 @@
|
|
|
98
98
|
"tsx": "^4.7.0",
|
|
99
99
|
"typescript": "^5.9.3",
|
|
100
100
|
"vitest": "^2.1.8",
|
|
101
|
-
"@uidex/
|
|
102
|
-
"@uidex/
|
|
101
|
+
"@uidex/tsconfig": "0.0.0",
|
|
102
|
+
"@uidex/eslint-config": "0.0.0"
|
|
103
103
|
},
|
|
104
104
|
"keywords": [
|
|
105
105
|
"uidex",
|