@tekyzinc/gsd-t 3.26.11 → 3.29.10

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 (34) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +4 -0
  3. package/bin/context-budget-audit.cjs +17 -2
  4. package/bin/gsd-t-build-coverage.cjs +438 -0
  5. package/bin/gsd-t-ci-parity.cjs +500 -0
  6. package/bin/gsd-t-economics.cjs +37 -9
  7. package/bin/gsd-t-test-data-adapters/file-json-array.cjs +56 -0
  8. package/bin/gsd-t-test-data-adapters/localstorage-key-prefix.cjs +44 -0
  9. package/bin/gsd-t-test-data-adapters/sqlite-table-where.cjs +71 -0
  10. package/bin/gsd-t-test-data-ledger.cjs +290 -0
  11. package/bin/gsd-t-time-format.cjs +94 -0
  12. package/bin/gsd-t.js +30 -0
  13. package/bin/model-windows.cjs +99 -0
  14. package/bin/model-windows.test.cjs +75 -0
  15. package/bin/orchestrator.js +4 -1
  16. package/bin/runway-estimator.cjs +35 -5
  17. package/bin/token-budget.cjs +12 -3
  18. package/commands/gsd-t-complete-milestone.md +7 -3
  19. package/commands/gsd-t-help.md +21 -0
  20. package/commands/gsd-t-init.md +1 -1
  21. package/commands/gsd-t-verify.md +90 -0
  22. package/package.json +1 -1
  23. package/scripts/context-meter/transcript-parser.js +12 -2
  24. package/scripts/context-meter/transcript-parser.test.js +51 -4
  25. package/scripts/gsd-t-calibration-hook.js +8 -1
  26. package/scripts/gsd-t-context-meter.e2e.test.js +45 -6
  27. package/scripts/gsd-t-context-meter.js +17 -3
  28. package/scripts/gsd-t-context-meter.test.js +85 -0
  29. package/scripts/gsd-t-date-guard.js +26 -5
  30. package/scripts/gsd-t-design-review-server.js +3 -1
  31. package/templates/CLAUDE-global.md +37 -1
  32. package/templates/progress.md +6 -2
  33. package/templates/test-helpers/README.md +98 -0
  34. package/templates/test-helpers/test-data-fixture.ts +153 -0
@@ -384,3 +384,88 @@ test("12. clock injection — timestamp uses injected clock", async () => {
384
384
  const state = JSON.parse(fs.readFileSync(stateFile(tmpRoot), "utf8"));
385
385
  assert.equal(state.timestamp, fixed.toISOString());
386
386
  });
387
+
388
+ /* ── M-fix: model-aware context window (the reported regression) ───────── */
389
+
390
+ test("13. Opus 4.7 @ ~36% of a 1M window stays 'normal' (regression repro)", async () => {
391
+ // The exact reported symptom: ~360k tokens used on an Opus 4.7 session.
392
+ // With the old hardcoded 200k window this computed 180% → premature
393
+ // headless handoff at ~64% of context REMAINING. With model-aware sizing
394
+ // the window is 1M, so 360k = 36% = normal, no handoff.
395
+ seedState(tmpRoot, { checkCount: 4 });
396
+
397
+ const out = await runMeter({
398
+ payload: makePayload(),
399
+ projectRoot: tmpRoot,
400
+ _loadConfig: () => makeConfig(), // config still says 200k — must be overridden
401
+ _parseTranscript: async () => ({ ...FAKE_PARSED, model: "claude-opus-4-7" }),
402
+ _estimateTokens: () => ({ inputTokens: 360000 }),
403
+ });
404
+
405
+ // No handoff marker — this is the whole point of the fix.
406
+ assert.deepEqual(out, {});
407
+
408
+ const state = JSON.parse(fs.readFileSync(stateFile(tmpRoot), "utf8"));
409
+ assert.equal(state.modelWindowSize, 1_000_000, "window resolved from model, not config");
410
+ assert.equal(state.pct, 36, "360k / 1M = 36%");
411
+ assert.equal(state.threshold, "normal");
412
+ });
413
+
414
+ test("14. Opus 4.7 @ 80% of the true 1M window DOES hand off", async () => {
415
+ // The handoff must still fire at the real 75% threshold against the
416
+ // corrected window — we keep the guard, we just size it correctly.
417
+ seedState(tmpRoot, { checkCount: 4 });
418
+
419
+ const out = await runMeter({
420
+ payload: makePayload(),
421
+ projectRoot: tmpRoot,
422
+ _loadConfig: () => makeConfig(),
423
+ _parseTranscript: async () => ({ ...FAKE_PARSED, model: "claude-opus-4-7-20260115" }),
424
+ _estimateTokens: () => ({ inputTokens: 800000 }), // 80% of 1M > 75%
425
+ });
426
+
427
+ assert.equal(out.additionalContext, "next-spawn-headless:true");
428
+ const state = JSON.parse(fs.readFileSync(stateFile(tmpRoot), "utf8"));
429
+ assert.equal(state.modelWindowSize, 1_000_000);
430
+ assert.equal(state.pct, 80);
431
+ assert.equal(state.threshold, "threshold");
432
+ });
433
+
434
+ test("15. no model in transcript → falls back to config window (back-compat)", async () => {
435
+ // Existing transcripts / stubs without a model field must behave exactly
436
+ // as before: config's modelWindowSize governs.
437
+ seedState(tmpRoot, { checkCount: 4 });
438
+
439
+ const out = await runMeter({
440
+ payload: makePayload(),
441
+ projectRoot: tmpRoot,
442
+ _loadConfig: () => makeConfig({ modelWindowSize: 200000 }),
443
+ _parseTranscript: async () => FAKE_PARSED, // no `model` key
444
+ _estimateTokens: () => ({ inputTokens: 160000 }), // 80% of 200k
445
+ });
446
+
447
+ assert.equal(out.additionalContext, "next-spawn-headless:true");
448
+ const state = JSON.parse(fs.readFileSync(stateFile(tmpRoot), "utf8"));
449
+ assert.equal(state.modelWindowSize, 200000);
450
+ assert.equal(state.pct, 80);
451
+ });
452
+
453
+ test("16. Haiku session correctly sized at 200k (not over-large 1M)", async () => {
454
+ seedState(tmpRoot, { checkCount: 4 });
455
+
456
+ const out = await runMeter({
457
+ payload: makePayload(),
458
+ projectRoot: tmpRoot,
459
+ _loadConfig: () => makeConfig(),
460
+ _parseTranscript: async () => ({
461
+ ...FAKE_PARSED,
462
+ model: "claude-haiku-4-5-20251001",
463
+ }),
464
+ _estimateTokens: () => ({ inputTokens: 170000 }), // 85% of 200k
465
+ });
466
+
467
+ assert.equal(out.additionalContext, "next-spawn-headless:true");
468
+ const state = JSON.parse(fs.readFileSync(stateFile(tmpRoot), "utf8"));
469
+ assert.equal(state.modelWindowSize, 200000);
470
+ assert.equal(state.pct, 85);
471
+ });
@@ -89,18 +89,25 @@ const FRESH_STAMP_PATTERNS = [
89
89
  ];
90
90
 
91
91
  // Generic ISO date — only validated when surrounded by strong "freshly stamping now"
92
- // context (e.g., right after labels like "Date:", "Today:", "Stamped:", etc.).
93
- // Two arms: (a) date+time (validated against full ±DRIFT_MINUTES window),
92
+ // context (e.g., right after labels like the canonical frontmatter / metadata
93
+ // keys captured below).
94
+ // Two arms: (a) date+time (validated against full +/-DRIFT_MINUTES window),
94
95
  // (b) date-only (validated as same-calendar-day-as-now, time-of-day ignored).
96
+ //
97
+ // M59 (v3.29.10): time portion may carry an optional trailing TZ token —
98
+ // either a short abbreviation (PDT/PST/UTC/...), a numeric offset
99
+ // (+/-HH:MM or +/-HHMM), or Z. The TZ is matched but not used for drift
100
+ // math — drift is computed against the local clock, which already has the
101
+ // live offset.
95
102
  const STAMPED_ISO_PATTERN = {
96
103
  name: "stamped-iso",
97
- regex: /\b(?:Date|Today|Stamped|Updated|Created|Generated|Now|Timestamp|At)\s*[:=]\s*(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/gi,
104
+ regex: /\b(?:Date|Today|Stamped|Updated|Created|Generated|Now|Timestamp|At)\s*[:=]\s*(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::\d{2})?(?:\s+[A-Z]{2,5}|[+\-]\d{2}:?\d{2}|Z)?)?/gi,
98
105
  extract: (m) => {
99
106
  const hasTime = m[4] !== undefined;
100
107
  return {
101
108
  stamped: new Date(
102
109
  Number(m[1]), Number(m[2]) - 1, Number(m[3]),
103
- hasTime ? Number(m[4]) : 12, // Date-only noon, neutralizes timezone-edge false positives
110
+ hasTime ? Number(m[4]) : 12, // Date-only -> noon, neutralizes timezone-edge false positives
104
111
  hasTime ? Number(m[5]) : 0,
105
112
  0
106
113
  ),
@@ -109,6 +116,20 @@ const STAMPED_ISO_PATTERN = {
109
116
  },
110
117
  };
111
118
 
119
+ // M59 (v3.29.10): table cells in progress.md's "Completed Milestones" and
120
+ // "Session Log" tables now carry `YYYY-MM-DD HH:MM TZ`. We validate them
121
+ // against +/-DRIFT_MINUTES (treat as a fresh stamp). Date-only cells in
122
+ // pre-3.29.10 rows remain valid and are NOT flagged - those are historical
123
+ // (forward-only rule), so this regex requires the HH:MM portion to fire.
124
+ const PROGRESS_TABLE_CELL_PATTERN = {
125
+ name: "progress-table-cell",
126
+ regex: /\|\s*(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?:\s+[A-Z]{2,5})?\s*\|/g,
127
+ extract: (m) => new Date(
128
+ Number(m[1]), Number(m[2]) - 1, Number(m[3]),
129
+ Number(m[4]), Number(m[5]), 0
130
+ ),
131
+ };
132
+
112
133
  function isAllowlisted(filePath) {
113
134
  if (!filePath) return false;
114
135
  return ALLOWLIST_PATTERNS.some((re) => re.test(filePath));
@@ -119,7 +140,7 @@ function findStaleTimestamps(content, now, oldContent) {
119
140
  const findings = [];
120
141
  const oldText = typeof oldContent === "string" ? oldContent : "";
121
142
 
122
- const allPatterns = [...FRESH_STAMP_PATTERNS, STAMPED_ISO_PATTERN];
143
+ const allPatterns = [...FRESH_STAMP_PATTERNS, STAMPED_ISO_PATTERN, PROGRESS_TABLE_CELL_PATTERN];
123
144
 
124
145
  for (const pattern of allPatterns) {
125
146
  const regex = new RegExp(pattern.regex.source, pattern.regex.flags);
@@ -13,6 +13,7 @@ const http = require("http");
13
13
  const { spawn } = require("child_process");
14
14
  const fs = require("fs");
15
15
  const path = require("path");
16
+ const { localIsoWithOffset } = require(path.join(__dirname, "..", "bin", "gsd-t-time-format.cjs"));
16
17
  const url = require("url");
17
18
 
18
19
  // ── CLI args ──────────────────────────────────────────────────────────
@@ -422,7 +423,8 @@ function writeFeedback(items) {
422
423
  fs.writeFileSync(
423
424
  path.join(REVIEW_DIR, "review-complete.json"),
424
425
  JSON.stringify({
425
- completedAt: new Date().toISOString(),
426
+ // M59 (v3.29.10): local-offset ISO (`YYYY-MM-DDTHH:MM:SS±HH:MM`) rather than UTC `Z`.
427
+ completedAt: localIsoWithOffset(),
426
428
  phase: readStatus().phase,
427
429
  items: items.map(i => ({ id: i.id, verdict: i.verdict })),
428
430
  }, null, 2)
@@ -167,11 +167,13 @@ Whenever you write a date or timestamp to any file — decision log entries in `
167
167
  2. If absent, run `node -e "console.log(new Date().toISOString())"` via Bash before writing.
168
168
 
169
169
  **Enforcement**: a PreToolUse hook (`scripts/gsd-t-date-guard.js`) blocks Write/Edit calls whose content contains timestamps drifting more than ±5 minutes from the live system clock. The guard:
170
- - Validates decision-log entries (`- YYYY-MM-DD HH:MM:`), filename timestamps (`continue-here-YYYY-MM-DDTHHMMSS`), banners (`Day: Mon DD, YYYY HH:MM`), and labeled stamps (`Date:`, `Updated:`, `Created:`, etc.).
170
+ - Validates decision-log entries (`- YYYY-MM-DD HH:MM:`), filename timestamps (`continue-here-YYYY-MM-DDTHHMMSS`), banners (`Day: Mon DD, YYYY HH:MM`), labeled stamps (`Date:`, `Updated:`, `Created:`, etc., with optional TZ abbr / numeric offset / `Z`), and **progress.md table cells carrying `YYYY-MM-DD HH:MM TZ`** (M59, v3.29.10+ — Completed Milestones + Session Log).
171
171
  - For Edit, ignores timestamps that appear in BOTH `old_string` and `new_string` (pre-existing context, not new writes).
172
172
  - Allowlists machine-written paths (`.gsd-t/events/`, `.gsd-t/transcripts/`, `.gsd-t/metrics/`, `.git/`, `node_modules/`, archives, log files).
173
173
  - Fails open on internal error — broken tool calls would be worse than drift.
174
174
 
175
+ **Timestamp precision in progress.md (M59, v3.29.10+)**: the `## Date:` frontmatter line, the "Completed" cell of the Completed Milestones table, and the "Date" cell of the Session Log table MUST be written as `YYYY-MM-DD HH:MM TZ` (e.g. `2026-05-27 10:15 PDT`). This is **forward-only** — pre-3.29.10 rows that read date-only (`YYYY-MM-DD`) stay as-is. Readers (status, dashboard, GSD-T-Board) MUST accept both. `archive-meta.json::completedAt` is local-offset ISO (`YYYY-MM-DDTHH:MM:SS±HH:MM`) — use `localIsoWithOffset()` from `bin/gsd-t-time-format.cjs`, not `new Date().toISOString()` (which produces UTC `Z`).
176
+
175
177
  If the guard blocks your write, do NOT bypass it. Re-read `[GSD-T NOW]`, regenerate the timestamp, retry.
176
178
 
177
179
  ## Conversation vs. Work
@@ -260,6 +262,34 @@ Every Playwright assertion must verify one of:
260
262
 
261
263
  **If a test would pass on an empty HTML page with the correct element IDs and no JavaScript, it is not a functional test.** Rewrite it.
262
264
 
265
+ ### Test Data Cleanup (MANDATORY — M58)
266
+
267
+ **Tests that insert data into a project's stores MUST register those inserts with the GSD-T test-data ledger so Verify can purge them.** Tests that leave orphaned `E2E_*` records in production data violate this rule.
268
+
269
+ The supported mechanism is the `withTestData()` Playwright fixture:
270
+
271
+ ```ts
272
+ import { test as base } from '@playwright/test';
273
+ import { withTestData } from '@tekyzinc/gsd-t/templates/test-helpers/test-data-fixture';
274
+
275
+ export const test = base.extend(withTestData());
276
+
277
+ test('drag idea creates new column', async ({ page, testData }) => {
278
+ const id = testData.tag('E2E_DRAG'); // → "E2E_DRAG_{runId}_{counter}"
279
+ await testData.register({
280
+ kind: 'localStorage-key-prefix',
281
+ store: 'gsd-t-board:idea:',
282
+ id,
283
+ taggedPrefix: 'E2E_',
284
+ });
285
+ // … UI interactions that insert a row keyed by `${store}${id}` …
286
+ });
287
+ ```
288
+
289
+ Three built-in adapters: `localStorage-key-prefix`, `file-json-array`, `sqlite-table-where`. Extend via `registerAdapter(kind, adapter)`. Each adapter refuses to delete a record whose id does not start with the ledger row's `taggedPrefix` (defense in depth — see `.gsd-t/contracts/test-data-tagging-contract.md`).
290
+
291
+ After the E2E suite, `gsd-t-verify` Step 4.5 runs `gsd-t test-data --purge --run "$GSD_T_VERIFY_RUN_ID"`. If any adapter throws or refuses, verify FAILs the gate (block-promotion semantics — equivalent to a failing CI-Parity Gate). Contract: `.gsd-t/contracts/test-data-ledger-contract.md` v1.0.0 STABLE.
292
+
263
293
  ## QA Agent (Mandatory)
264
294
 
265
295
  Every code-producing/validating phase MUST run QA. QA writes ZERO feature code — it generates, runs, and gap-reports tests. Failure (or any shallow E2E test) blocks phase completion.
@@ -537,6 +567,12 @@ BEFORE EVERY COMMIT:
537
567
  │ YES → Verify test names and paths are referenced in requirements
538
568
  ├── Did I change UI, routes, or user flows?
539
569
  │ YES → Update affected E2E test specs (Playwright/Cypress)
570
+ ├── Did I add a new top-level dir, or change build/CI config?
571
+ │ This is ENFORCED MECHANICALLY by `gsd-t-verify` Step 2.6
572
+ │ (CI-Parity Gate: `gsd-t build-coverage` + `gsd-t ci-parity`,
573
+ │ FAIL-blocking). You do NOT self-attest this — verify runs the
574
+ │ real CI build. It exists because TimeTracking v1.10.12 shipped
575
+ │ VERIFIED+tagged with a new dir absent from the Dockerfile COPY.
540
576
  └── Did I run the affected tests?
541
577
  YES → Verify they pass. NO → Run them now.
542
578
  ```
@@ -3,7 +3,7 @@
3
3
  ## Project: {Project Name}
4
4
  ## Version: 0.1.0
5
5
  ## Status: READY
6
- ## Date: {Date}
6
+ ## Date: {YYYY-MM-DD HH:MM TZ}
7
7
 
8
8
  ## Current Milestone
9
9
  None — ready for next milestone
@@ -11,6 +11,8 @@ None — ready for next milestone
11
11
  ## Completed Milestones
12
12
  | Milestone | Version | Completed | Tag |
13
13
  |-----------|---------|-----------|-----|
14
+ <!-- M59: "Completed" cell format is `YYYY-MM-DD HH:MM TZ` for entries written ≥ v3.29.10; older rows may be `YYYY-MM-DD`. Readers MUST accept both. -->
15
+
14
16
 
15
17
  ## Domains
16
18
  | Domain | Status | Tasks | Completed |
@@ -37,4 +39,6 @@ None — ready for next milestone
37
39
  ## Session Log
38
40
  | Date | Session | What was accomplished |
39
41
  |------|---------|----------------------|
40
- | {Date} | 1 | Project initialized |
42
+ | {YYYY-MM-DD HH:MM TZ} | 1 | Project initialized |
43
+ <!-- M59: "Date" cell format is `YYYY-MM-DD HH:MM TZ` for entries written ≥ v3.29.10; older rows may be `YYYY-MM-DD`. Readers MUST accept both. -->
44
+
@@ -0,0 +1,98 @@
1
+ # GSD-T Test Helpers
2
+
3
+ Helpers that test suites can import to keep test data out of production stores.
4
+
5
+ ## `test-data-fixture.ts`
6
+
7
+ Playwright fixture (`withTestData`) that auto-registers test inserts with the
8
+ GSD-T test-data ledger. After Playwright finishes, the verify-final-step
9
+ (`gsd-t-verify` Step 4.5) sweeps the ledger and purges every registered row.
10
+
11
+ ### Install
12
+
13
+ ```ts
14
+ // playwright.config.ts (no changes — fixture is plugged in per-spec or via a base test)
15
+
16
+ // test/_base.ts
17
+ import { test as base } from '@playwright/test';
18
+ import { withTestData } from '@tekyzinc/gsd-t/templates/test-helpers/test-data-fixture';
19
+
20
+ export const test = base.extend(withTestData());
21
+ export { expect } from '@playwright/test';
22
+ ```
23
+
24
+ ### Use
25
+
26
+ ```ts
27
+ import { test } from './_base';
28
+
29
+ test('drag idea creates new column', async ({ page, testData }) => {
30
+ const id = testData.tag('E2E_DRAG');
31
+ await testData.register({
32
+ kind: 'localStorage-key-prefix',
33
+ store: 'gsd-t-board:idea:',
34
+ id,
35
+ taggedPrefix: 'E2E_',
36
+ });
37
+
38
+ // ... interact with the UI; the app inserts a row keyed by `gsd-t-board:idea:${id}` ...
39
+ });
40
+ ```
41
+
42
+ ### Tagging Convention
43
+
44
+ All IDs that flow through `testData.register()` MUST start with a recognized
45
+ prefix. Defaults to `E2E_`. Projects can declare additional prefixes in
46
+ `.gsd-t/test-data-config.json`:
47
+
48
+ ```json
49
+ { "taggedPrefixes": ["E2E_", "FIXTURE_", "INTEGRATION_"] }
50
+ ```
51
+
52
+ `testData.tag(prefix)` composes IDs of the form
53
+ `{PREFIX}_{verifyRunId}_{counter}` — e.g.
54
+ `E2E_DRAG_verify-m58-20260527T091800Z_3`.
55
+
56
+ ### How purge happens
57
+
58
+ 1. Each call to `testData.register(...)` appends a JSONL row to
59
+ `.gsd-t/test-data-ledger.jsonl`.
60
+ 2. After Playwright runs, `gsd-t-verify` Step 4.5 executes
61
+ `gsd-t test-data --purge --run "$GSD_T_VERIFY_RUN_ID"`.
62
+ 3. The ledger is read; each row is dispatched to its `kind`'s adapter; the
63
+ adapter removes the record from the store (or reports `absent` /
64
+ structured `error`).
65
+ 4. If any row produces an error, verify FAILs the Test Data Cleanup Gate
66
+ (block-promotion semantics — equivalent to a failing CI-Parity Gate).
67
+
68
+ ### Opt-in per-test purge
69
+
70
+ For long suites where you want to clean up after every test, pass
71
+ `purgePerTest: true`:
72
+
73
+ ```ts
74
+ export const test = base.extend(withTestData({ purgePerTest: true }));
75
+ ```
76
+
77
+ This invokes `purgeRunInserts` in the fixture's `afterEach` instead of
78
+ deferring to the verify-final-step. Most suites should leave it off — the
79
+ verify step is the canonical sweep and avoids per-test overhead.
80
+
81
+ ### Verify-run id
82
+
83
+ The fixture reads `process.env.GSD_T_VERIFY_RUN_ID` and uses it as the
84
+ ledger `runId`. `gsd-t-verify` sets this at the top of the verify run as
85
+ `verify-${MILESTONE}-$(date -u +%Y%m%dT%H%M%SZ)`. For local development
86
+ runs (where the env var is absent), the fixture falls back to
87
+ `local-${randomUUID()}` so the ledger is still coherent.
88
+
89
+ ### What this does NOT do
90
+
91
+ - It does NOT enforce that every test uses the fixture — the gate works by
92
+ finding orphans, not by lint. A test that bypasses the fixture and inserts
93
+ data the gate doesn't know about will leave that data behind.
94
+ - It does NOT clean up data inserted before the suite started — that's a
95
+ pre-existing data hygiene concern.
96
+ - It does NOT roll back database transactions. Use it for additive inserts
97
+ (rows, keys, files); for transactional cleanup, use your store's native
98
+ rollback.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * GSD-T M58 — Playwright Test Data fixture
3
+ *
4
+ * Auto-registers test data inserts with the GSD-T ledger so the Test Data
5
+ * Cleanup Gate (gsd-t-verify Step 4.5) can purge them after the suite.
6
+ *
7
+ * Usage:
8
+ *
9
+ * import { test as base } from '@playwright/test';
10
+ * import { withTestData } from '@tekyzinc/gsd-t/templates/test-helpers/test-data-fixture';
11
+ *
12
+ * export const test = base.extend(withTestData());
13
+ *
14
+ * test('drag idea creates new column', async ({ page, testData }) => {
15
+ * const id = testData.tag('E2E_DRAG');
16
+ * await testData.register({
17
+ * kind: 'localStorage-key-prefix',
18
+ * store: 'gsd-t-board:idea:',
19
+ * id,
20
+ * taggedPrefix: 'E2E_',
21
+ * });
22
+ * // ... UI interactions that insert {key: 'gsd-t-board:idea:' + id} ...
23
+ * });
24
+ *
25
+ * Tagging convention: `{PREFIX}_{verifyRunId}_{counter}`.
26
+ *
27
+ * Run id comes from `process.env.GSD_T_VERIFY_RUN_ID` (set by gsd-t-verify).
28
+ * If absent, the fixture falls back to a per-process UUID so local runs still
29
+ * write a coherent ledger.
30
+ */
31
+
32
+ import { randomUUID } from 'node:crypto';
33
+ import * as path from 'node:path';
34
+
35
+ // Resolve the ledger module path at runtime so this template file does not
36
+ // require build-time linkage to the published package.
37
+ function resolveLedger(): {
38
+ appendInsert: (row: LedgerRow) => { ok: boolean; ledgerPath: string };
39
+ } {
40
+ // Caller can override via env (used by synthetic suites under test/fixtures/m58-d2/).
41
+ const override = process.env.GSD_T_LEDGER_MODULE_PATH;
42
+ const modulePath = override
43
+ ? path.resolve(override)
44
+ : require.resolve('@tekyzinc/gsd-t/bin/gsd-t-test-data-ledger.cjs');
45
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
46
+ return require(modulePath);
47
+ }
48
+
49
+ type AdapterKind = 'localStorage-key-prefix' | 'file-json-array' | 'sqlite-table-where' | string;
50
+
51
+ interface LedgerRow {
52
+ projectDir: string;
53
+ runId: string;
54
+ kind: AdapterKind;
55
+ store: string;
56
+ id: string;
57
+ taggedPrefix?: string;
58
+ insertedAt?: string;
59
+ }
60
+
61
+ export interface TestDataHandle {
62
+ /**
63
+ * Compose a tagged identifier of the form `{PREFIX}_{runId}_{counter}`.
64
+ * Default prefix is `E2E_`.
65
+ */
66
+ tag(prefix?: string): string;
67
+
68
+ /**
69
+ * Record an insert in the ledger. Must be called before / immediately
70
+ * after the test inserts the row in its store so the verify-final-step
71
+ * can purge it.
72
+ */
73
+ register(opts: {
74
+ kind: AdapterKind;
75
+ store: string;
76
+ id: string;
77
+ taggedPrefix?: string;
78
+ }): Promise<void>;
79
+
80
+ /**
81
+ * The runId this fixture is writing under (read-only).
82
+ */
83
+ readonly runId: string;
84
+ }
85
+
86
+ export interface WithTestDataOptions {
87
+ /**
88
+ * Project directory (defaults to process.cwd()).
89
+ */
90
+ projectDir?: string;
91
+
92
+ /**
93
+ * Default tag prefix when `tag()` is called without one (defaults to `E2E_`).
94
+ */
95
+ defaultPrefix?: string;
96
+
97
+ /**
98
+ * Opt-in: per-test purge in `test.afterEach`. Default false — the canonical
99
+ * purge point is gsd-t-verify Step 4.5 (`gsd-t test-data --purge --run`).
100
+ */
101
+ purgePerTest?: boolean;
102
+ }
103
+
104
+ export function withTestData(opts: WithTestDataOptions = {}) {
105
+ const projectDir = opts.projectDir || process.cwd();
106
+ const defaultPrefix = opts.defaultPrefix || 'E2E_';
107
+ const runId = process.env.GSD_T_VERIFY_RUN_ID || `local-${randomUUID()}`;
108
+
109
+ // Playwright fixture factory shape: { testData: [async ({}, use) => {...}, { scope: 'test' }] }
110
+ return {
111
+ testData: [
112
+ async ({}, use: (handle: TestDataHandle) => Promise<void>) => {
113
+ let counter = 0;
114
+ const ledger = resolveLedger();
115
+ const handle: TestDataHandle = {
116
+ runId,
117
+ tag(prefix?: string) {
118
+ const p = prefix || defaultPrefix;
119
+ counter += 1;
120
+ // Normalise prefix to end with '_' so composed IDs match the
121
+ // taggedPrefix the adapter will guard against.
122
+ const base = p.endsWith('_') ? p : `${p}_`;
123
+ return `${base}${runId}_${counter}`;
124
+ },
125
+ async register({ kind, store, id, taggedPrefix }) {
126
+ const prefix = taggedPrefix || defaultPrefix;
127
+ ledger.appendInsert({
128
+ projectDir,
129
+ runId,
130
+ kind,
131
+ store,
132
+ id,
133
+ taggedPrefix: prefix,
134
+ });
135
+ },
136
+ };
137
+ await use(handle);
138
+ // Per-test purge is opt-in; canonical sweep is the verify-final-step.
139
+ if (opts.purgePerTest) {
140
+ // Lazy-load purgeRunInserts only when opted in.
141
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
142
+ const ledgerMod = require(
143
+ process.env.GSD_T_LEDGER_MODULE_PATH
144
+ ? path.resolve(process.env.GSD_T_LEDGER_MODULE_PATH)
145
+ : '@tekyzinc/gsd-t/bin/gsd-t-test-data-ledger.cjs',
146
+ );
147
+ await ledgerMod.purgeRunInserts({ projectDir, runId });
148
+ }
149
+ },
150
+ { scope: 'test' },
151
+ ],
152
+ };
153
+ }