@qoretechnologies/qlip 0.1.3-beta.20260724071501 → 0.1.3-pr.28.g211f2a4

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.
Files changed (44) hide show
  1. package/README.md +25 -0
  2. package/dist/fs/output.d.ts +8 -0
  3. package/dist/fs/output.d.ts.map +1 -1
  4. package/dist/fs/output.js +8 -0
  5. package/dist/plugin/vitestPlugin.d.ts.map +1 -1
  6. package/dist/plugin/vitestPlugin.js +2 -0
  7. package/dist/runtime/context.d.ts +19 -7
  8. package/dist/runtime/context.d.ts.map +1 -1
  9. package/dist/runtime/context.js +18 -4
  10. package/dist/runtime/screenshot.d.ts.map +1 -1
  11. package/dist/runtime/screenshot.js +40 -8
  12. package/dist/test-runner/cli/upload.d.ts.map +1 -1
  13. package/dist/test-runner/cli/upload.js +14 -3
  14. package/dist/test-runner/index.js +3 -3
  15. package/dist/test-runner/manifest-store.d.ts +15 -13
  16. package/dist/test-runner/manifest-store.d.ts.map +1 -1
  17. package/dist/test-runner/manifest-store.js +19 -11
  18. package/dist/types.d.ts +47 -0
  19. package/dist/types.d.ts.map +1 -1
  20. package/dist/upload/capture-report.d.ts +141 -0
  21. package/dist/upload/capture-report.d.ts.map +1 -0
  22. package/dist/upload/capture-report.js +235 -0
  23. package/dist/upload/census.d.ts +45 -0
  24. package/dist/upload/census.d.ts.map +1 -0
  25. package/dist/upload/census.js +129 -0
  26. package/dist/upload/finalize.d.ts +8 -0
  27. package/dist/upload/finalize.d.ts.map +1 -1
  28. package/dist/upload/finalize.js +71 -5
  29. package/dist/upload/manifest.d.ts +48 -14
  30. package/dist/upload/manifest.d.ts.map +1 -1
  31. package/dist/upload/manifest.js +125 -48
  32. package/dist/upload/reporter.d.ts +23 -6
  33. package/dist/upload/reporter.d.ts.map +1 -1
  34. package/dist/upload/reporter.js +27 -6
  35. package/dist-cjs/fs/output.js +10 -1
  36. package/dist-cjs/test-runner/cli/upload.js +14 -3
  37. package/dist-cjs/test-runner/index.js +2 -2
  38. package/dist-cjs/test-runner/manifest-store.js +20 -12
  39. package/dist-cjs/upload/capture-report.js +247 -0
  40. package/dist-cjs/upload/census.js +138 -0
  41. package/dist-cjs/upload/finalize.js +73 -6
  42. package/dist-cjs/upload/manifest.js +125 -48
  43. package/dist-cjs/upload/reporter.js +27 -6
  44. package/package.json +14 -15
package/README.md CHANGED
@@ -180,6 +180,10 @@ export const LoggedIn = {
180
180
  };
181
181
  ```
182
182
 
183
+ Take as many as you like per story — each name is its own snapshot on the server. Give them distinct names: two captures of one story sharing a name (including `auto`, which the automatic capture uses) resolve to one snapshot, and the run warns which image it had to drop.
184
+
185
+ **Known limitation:** qlip-server keys *baselines* by story, kind and viewport, without the screenshot name. A story's auto capture and its manual captures are therefore independent, but **two manual captures of one story at one viewport share a baseline** — accepting one sets the baseline for the other, which then shows a meaningless diff. The run warns when a build contains such captures. Fixing it needs a server-side change; until then, give such captures distinct viewports if you need independent baselines.
186
+
183
187
  ## Parameters
184
188
 
185
189
  Configure screenshots per story via `parameters.qlip`:
@@ -219,6 +223,7 @@ qlipVitestPlugin({
219
223
  waitForIdleMs: 300,
220
224
  maxWaitForIdleMs: 2000,
221
225
  ignoreElements: [],
226
+ diagnostics: false,
222
227
  });
223
228
  ```
224
229
 
@@ -226,6 +231,10 @@ qlipVitestPlugin({
226
231
 
227
232
  `ignoreElements` lets you provide CSS selectors to mask before capture. Qlip draws solid overlays on matching elements so layout stays intact while visual diffs ignore those regions.
228
233
 
234
+ qlip also reports what it *failed* to capture: the run warns when Storybook stories executed without producing a capture, naming them and the files they came from. It is advisory — a story that sets `parameters.qlip.auto = false` shows up there too.
235
+
236
+ `diagnostics` (or `QLIP_DEBUG=1`) logs one line per capture — story, kind, path, and which browser context wrote it. Turn it on when a CI build captured fewer stories than it ran; the always-written `capture-report.json` covers the same ground after the fact.
237
+
229
238
  ## Memory-safe config for large Storybooks
230
239
 
231
240
  If your Storybook has more than ~150 stories, or any stories that mount a heavy app shell (dashboards, multi-pane editors, etc.), the **default Vitest browser-mode** can OOM — it spawns one worker per CPU core, each with its own BrowserContext, and on a 12-core machine that's easily 20+ GB peak RAM.
@@ -265,11 +274,27 @@ Verified on a real consumer (qorus-ide, 90 story files / 643 tests, Vitest 2.1.9
265
274
  auto/<storyTitle>--<storyName>.png
266
275
  manual/<storyTitle>--<storyName>--<screenshotName>.png
267
276
  error/<storyTitle>--<storyName>--qlip-auto-error-capture.png
277
+ manifest-fragments/<contextId>-<seq>.json
268
278
  manifest.json
279
+ capture-report.json
269
280
  ```
270
281
 
271
282
  - `buildId` defaults to `YYYYMMDD-HHmmss`
272
283
  - `parameters.qlip.skip === true` disables all captures for that story
284
+ - `manifest-fragments/` holds one append-only record per capture; they
285
+ merge into `manifest.json` at end-of-run. Safe to delete after a
286
+ build uploads — see [`design/MANIFEST_FRAGMENTS.md`](./design/MANIFEST_FRAGMENTS.md)
287
+ - `capture-report.json` is the run's audit: captures per context and
288
+ per story file, plus any screenshot on disk that no manifest entry
289
+ references
290
+
291
+ ## Sharded CI runs
292
+
293
+ Several `vitest run` invocations (or `test-storybook` shards) can capture into one build: pin `buildId` — e.g. `buildId: process.env.GITHUB_RUN_ID` — so every shard writes into the same `<outputDir>/<buildId>/`, then upload once at the end with `npx qlip-upload --build-dir ./qlip/screenshots/$GITHUB_RUN_ID`. Fragment names carry a per-process id, so shards cannot overwrite each other's captures.
294
+
295
+ `isolate: false` is supported. Several story files then share one browser context and capture concurrently; fragments are append-only precisely so that is safe.
296
+
297
+ Add `--fail-on-partial-build` to `qlip-upload` (or `upload.failOnPartialBuild` on the plugin) to turn "screenshots on disk that never reached the manifest" from a warning into a red build.
273
298
 
274
299
  ## Manifest
275
300
 
@@ -14,6 +14,14 @@ export declare const DEFAULT_VIEWPORT: {
14
14
  * reference it without pulling node:fs/path into the browser bundle.
15
15
  */
16
16
  export declare const MANIFEST_FRAGMENT_DIR = "manifest-fragments";
17
+ /**
18
+ * Fragment file name for one capture. `(fragmentId, seq)` is unique
19
+ * across browser contexts AND across processes sharing a build dir, so
20
+ * no two writers ever target the same path — that is what makes the
21
+ * fragment set append-only. Shared by both capture paths so they agree
22
+ * by construction. See `design/MANIFEST_FRAGMENTS.md`.
23
+ */
24
+ export declare const fragmentFileName: (fragmentId: string, seq: number) => string;
17
25
  export declare const generateBuildId: (date?: Date) => string;
18
26
  export declare const sanitizeSegment: (value: string) => string;
19
27
  export declare const joinPath: (...parts: string[]) => string;
@@ -1 +1 @@
1
- {"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../../src/fs/output.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAEjE,eAAO,MAAM,SAAS,SAAS,CAAC;AAEhC,eAAO,MAAM,kBAAkB,uBAAuB,CAAC;AACvD,eAAO,MAAM,0BAA0B,4BAA4B,CAAC;AACpE,eAAO,MAAM,gBAAgB;;;CAA+B,CAAC;AAC7D;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,uBAAuB,CAAC;AAE1D,eAAO,MAAM,eAAe,GAAI,OAAM,IAAiB,KAAG,MASzD,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,OAAO,MAAM,KAAG,MACD,CAAC;AAEhD,eAAO,MAAM,QAAQ,GAAI,GAAG,OAAO,MAAM,EAAE,KAAG,MAgB7C,CAAC;AA+BF,eAAO,MAAM,uBAAuB,GAAI,+CAKrC;IACD,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;;;CAOA,CAAC;AAEF,eAAO,MAAM,yBAAyB,GAAI,+DAMvC;IACD,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;CACxB;;;;CAYA,CAAC;AAKF,eAAO,MAAM,gBAAgB,GAAI,+CAK9B;IACD,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;;;CAOA,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAI,+DAMhC;IACD,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;CACxB;;;CAWA,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,oDAM5B;IACD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,SAAS,CAAC,EAAE,IAAI,CAAC;CAClB,KAAG,YAeF,CAAC"}
1
+ {"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../../src/fs/output.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAEjE,eAAO,MAAM,SAAS,SAAS,CAAC;AAEhC,eAAO,MAAM,kBAAkB,uBAAuB,CAAC;AACvD,eAAO,MAAM,0BAA0B,4BAA4B,CAAC;AACpE,eAAO,MAAM,gBAAgB;;;CAA+B,CAAC;AAC7D;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,uBAAuB,CAAC;AAE1D;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,GAAI,YAAY,MAAM,EAAE,KAAK,MAAM,KAAG,MACb,CAAC;AAEvD,eAAO,MAAM,eAAe,GAAI,OAAM,IAAiB,KAAG,MASzD,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,OAAO,MAAM,KAAG,MACD,CAAC;AAEhD,eAAO,MAAM,QAAQ,GAAI,GAAG,OAAO,MAAM,EAAE,KAAG,MAgB7C,CAAC;AA+BF,eAAO,MAAM,uBAAuB,GAAI,+CAKrC;IACD,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;;;CAOA,CAAC;AAEF,eAAO,MAAM,yBAAyB,GAAI,+DAMvC;IACD,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;CACxB;;;;CAYA,CAAC;AAKF,eAAO,MAAM,gBAAgB,GAAI,+CAK9B;IACD,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;;;CAOA,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAI,+DAMhC;IACD,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;CACxB;;;CAWA,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,oDAM5B;IACD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,SAAS,CAAC,EAAE,IAAI,CAAC;CAClB,KAAG,YAeF,CAAC"}
package/dist/fs/output.js CHANGED
@@ -10,6 +10,14 @@ export const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
10
10
  * reference it without pulling node:fs/path into the browser bundle.
11
11
  */
12
12
  export const MANIFEST_FRAGMENT_DIR = 'manifest-fragments';
13
+ /**
14
+ * Fragment file name for one capture. `(fragmentId, seq)` is unique
15
+ * across browser contexts AND across processes sharing a build dir, so
16
+ * no two writers ever target the same path — that is what makes the
17
+ * fragment set append-only. Shared by both capture paths so they agree
18
+ * by construction. See `design/MANIFEST_FRAGMENTS.md`.
19
+ */
20
+ export const fragmentFileName = (fragmentId, seq) => `${fragmentId}-${String(seq).padStart(5, '0')}.json`;
13
21
  export const generateBuildId = (date = new Date()) => {
14
22
  const pad = (num) => String(num).padStart(2, '0');
15
23
  const year = date.getFullYear();
@@ -1 +1 @@
1
- {"version":3,"file":"vitestPlugin.d.ts","sourceRoot":"","sources":["../../src/plugin/vitestPlugin.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAU5C,OAAO,EAAE,iBAAiB,EAAqB,MAAM,aAAa,CAAC;AAMnE,eAAO,MAAM,gBAAgB,GAAI,UAAS,iBAAsB,KAAG,MAiQlE,CAAC"}
1
+ {"version":3,"file":"vitestPlugin.d.ts","sourceRoot":"","sources":["../../src/plugin/vitestPlugin.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAU5C,OAAO,EAAE,iBAAiB,EAAqB,MAAM,aAAa,CAAC;AAMnE,eAAO,MAAM,gBAAgB,GAAI,UAAS,iBAAsB,KAAG,MAmQlE,CAAC"}
@@ -49,6 +49,8 @@ export const qlipVitestPlugin = (options = {}) => {
49
49
  consoleLogExcludePatterns: options.consoleLogExcludePatterns ?? [],
50
50
  },
51
51
  tool: { name: TOOL_NAME, version },
52
+ // Read here, in Node, because the browser runtime has no env.
53
+ diagnostics: options.diagnostics ?? process.env['QLIP_DEBUG'] === '1',
52
54
  };
53
55
  const setupTs = fileURLToPath(new URL('../runtime/setup.ts', import.meta.url));
54
56
  const setupJs = fileURLToPath(new URL('../runtime/setup.js', import.meta.url));
@@ -7,20 +7,32 @@ export interface QlipRuntimeState {
7
7
  warnedMissingStorybook: boolean;
8
8
  consoleLogs: QlipConsoleMessage[];
9
9
  /**
10
- * Unique-per-browser-context identifier used as the fragment
11
- * filename when writing this context's manifest to
12
- * `<buildDir>/manifest-fragments/`. Each `*.stories.tsx` runs in
13
- * its own browser context with its own QlipRuntimeState (initialized
14
- * here); without a per-context fragment, contexts would overwrite
15
- * each other's writes to a shared `manifest.json`. See
16
- * `src/upload/manifest.ts` for the Node-side merge.
10
+ * Identifies this browser context in the fragment file names it
11
+ * writes under `<buildDir>/manifest-fragments/`. Paired with
12
+ * `fragmentSeq` it makes every capture's file name unique, which is
13
+ * what lets fragments be append-only. See `src/upload/manifest.ts`
14
+ * for the Node-side merge.
17
15
  */
18
16
  fragmentId: string;
17
+ /**
18
+ * Captures written by this context so far. Incremented per fragment
19
+ * write; never reused, so no fragment file is ever written twice.
20
+ */
21
+ fragmentSeq: number;
19
22
  }
20
23
  export declare const getRuntimeState: () => QlipRuntimeState | null;
21
24
  export declare const initRuntimeState: () => QlipRuntimeState | null;
22
25
  export declare const nextStepName: (state: QlipRuntimeState, storyId: string) => string;
23
26
  export declare const pushEntry: (state: QlipRuntimeState, entry: QlipManifestEntry) => void;
27
+ /**
28
+ * Claim the next fragment sequence number for this context.
29
+ *
30
+ * Synchronous and unshared, so two overlapping captures — the
31
+ * `afterEach` hooks of two test files in one context under
32
+ * `isolate: false` — can never claim the same number, and therefore
33
+ * never write the same fragment file.
34
+ */
35
+ export declare const nextFragmentSeq: (state: QlipRuntimeState) => number;
24
36
  /**
25
37
  * Remove every `error`-kind entry for `storyId` from the in-memory
26
38
  * manifest. Returns the removed entries so the caller can delete their
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../src/runtime/context.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,iBAAiB,EACjB,iBAAiB,EAClB,MAAM,aAAa,CAAC;AAIrB,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,iBAAiB,CAAC;IAC1B,QAAQ,EAAE,YAAY,CAAC;IACvB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,sBAAsB,EAAE,OAAO,CAAC;IAChC,WAAW,EAAE,kBAAkB,EAAE,CAAC;IAClC;;;;;;;;OAQG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAyBD,eAAO,MAAM,eAAe,QAAO,gBAAgB,GAAG,IAKrD,CAAC;AAEF,eAAO,MAAM,gBAAgB,QAAO,gBAAgB,GAAG,IA0CtD,CAAC;AAEF,eAAO,MAAM,YAAY,GACvB,OAAO,gBAAgB,EACvB,SAAS,MAAM,KACd,MAKF,CAAC;AAEF,eAAO,MAAM,SAAS,GACpB,OAAO,gBAAgB,EACvB,OAAO,iBAAiB,SAGzB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,sBAAsB,GACjC,OAAO,gBAAgB,EACvB,SAAS,MAAM,KACd,iBAAiB,EAmBnB,CAAC;AAEF,eAAO,MAAM,WAAW,GAAI,OAAO,gBAAgB,EAAE,SAAS,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,SAM3F,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAAI,OAAO,gBAAgB,SAE3D,CAAC;AAEF,eAAO,MAAM,cAAc,GACzB,OAAO,gBAAgB,EACvB,KAAK,kBAAkB,EACvB,SAAS,MAAM,SAKhB,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAC3B,OAAO,gBAAgB,KACtB,kBAAkB,EAIpB,CAAC"}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../src/runtime/context.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,iBAAiB,EACjB,iBAAiB,EAClB,MAAM,aAAa,CAAC;AAIrB,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,iBAAiB,CAAC;IAC1B,QAAQ,EAAE,YAAY,CAAC;IACvB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,sBAAsB,EAAE,OAAO,CAAC;IAChC,WAAW,EAAE,kBAAkB,EAAE,CAAC;IAClC;;;;;;OAMG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;CACrB;AAyBD,eAAO,MAAM,eAAe,QAAO,gBAAgB,GAAG,IAKrD,CAAC;AAEF,eAAO,MAAM,gBAAgB,QAAO,gBAAgB,GAAG,IA4CtD,CAAC;AAEF,eAAO,MAAM,YAAY,GACvB,OAAO,gBAAgB,EACvB,SAAS,MAAM,KACd,MAKF,CAAC;AAEF,eAAO,MAAM,SAAS,GACpB,OAAO,gBAAgB,EACvB,OAAO,iBAAiB,SAGzB,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,gBAAgB,KAAG,MAGzD,CAAC;AAEF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,sBAAsB,GACjC,OAAO,gBAAgB,EACvB,SAAS,MAAM,KACd,iBAAiB,EAmBnB,CAAC;AAEF,eAAO,MAAM,WAAW,GAAI,OAAO,gBAAgB,EAAE,SAAS,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,SAM3F,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAAI,OAAO,gBAAgB,SAE3D,CAAC;AAEF,eAAO,MAAM,cAAc,GACzB,OAAO,gBAAgB,EACvB,KAAK,kBAAkB,EACvB,SAAS,MAAM,SAKhB,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAC3B,OAAO,gBAAgB,KACtB,kBAAkB,EAIpB,CAAC"}
@@ -40,10 +40,11 @@ export const initRuntimeState = () => {
40
40
  tool: runtimeConfig.tool,
41
41
  });
42
42
  // Fragment ID needs to be unique per browser context, not unique per
43
- // build. Stamp time + random suffix is plenty (each context is its
44
- // own process / module graph, so two contexts can't race here).
45
- // Math.random() alone would collide ~1 in 16M; with the millis
46
- // prefix that risk is gone.
43
+ // build: under `isolate: false` several test files share one context
44
+ // (and one of these states), and several vitest processes can share
45
+ // one build dir. Stamp time + random suffix covers both — Math.random()
46
+ // alone would collide ~1 in 16M; with the millis prefix that risk is
47
+ // gone.
47
48
  const fragmentId = `${realDateNow().toString(36)}-${Math.random()
48
49
  .toString(36)
49
50
  .slice(2, 10)}`;
@@ -55,6 +56,7 @@ export const initRuntimeState = () => {
55
56
  warnedMissingStorybook: false,
56
57
  consoleLogs: [],
57
58
  fragmentId,
59
+ fragmentSeq: 0,
58
60
  };
59
61
  const globalState = globalThis;
60
62
  globalState[GLOBAL_KEY] = runtime;
@@ -69,6 +71,18 @@ export const nextStepName = (state, storyId) => {
69
71
  export const pushEntry = (state, entry) => {
70
72
  state.manifest.entries.push(entry);
71
73
  };
74
+ /**
75
+ * Claim the next fragment sequence number for this context.
76
+ *
77
+ * Synchronous and unshared, so two overlapping captures — the
78
+ * `afterEach` hooks of two test files in one context under
79
+ * `isolate: false` — can never claim the same number, and therefore
80
+ * never write the same fragment file.
81
+ */
82
+ export const nextFragmentSeq = (state) => {
83
+ state.fragmentSeq += 1;
84
+ return state.fragmentSeq;
85
+ };
72
86
  /**
73
87
  * Remove every `error`-kind entry for `storyId` from the in-memory
74
88
  * manifest. Returns the removed entries so the caller can delete their
@@ -1 +1 @@
1
- {"version":3,"file":"screenshot.d.ts","sourceRoot":"","sources":["../../src/runtime/screenshot.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,qBAAqB,EACrB,gBAAgB,EAEjB,MAAM,aAAa,CAAC;AA2BrB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAqJ1C;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,0BAA0B,GACrC,SAAS,MAAM;AACf;;;;;;;GAOG;AACH,gBAAgB,MAAM,KACrB;IAAE,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;CAiCvD,CAAC;AAwDF;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,GAAI,qEAIxC;IACD,iBAAiB,EAAE,OAAO,CAAC;IAC3B,oBAAoB,EAAE,OAAO,CAAC;IAC9B,qBAAqB,EAAE,OAAO,CAAC;CAChC,SA+DA,CAAC;AAyaF,eAAO,MAAM,UAAU,GACrB,KAAK,gBAAgB,EACrB,gBAAgB,MAAM,GAAG,qBAAqB,EAC9C,UAAU,qBAAqB,kBA0BhC,CAAC;AAEF,KAAK,eAAe,GAAG,WAAW,GAAG;IAAE,KAAK,CAAC,EAAE,gBAAgB,CAAA;CAAE,CAAC;AA8ClE,eAAO,MAAM,qBAAqB,GAAU,KAAK,eAAe,kBA0F/D,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAAU,KAAK,eAAe,kBAkEhE,CAAC"}
1
+ {"version":3,"file":"screenshot.d.ts","sourceRoot":"","sources":["../../src/runtime/screenshot.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,qBAAqB,EACrB,gBAAgB,EAEjB,MAAM,aAAa,CAAC;AA8BrB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AA8L1C;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,0BAA0B,GACrC,SAAS,MAAM;AACf;;;;;;;GAOG;AACH,gBAAgB,MAAM,KACrB;IAAE,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;CAiCvD,CAAC;AAwDF;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,GAAI,qEAIxC;IACD,iBAAiB,EAAE,OAAO,CAAC;IAC3B,oBAAoB,EAAE,OAAO,CAAC;IAC9B,qBAAqB,EAAE,OAAO,CAAC;CAChC,SA+DA,CAAC;AA+ZF,eAAO,MAAM,UAAU,GACrB,KAAK,gBAAgB,EACrB,gBAAgB,MAAM,GAAG,qBAAqB,EAC9C,UAAU,qBAAqB,kBA0BhC,CAAC;AAEF,KAAK,eAAe,GAAG,WAAW,GAAG;IAAE,KAAK,CAAC,EAAE,gBAAgB,CAAA;CAAE,CAAC;AA8ClE,eAAO,MAAM,qBAAqB,GAAU,KAAK,eAAe,kBAiG/D,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAAU,KAAK,eAAe,kBAkEhE,CAAC"}
@@ -1,9 +1,31 @@
1
1
  import { resolveQlipOptions } from '../config/parameters.js';
2
2
  import { monotonicNow, realClearInterval, realSetInterval, realSetTimeout, } from './clock.js';
3
- import { AUTO_ERROR_SCREENSHOT_BASE, MANIFEST_FRAGMENT_DIR, buildAutoLogPath, buildAutoScreenshotPath, buildManualLogPath, buildManualScreenshotPath, joinPath, sanitizeSegment, } from '../fs/output.js';
4
- import { flushConsoleLogs, initRuntimeState, markStorybookWarning, nextStepName, pruneStaleErrorEntries, pushEntry, updateStats, } from './context.js';
5
- const writeManifestFragment = async (commands, buildDir, fragmentId, manifest) => {
6
- await commands.writeFile(joinPath(buildDir, MANIFEST_FRAGMENT_DIR, `${fragmentId}.json`), JSON.stringify(manifest, null, 2), 'utf-8');
3
+ import { AUTO_ERROR_SCREENSHOT_BASE, MANIFEST_FRAGMENT_DIR, buildAutoLogPath, buildAutoScreenshotPath, buildManualLogPath, buildManualScreenshotPath, fragmentFileName, joinPath, sanitizeSegment, } from '../fs/output.js';
4
+ import { flushConsoleLogs, initRuntimeState, markStorybookWarning, nextFragmentSeq, nextStepName, pruneStaleErrorEntries, pushEntry, updateStats, } from './context.js';
5
+ const writeManifestFragment = async (commands, state, payload) => {
6
+ const seq = nextFragmentSeq(state);
7
+ const fragment = {
8
+ ...state.manifest,
9
+ entries: payload.entries ?? [],
10
+ fragmentId: state.fragmentId,
11
+ fragmentSeq: seq,
12
+ ...(payload.tombstones ? { tombstones: payload.tombstones } : {}),
13
+ };
14
+ // JSON.stringify runs to completion before the first await, so the
15
+ // payload is a consistent snapshot even while a concurrent capture
16
+ // mutates the shared manifest.
17
+ const data = JSON.stringify(fragment, null, 2);
18
+ await commands.writeFile(joinPath(state.config.buildDir, MANIFEST_FRAGMENT_DIR, fragmentFileName(state.fragmentId, seq)), data, 'utf-8');
19
+ if (state.config.diagnostics === true) {
20
+ for (const entry of payload.entries ?? []) {
21
+ // eslint-disable-next-line no-console
22
+ console.log(`[qlip] captured ${entry.storyId} ${entry.kind}/${entry.status} → ${entry.path} (ctx ${state.fragmentId} #${String(seq)})`);
23
+ }
24
+ for (const tombstone of payload.tombstones ?? []) {
25
+ // eslint-disable-next-line no-console
26
+ console.log(`[qlip] retracted ${tombstone.storyId} ${tombstone.kind} (ctx ${state.fragmentId} #${String(seq)})`);
27
+ }
28
+ }
7
29
  };
8
30
  /**
9
31
  * Best-effort file deletion for retry-mask pruning. Silent no-op when
@@ -398,7 +420,7 @@ const captureScreenshot = async ({ kind, story, screenshotName, options, testErr
398
420
  const usesNamedPath = kind === 'manual' || kind === 'error';
399
421
  if (resolved.skip) {
400
422
  flushConsoleLogs(runtime);
401
- pushEntry(runtime, buildEntry({
423
+ const skippedEntry = buildEntry({
402
424
  kind,
403
425
  storyId: story.id,
404
426
  storyTitle: story.title,
@@ -425,14 +447,17 @@ const captureScreenshot = async ({ kind, story, screenshotName, options, testErr
425
447
  status: 'skipped',
426
448
  error: testError ?? null,
427
449
  timingsMs: monotonicNow() - captureStart,
428
- }));
450
+ });
451
+ pushEntry(runtime, skippedEntry);
429
452
  updateStats(runtime, {
430
453
  storiesTotal: kind === 'auto'
431
454
  ? runtime.manifest.stats.storiesTotal + 1
432
455
  : runtime.manifest.stats.storiesTotal,
433
456
  skipped: runtime.manifest.stats.skipped + 1,
434
457
  });
435
- await writeManifestFragment(commands, runtime.config.buildDir, runtime.fragmentId, runtime.manifest);
458
+ await writeManifestFragment(commands, runtime, {
459
+ entries: [skippedEntry],
460
+ });
436
461
  return;
437
462
  }
438
463
  let relativePath = '';
@@ -561,7 +586,7 @@ const captureScreenshot = async ({ kind, story, screenshotName, options, testErr
561
586
  : runtime.manifest.stats.failed,
562
587
  });
563
588
  }
564
- await writeManifestFragment(commands, runtime.config.buildDir, runtime.fragmentId, runtime.manifest);
589
+ await writeManifestFragment(commands, runtime, { entries: [entry] });
565
590
  };
566
591
  export const screenshot = async (ctx, nameOrOptions, options) => {
567
592
  const runtime = initRuntimeState();
@@ -688,6 +713,13 @@ export const captureAutoScreenshot = async (ctx) => {
688
713
  const pruned = pruneStaleErrorEntries(runtime, story.id);
689
714
  if (pruned.length) {
690
715
  const { commands } = await ensureBrowserContext();
716
+ // Fragments are append-only, so dropping the entries from the
717
+ // in-memory manifest no longer removes them from disk — the
718
+ // fragments that carried them were already written. Retract them
719
+ // with a tombstone the merger applies.
720
+ await writeManifestFragment(commands, runtime, {
721
+ tombstones: [{ storyId: story.id, kind: 'error' }],
722
+ });
691
723
  for (const entry of pruned) {
692
724
  if (entry.path) {
693
725
  await bestEffortRemoveFile(commands, joinPath(runtime.config.buildDir, entry.path));
@@ -1 +1 @@
1
- {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../../src/test-runner/cli/upload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AA0HH,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,CAAC;IACjB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,eAAO,MAAM,SAAS,GACpB,MAAM,MAAM,EAAE,EACd,MAAK,MAAM,CAAC,UAAwB,EACpC,SAAQ;IAAE,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAY,KAC7E,OAAO,CAAC,gBAAgB,CAoE1B,CAAC"}
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../../src/test-runner/cli/upload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAoIH,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,CAAC;IACjB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,eAAO,MAAM,SAAS,GACpB,MAAM,MAAM,EAAE,EACd,MAAK,MAAM,CAAC,UAAwB,EACpC,SAAQ;IAAE,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAY,KAC7E,OAAO,CAAC,gBAAgB,CAsE1B,CAAC"}
@@ -54,12 +54,18 @@ Options:
54
54
  --commit <sha> Override commit detection.
55
55
  --fail-on-upload-error Exit non-zero on upload failure (default:
56
56
  log error + exit 0).
57
+ --fail-on-partial-build Exit non-zero when screenshots on disk are
58
+ missing from the manifest (default: warn).
57
59
  -h, --help Show this help.
58
60
 
59
61
  Env vars override defaults; CLI flags override env vars.
60
62
  `;
61
63
  const parseArgs = (argv) => {
62
- const args = { failOnUploadError: false, help: false };
64
+ const args = {
65
+ failOnUploadError: false,
66
+ failOnPartialBuild: false,
67
+ help: false,
68
+ };
63
69
  for (let i = 0; i < argv.length; i += 1) {
64
70
  const arg = argv[i];
65
71
  const next = () => argv[++i];
@@ -92,6 +98,9 @@ const parseArgs = (argv) => {
92
98
  case '--fail-on-upload-error':
93
99
  args.failOnUploadError = true;
94
100
  break;
101
+ case '--fail-on-partial-build':
102
+ args.failOnPartialBuild = true;
103
+ break;
95
104
  default:
96
105
  if (arg.startsWith('-')) {
97
106
  throw new Error(`Unknown option: ${arg}`);
@@ -172,6 +181,7 @@ export const runUpload = async (argv, env = process.env, logger = console) => {
172
181
  ...(parsed.branch !== undefined ? { branch: parsed.branch } : {}),
173
182
  ...(parsed.commit !== undefined ? { commit: parsed.commit } : {}),
174
183
  failOnUploadError: parsed.failOnUploadError,
184
+ failOnPartialBuild: parsed.failOnPartialBuild,
175
185
  };
176
186
  // finalizeBuild needs a runtime config but only reads `buildDir`
177
187
  // off it. We can synthesize a minimal one — the manifest itself
@@ -189,8 +199,9 @@ export const runUpload = async (argv, env = process.env, logger = console) => {
189
199
  await finalizeBuild({ runtime, upload });
190
200
  }
191
201
  catch (err) {
192
- // finalizeBuild only throws if failOnUploadError is set AND
193
- // upload failed. So if we get here, the CLI should exit non-zero.
202
+ // finalizeBuild only throws when the caller opted in: upload
203
+ // failed with --fail-on-upload-error, or the build came out
204
+ // partial with --fail-on-partial-build. Either way, exit non-zero.
194
205
  logger.error(`qlip-upload: ${err.message}`);
195
206
  return { exitCode: 2, buildDir };
196
207
  }
@@ -45,7 +45,7 @@ import { promises as fs } from 'node:fs';
45
45
  import path from 'node:path';
46
46
  import { resolveQlipOptions } from '../config/parameters.js';
47
47
  import { DEFAULT_OUTPUT_DIR, DEFAULT_VIEWPORT, MANIFEST_FRAGMENT_DIR, TOOL_NAME, buildAutoScreenshotPath, generateBuildId, } from '../fs/output.js';
48
- import { flushFragmentToDisk, getOrInitStore, nextStepName, peekStore, pushEntry, } from './manifest-store.js';
48
+ import { writeEntryFragment, getOrInitStore, nextStepName, peekStore, pushEntry, } from './manifest-store.js';
49
49
  import { applyAnimationControlInPage, applyIgnoreMasksInPage, removeIgnoreMasksInPage, waitForDomIdleInPage, } from './prep-scripts.js';
50
50
  import { QLIP_TOOL_VERSION } from './version.js';
51
51
  /**
@@ -217,7 +217,7 @@ const qlipCaptureInner = async (page, context, options) => {
217
217
  timingsMs: Date.now() - captureStart,
218
218
  });
219
219
  pushEntry(store, entry);
220
- await flushFragmentToDisk(store);
220
+ await writeEntryFragment(store, entry);
221
221
  return entry;
222
222
  }
223
223
  let status = 'captured';
@@ -276,7 +276,7 @@ const qlipCaptureInner = async (page, context, options) => {
276
276
  pushEntry(store, entry);
277
277
  // Persist after every capture so the separate qlip-upload process
278
278
  // sees the latest state even if the test run is interrupted.
279
- await flushFragmentToDisk(store);
279
+ await writeEntryFragment(store, entry);
280
280
  return entry;
281
281
  };
282
282
  export { nextStepName }; // exposed for the manual-screenshot path (v1.5)
@@ -39,16 +39,18 @@ export interface IQlipTestRunnerStore {
39
39
  /** ms-since-epoch at first capture, for `durationMs`. */
40
40
  startedAt: number;
41
41
  /**
42
- * Per-process fragment ID. Each Jest worker / test-runner shard
43
- * gets its own fragment file under `manifest-fragments/`. Lines
44
- * up with the Vitest plugin path's fragment-per-browser-context
45
- * model so `mergeManifestFragments()` (in `src/upload/manifest.ts`)
46
- * works identically across both capture paths.
42
+ * Per-process fragment ID, prefixing every fragment file this
43
+ * process writes under `manifest-fragments/`. Lines up with the
44
+ * Vitest plugin path's model so `mergeManifestFragments()` (in
45
+ * `src/upload/manifest.ts`) works identically across both capture
46
+ * paths.
47
47
  *
48
48
  * Random + timestamp keeps it unique across parallel workers
49
49
  * without coordination.
50
50
  */
51
51
  fragmentId: string;
52
+ /** Captures written so far; see `QlipRuntimeState.fragmentSeq`. */
53
+ fragmentSeq: number;
52
54
  }
53
55
  /**
54
56
  * Get-or-initialise the per-process store. Idempotent; safe to
@@ -62,16 +64,16 @@ export declare const getOrInitStore: (init: {
62
64
  toolVersion: string;
63
65
  }) => IQlipTestRunnerStore;
64
66
  /**
65
- * Write the current store's full manifest to its fragment file on
66
- * disk. Called after every capture so the qlip-upload CLI (a
67
- * separate process) can read the latest state at end-of-run.
67
+ * Write one capture's fragment to disk. Called after every capture so
68
+ * the qlip-upload CLI (a separate process) sees it even if the run is
69
+ * interrupted.
68
70
  *
69
- * Cheap: ~5 KB JSON written per capture, well within macOS/Linux
70
- * filesystem cache. We overwrite each call rather than appending —
71
- * the file's contents always reflect THIS process's full
72
- * accumulated manifest.
71
+ * Append-only, matching the Vitest path: a fragment file is named for
72
+ * `(fragmentId, seq)` and therefore written exactly once, so no two
73
+ * captures — in this process or any other sharing the build dir — can
74
+ * clobber each other. See `design/MANIFEST_FRAGMENTS.md`.
73
75
  */
74
- export declare const flushFragmentToDisk: (store: IQlipTestRunnerStore) => Promise<void>;
76
+ export declare const writeEntryFragment: (store: IQlipTestRunnerStore, entry: QlipManifestEntry) => Promise<void>;
75
77
  /**
76
78
  * Read the current store. Returns `undefined` if `qlipCapture` was
77
79
  * never invoked (e.g. globalSetup teardown firing on a run with no
@@ -1 +1 @@
1
- {"version":3,"file":"manifest-store.d.ts","sourceRoot":"","sources":["../../src/test-runner/manifest-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAIH,OAAO,KAAK,EACV,YAAY,EACZ,iBAAiB,EACjB,oBAAoB,EACrB,MAAM,aAAa,CAAC;AASrB,MAAM,WAAW,oBAAoB;IACnC,yDAAyD;IACzD,QAAQ,EAAE,MAAM,CAAC;IACjB,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,SAAS,EAAE,MAAM,CAAC;IAClB,0DAA0D;IAC1D,QAAQ,EAAE,YAAY,CAAC;IACvB,4DAA4D;IAC5D,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,yDAAyD;IACzD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;;OASG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAQD;;;GAGG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,WAAW,EAAE,MAAM,CAAC;CACrB,KAAG,oBA4BH,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB,GAC9B,OAAO,oBAAoB,KAC1B,OAAO,CAAC,IAAI,CAWd,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,SAAS,QAAO,oBAAoB,GAAG,SAEnD,CAAC;AAEF,sEAAsE;AACtE,eAAO,MAAM,UAAU,QAAO,IAE7B,CAAC;AAEF,yDAAyD;AACzD,eAAO,MAAM,SAAS,GACpB,OAAO,oBAAoB,EAC3B,OAAO,iBAAiB,KACvB,IAaF,CAAC;AAEF,gEAAgE;AAChE,eAAO,MAAM,YAAY,GACvB,OAAO,oBAAoB,EAC3B,SAAS,MAAM,KACd,MAKF,CAAC"}
1
+ {"version":3,"file":"manifest-store.d.ts","sourceRoot":"","sources":["../../src/test-runner/manifest-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAIH,OAAO,KAAK,EACV,YAAY,EACZ,iBAAiB,EAEjB,oBAAoB,EACrB,MAAM,aAAa,CAAC;AAUrB,MAAM,WAAW,oBAAoB;IACnC,yDAAyD;IACzD,QAAQ,EAAE,MAAM,CAAC;IACjB,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,SAAS,EAAE,MAAM,CAAC;IAClB,0DAA0D;IAC1D,QAAQ,EAAE,YAAY,CAAC;IACvB,4DAA4D;IAC5D,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,yDAAyD;IACzD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;;OASG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,WAAW,EAAE,MAAM,CAAC;CACrB;AAQD;;;GAGG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,WAAW,EAAE,MAAM,CAAC;CACrB,KAAG,oBA6BH,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,GAC7B,OAAO,oBAAoB,EAC3B,OAAO,iBAAiB,KACvB,OAAO,CAAC,IAAI,CAkBd,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,SAAS,QAAO,oBAAoB,GAAG,SAEnD,CAAC;AAEF,sEAAsE;AACtE,eAAO,MAAM,UAAU,QAAO,IAE7B,CAAC;AAEF,yDAAyD;AACzD,eAAO,MAAM,SAAS,GACpB,OAAO,oBAAoB,EAC3B,OAAO,iBAAiB,KACvB,IAaF,CAAC;AAEF,gEAAgE;AAChE,eAAO,MAAM,YAAY,GACvB,OAAO,oBAAoB,EAC3B,SAAS,MAAM,KACd,MAKF,CAAC"}
@@ -26,7 +26,7 @@
26
26
  */
27
27
  import { promises as fs } from 'node:fs';
28
28
  import path from 'node:path';
29
- import { MANIFEST_FRAGMENT_DIR, TOOL_NAME, createManifest, } from '../fs/output.js';
29
+ import { MANIFEST_FRAGMENT_DIR, TOOL_NAME, createManifest, fragmentFileName, } from '../fs/output.js';
30
30
  const STORE_KEY = Symbol.for('@qoretechnologies/qlip/__test_runner_store__');
31
31
  const holder = () => globalThis;
32
32
  /**
@@ -57,23 +57,31 @@ export const getOrInitStore = (init) => {
57
57
  counters: new Map(),
58
58
  startedAt: Date.now(),
59
59
  fragmentId,
60
+ fragmentSeq: 0,
60
61
  };
61
62
  holder()[STORE_KEY] = store;
62
63
  return store;
63
64
  };
64
65
  /**
65
- * Write the current store's full manifest to its fragment file on
66
- * disk. Called after every capture so the qlip-upload CLI (a
67
- * separate process) can read the latest state at end-of-run.
66
+ * Write one capture's fragment to disk. Called after every capture so
67
+ * the qlip-upload CLI (a separate process) sees it even if the run is
68
+ * interrupted.
68
69
  *
69
- * Cheap: ~5 KB JSON written per capture, well within macOS/Linux
70
- * filesystem cache. We overwrite each call rather than appending —
71
- * the file's contents always reflect THIS process's full
72
- * accumulated manifest.
70
+ * Append-only, matching the Vitest path: a fragment file is named for
71
+ * `(fragmentId, seq)` and therefore written exactly once, so no two
72
+ * captures — in this process or any other sharing the build dir — can
73
+ * clobber each other. See `design/MANIFEST_FRAGMENTS.md`.
73
74
  */
74
- export const flushFragmentToDisk = async (store) => {
75
- const fragmentPath = path.join(store.buildDir, MANIFEST_FRAGMENT_DIR, `${store.fragmentId}.json`);
76
- await fs.writeFile(fragmentPath, JSON.stringify(store.manifest, null, 2), 'utf-8');
75
+ export const writeEntryFragment = async (store, entry) => {
76
+ store.fragmentSeq += 1;
77
+ const seq = store.fragmentSeq;
78
+ const fragment = {
79
+ ...store.manifest,
80
+ entries: [entry],
81
+ fragmentId: store.fragmentId,
82
+ fragmentSeq: seq,
83
+ };
84
+ await fs.writeFile(path.join(store.buildDir, MANIFEST_FRAGMENT_DIR, fragmentFileName(store.fragmentId, seq)), JSON.stringify(fragment, null, 2), 'utf-8');
77
85
  };
78
86
  /**
79
87
  * Read the current store. Returns `undefined` if `qlipCapture` was
package/dist/types.d.ts CHANGED
@@ -101,6 +101,13 @@ export interface QlipUploadOptions {
101
101
  * Set to `true` in CI when you want red builds on upload errors.
102
102
  */
103
103
  failOnUploadError?: boolean;
104
+ /**
105
+ * Whether a partial build should fail the run. A build is partial
106
+ * when PNGs exist on disk that no manifest entry references — i.e.
107
+ * captures whose manifest record was lost. Default: `false` (warn
108
+ * only). See `design/MANIFEST_FRAGMENTS.md`.
109
+ */
110
+ failOnPartialBuild?: boolean;
104
111
  }
105
112
  export interface QlipPluginOptions extends QlipCaptureOptions {
106
113
  outputDir?: string;
@@ -111,6 +118,13 @@ export interface QlipPluginOptions extends QlipCaptureOptions {
111
118
  * happens — the screenshots stay on local disk.
112
119
  */
113
120
  upload?: QlipUploadOptions;
121
+ /**
122
+ * Log one line per capture (story, kind, path, context + sequence)
123
+ * and keep it in the build's `capture-report.json`. Off by default;
124
+ * `$QLIP_DEBUG=1` turns it on without touching config. Use it when a
125
+ * CI build captured fewer stories than it ran.
126
+ */
127
+ diagnostics?: boolean;
114
128
  }
115
129
  export type QlipResolvedDefaults = Required<QlipCaptureOptions> & {
116
130
  outputDir: string;
@@ -125,6 +139,8 @@ export interface QlipRuntimeConfig {
125
139
  name: string;
126
140
  version: string;
127
141
  };
142
+ /** See `QlipPluginOptions.diagnostics`. */
143
+ diagnostics?: boolean;
128
144
  }
129
145
  export interface QlipScreenshotOptions extends QlipCaptureOptions {
130
146
  name?: string;
@@ -256,4 +272,35 @@ export interface QlipManifest {
256
272
  };
257
273
  entries: QlipManifestEntry[];
258
274
  }
275
+ /**
276
+ * Marks every entry for `(storyId, kind)` as stale, whatever order the
277
+ * fragments merge in. Written by the retry-mask prune when a story
278
+ * passes on a later attempt: the error captures from the failed
279
+ * attempts are no longer real failures. Order-independent by design —
280
+ * "this story ultimately passed" is true regardless of when the
281
+ * tombstone lands relative to the entries it retracts.
282
+ */
283
+ export interface QlipManifestTombstone {
284
+ storyId: string;
285
+ kind: QlipEntryKind;
286
+ }
287
+ /**
288
+ * One capture's on-disk record under `<buildDir>/manifest-fragments/`.
289
+ *
290
+ * Fragments are append-only: one file per capture, written exactly
291
+ * once and never rewritten, so concurrent captures — in one context,
292
+ * across contexts, or across processes sharing a `--build-dir` —
293
+ * cannot clobber each other. `entries` therefore holds a single entry
294
+ * (or none, for a tombstone-only fragment), but the merger accepts any
295
+ * count so fragments written by older qlip versions still merge.
296
+ *
297
+ * See `design/MANIFEST_FRAGMENTS.md`.
298
+ */
299
+ export interface QlipManifestFragment extends QlipManifest {
300
+ /** Identifies the writing browser context / process. */
301
+ fragmentId?: string;
302
+ /** Per-context capture counter; orders entries within a context. */
303
+ fragmentSeq?: number;
304
+ tombstones?: QlipManifestTombstone[];
305
+ }
259
306
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}