@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.
- package/CHANGELOG.md +151 -0
- package/README.md +4 -0
- package/bin/context-budget-audit.cjs +17 -2
- package/bin/gsd-t-build-coverage.cjs +438 -0
- package/bin/gsd-t-ci-parity.cjs +500 -0
- package/bin/gsd-t-economics.cjs +37 -9
- package/bin/gsd-t-test-data-adapters/file-json-array.cjs +56 -0
- package/bin/gsd-t-test-data-adapters/localstorage-key-prefix.cjs +44 -0
- package/bin/gsd-t-test-data-adapters/sqlite-table-where.cjs +71 -0
- package/bin/gsd-t-test-data-ledger.cjs +290 -0
- package/bin/gsd-t-time-format.cjs +94 -0
- package/bin/gsd-t.js +30 -0
- package/bin/model-windows.cjs +99 -0
- package/bin/model-windows.test.cjs +75 -0
- package/bin/orchestrator.js +4 -1
- package/bin/runway-estimator.cjs +35 -5
- package/bin/token-budget.cjs +12 -3
- package/commands/gsd-t-complete-milestone.md +7 -3
- package/commands/gsd-t-help.md +21 -0
- package/commands/gsd-t-init.md +1 -1
- package/commands/gsd-t-verify.md +90 -0
- package/package.json +1 -1
- package/scripts/context-meter/transcript-parser.js +12 -2
- package/scripts/context-meter/transcript-parser.test.js +51 -4
- package/scripts/gsd-t-calibration-hook.js +8 -1
- package/scripts/gsd-t-context-meter.e2e.test.js +45 -6
- package/scripts/gsd-t-context-meter.js +17 -3
- package/scripts/gsd-t-context-meter.test.js +85 -0
- package/scripts/gsd-t-date-guard.js +26 -5
- package/scripts/gsd-t-design-review-server.js +3 -1
- package/templates/CLAUDE-global.md +37 -1
- package/templates/progress.md +6 -2
- package/templates/test-helpers/README.md +98 -0
- 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
|
|
93
|
-
//
|
|
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
|
|
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
|
-
|
|
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`),
|
|
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
|
```
|
package/templates/progress.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
## Project: {Project Name}
|
|
4
4
|
## Version: 0.1.0
|
|
5
5
|
## Status: READY
|
|
6
|
-
## 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
|
-
| {
|
|
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
|
+
}
|