@keber/qa-framework 1.11.3 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +2 -2
  2. package/docs/architecture.md +30 -26
  3. package/docs/decisions/lane-heartbeat-caller.md +86 -0
  4. package/docs/final-report.md +23 -23
  5. package/docs/folder-structure-guide.md +100 -80
  6. package/docs/installation.md +35 -7
  7. package/docs/skills-architecture.md +23 -23
  8. package/docs/spec-driven-philosophy.md +9 -9
  9. package/docs/usage-with-agent.md +83 -9
  10. package/docs/visuals/diag-01-ejecutivo-1.png +0 -0
  11. package/docs/visuals/diag-02-tactico-1.png +0 -0
  12. package/docs/visuals/diag-03-tecnico-1.png +0 -0
  13. package/docs/visuals/info-01-ejecutivo-1.png +0 -0
  14. package/docs/visuals/info-02-tactico-1.png +0 -0
  15. package/docs/visuals/info-03-tecnico-1.png +0 -0
  16. package/docs/visuals/pres-01-ejecutivo-1.png +0 -0
  17. package/docs/visuals/pres-02-tactico-1.png +0 -0
  18. package/docs/visuals/pres-03-tecnico-1.png +0 -0
  19. package/docs/visuals/qa-01-problema-1.png +0 -0
  20. package/docs/visuals/qa-02-que-es-1.png +0 -0
  21. package/docs/visuals/qa-03-pipeline-1.png +0 -0
  22. package/docs/visuals/qa-04-6-archivos-1.png +0 -0
  23. package/docs/visuals/qa-05-humano-1.png +0 -0
  24. package/docs/visuals/qa-06-agente-1.png +0 -0
  25. package/docs/visuals/qa-07-skills-arch-1.png +0 -0
  26. package/docs/visuals/qa-08-traceability-1.png +0 -0
  27. package/docs/visuals/qa-en-01-problem-1.png +0 -0
  28. package/docs/visuals/qa-en-02-what-is-1.png +0 -0
  29. package/docs/visuals/qa-en-03-pipeline-1.png +0 -0
  30. package/docs/visuals/qa-en-04-6files-1.png +0 -0
  31. package/docs/visuals/qa-en-05-human-1.png +0 -0
  32. package/docs/visuals/qa-en-06-agent-1.png +0 -0
  33. package/docs/visuals/qa-en-07-skills-arch-1.png +0 -0
  34. package/docs/visuals/qa-en-08-traceability-1.png +0 -0
  35. package/examples/module-example/suppliers/00-inventory.md +1 -1
  36. package/examples/module-example/suppliers/suppliers-create.spec.ts +8 -8
  37. package/integrations/ado-powershell/README.md +1 -1
  38. package/integrations/ado-powershell/scripts/sync-ado-titles.ps1 +2 -2
  39. package/integrations/playwright/README.md +4 -4
  40. package/package.json +7 -3
  41. package/qa-framework.config.json +23 -2
  42. package/scripts/check-forbidden-chars.js +187 -0
  43. package/scripts/check-spanish-accents.js +159 -0
  44. package/scripts/cli.js +1 -1
  45. package/scripts/generate.js +2 -2
  46. package/scripts/init.js +55 -7
  47. package/scripts/lib/claude-agents.js +78 -0
  48. package/scripts/lib/claude-commands.js +56 -0
  49. package/scripts/sync-version.js +36 -0
  50. package/scripts/upgrade.js +356 -20
  51. package/scripts/validate.js +17 -17
  52. package/skills/qa-ado-integration/SKILL.md +19 -19
  53. package/skills/qa-ado-integration/references/scripts-and-config.md +6 -6
  54. package/skills/qa-automation/SKILL.md +49 -27
  55. package/skills/qa-automation/references/config-checklist.md +4 -4
  56. package/skills/qa-automation/references/dom-inspection-template.js +7 -7
  57. package/skills/qa-automation/references/patterns.md +20 -20
  58. package/skills/qa-automation/references/pom-template.md +161 -0
  59. package/skills/qa-maintenance/SKILL.md +11 -11
  60. package/skills/qa-maintenance/references/update-rules.md +8 -8
  61. package/skills/qa-module-analysis/SKILL.md +20 -19
  62. package/skills/qa-module-analysis/references/exploration-checklist.md +4 -4
  63. package/skills/qa-module-analysis/references/output-verification.md +69 -0
  64. package/skills/qa-module-analysis/references/spec-file-formats.md +14 -14
  65. package/skills/qa-spec-generation/SKILL.md +13 -7
  66. package/skills/qa-test-cases/SKILL.md +11 -81
  67. package/skills/qa-test-cases/references/test-case-template.md +5 -5
  68. package/skills/qa-test-plan/SKILL.md +22 -86
  69. package/skills/qa-test-plan/references/plan-de-pruebas-template.md +24 -24
  70. package/skills/qa-test-stabilization/SKILL.md +59 -30
  71. package/skills/qa-test-stabilization/references/classification-protocol.md +35 -35
  72. package/templates/agent-next-steps.md +10 -10
  73. package/templates/agents/qa-analisis.md +142 -0
  74. package/templates/agents/qa-asesoria.md +48 -0
  75. package/templates/agents/qa-informe-resultados.md +234 -0
  76. package/templates/agents/qa-plan.md +131 -0
  77. package/templates/automation-scaffold/.env.example +36 -6
  78. package/templates/automation-scaffold/fixtures/auth.ts +27 -5
  79. package/templates/automation-scaffold/fixtures/base.ts +23 -1
  80. package/templates/automation-scaffold/fixtures/test-helpers.ts +1 -1
  81. package/templates/automation-scaffold/global-setup.ts +117 -48
  82. package/templates/automation-scaffold/playwright.config.ts +72 -32
  83. package/templates/automation-scaffold/scripts/global-setup-guards.js +184 -0
  84. package/templates/automation-scaffold/scripts/lane-config.js +269 -0
  85. package/templates/automation-scaffold/scripts/lane-lock.js +589 -0
  86. package/templates/defect-report.md +17 -3
  87. package/templates/execution-report.md +9 -9
  88. package/templates/integration-scaffold/.env.example +1 -1
  89. package/templates/integration-scaffold/global-setup.ts +21 -1
  90. package/templates/integration-scaffold/playwright.config.ts +15 -1
  91. package/templates/qa-framework.instructions.md +8 -7
  92. package/templates/qa-framework.rules.md +53 -0
  93. package/templates/qa-readme.md +8 -8
  94. package/templates/session-summary.md +2 -2
  95. package/templates/specification/00-inventory.md +3 -3
  96. package/templates/specification/01-business-rules.md +6 -6
  97. package/templates/specification/02-workflows.md +1 -1
  98. package/templates/specification/03-roles-permissions.md +1 -1
  99. package/templates/specification/04-test-data.md +8 -8
  100. package/templates/specification/05-test-scenarios.md +5 -5
  101. package/templates/test-case.md +3 -3
  102. package/templates/test-data-guidelines.md +7 -7
  103. package/templates/test-plan-sprint.md +13 -13
  104. package/templates/test-plan.md +3 -3
  105. package/skills/qa-test-plan/references/priority-and-feasibility.md +0 -159
@@ -0,0 +1,589 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Lane lock manager for the parallel QA lanes declared in qa-framework.config.json
6
+ * (parallelLanes.lanes). The lane TABLE comes from lane-config.js; this file owns the
7
+ * lane STATE - which lanes are currently held, by whom, and since when.
8
+ *
9
+ * WHY THIS EXISTS: the applications under test invalidate a session when the same
10
+ * account logs in again. Two workers sharing one QA account silently destroy each
11
+ * other's session, and the damage shows up later as an unexplained logout or, worse,
12
+ * as evidence recorded under the wrong account. The lock is what guarantees one
13
+ * worker per account.
14
+ *
15
+ * State lives in <locks-dir>/lanes.json, guarded by a mkdir-based mutex
16
+ * (<locks-dir>/.mutex/), so concurrent acquire/release calls never race on the file.
17
+ * mkdir is used because it is atomic on every platform this runs on, needs no
18
+ * dependency, and leaves a visible artifact if a process dies mid-hold.
19
+ *
20
+ * LOCK DIRECTORY (QA_LANE_LOCKS_DIR), three sources in precedence order:
21
+ * 1. shell environment
22
+ * 2. the QA_LANE_LOCKS_DIR key in .env
23
+ * 3. per-checkout default (<scripts>/../.locks)
24
+ *
25
+ * The default preserves single-checkout behavior exactly, so this is not a breaking
26
+ * change. Setting it matters when sessions run from separate git worktrees: each
27
+ * worktree is an independent filesystem, so with the per-checkout default each one
28
+ * gets its OWN .locks/lanes.json and they never contend. Every status reports free
29
+ * lanes, every acquire succeeds, and the collision only surfaces later as an
30
+ * invalidated session. The failure is silent and looks exactly like success.
31
+ *
32
+ * That is why every CLI invocation logs the effective lock directory AND which of the
33
+ * three sources it came from to stderr. Given a silent failure mode, being able to
34
+ * answer "which lock directory is this session actually using" from the outside is
35
+ * part of the fix, not a nicety.
36
+ *
37
+ * The .env value is read with dotenv.parse(), NOT dotenv.config(). parse() returns a
38
+ * plain object and never touches process.env, so this script only ever sees this one
39
+ * key. config() would inject the WHOLE .env - every QA account password and any ADO
40
+ * token living in it - into a process whose only job is deciding where to write a
41
+ * JSON state file, where any dependency, crash dump or child process inherits them.
42
+ * Do not "simplify" this back to config(); it looks like harmless cleanup and is not.
43
+ *
44
+ * Usage:
45
+ * node lane-lock.js acquire "<label>"
46
+ * node lane-lock.js acquire "<label>" --require-capability mcp
47
+ * node lane-lock.js reserve <laneId> "<label>"
48
+ * node lane-lock.js release <lane>
49
+ * node lane-lock.js force-release <lane>
50
+ * node lane-lock.js heartbeat <lane>
51
+ * node lane-lock.js status
52
+ *
53
+ * acquire vs reserve (do not collapse them into one verb):
54
+ * - `acquire` picks a RANDOM free lane and queues when none are free. Correct for
55
+ * dispatching a worker that does not care which lane it gets.
56
+ * - `reserve <laneId>` takes an EXACT lane id, has NO queue, and throws hard if
57
+ * that lane is busy. Correct for a CI job or a local `playwright test` run, which
58
+ * needs a specific predictable lane matching its QA_LANE_ONLY and storageState.
59
+ * Collapsing them is how a CI job ends up on a random lane.
60
+ *
61
+ * HEARTBEAT: a held lane is swept as stale only if it has been held past the
62
+ * threshold AND (it never sent a heartbeat OR its last heartbeat is itself past the
63
+ * threshold). A recent heartbeat proves the holder is alive, so such a lane is never
64
+ * swept regardless of how old the acquisition is. Per docs/decisions/
65
+ * lane-heartbeat-caller.md the DISPATCHER calls heartbeat from its own wait loop -
66
+ * the executor is not required to and must not be relied upon to. A liveness
67
+ * protocol that depends on an uninstructed party is not a control.
68
+ */
69
+
70
+ const fs = require('fs');
71
+ const path = require('path');
72
+
73
+ const laneConfig = require('./lane-config.js');
74
+
75
+ // Minimal single-key reader used when dotenv is not installed.
76
+ //
77
+ // dotenv is a devDependency of the generated project, so `node scripts/lane-lock.js`
78
+ // can legitimately run before `npm install`. Silently ignoring .env in that case
79
+ // would be the worst option available: the lock directory would quietly fall back to
80
+ // the per-checkout default while the operator believes the shared one is in use,
81
+ // which is exactly the silent zero-contention failure this whole mechanism exists to
82
+ // prevent. So parse the one key by hand instead. It handles the forms dotenv does
83
+ // that matter here (comments, blank lines, surrounding quotes, `export ` prefix) and
84
+ // deliberately nothing more.
85
+ function parseSingleEnvKey(contents, key) {
86
+ for (const rawLine of String(contents).split(/\r?\n/)) {
87
+ const line = rawLine.trim();
88
+ if (!line || line.startsWith('#')) continue;
89
+ const withoutExport = line.startsWith('export ') ? line.slice('export '.length).trim() : line;
90
+ const eq = withoutExport.indexOf('=');
91
+ if (eq === -1) continue;
92
+ if (withoutExport.slice(0, eq).trim() !== key) continue;
93
+ let value = withoutExport.slice(eq + 1).trim();
94
+ if (
95
+ (value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
96
+ (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
97
+ ) {
98
+ value = value.slice(1, -1);
99
+ }
100
+ return value || undefined;
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ // Read ONLY this one key. See the dotenv note in the header comment above; this is a
106
+ // least-privilege decision, not a style choice. dotenv.parse() returns a plain object
107
+ // and never touches process.env, so no other key in .env is ever loaded.
108
+ function readLocksDirFromEnvFile() {
109
+ const envPath = path.join(__dirname, '..', '.env');
110
+ if (!fs.existsSync(envPath)) return undefined;
111
+ const contents = fs.readFileSync(envPath);
112
+ try {
113
+ return require('dotenv').parse(contents).QA_LANE_LOCKS_DIR;
114
+ } catch (err) {
115
+ if (err && err.code === 'MODULE_NOT_FOUND') return parseSingleEnvKey(contents, 'QA_LANE_LOCKS_DIR');
116
+ throw err;
117
+ }
118
+ }
119
+
120
+ function resolveLocksDir() {
121
+ if (process.env.QA_LANE_LOCKS_DIR) {
122
+ return { dir: path.resolve(process.env.QA_LANE_LOCKS_DIR), source: 'shell' };
123
+ }
124
+ const fromEnvFile = readLocksDirFromEnvFile();
125
+ if (fromEnvFile) {
126
+ return { dir: path.resolve(fromEnvFile), source: 'env-file' };
127
+ }
128
+ return { dir: path.join(__dirname, '..', '.locks'), source: 'default' };
129
+ }
130
+
131
+ const LOCKS = resolveLocksDir();
132
+ const LOCKS_DIR = LOCKS.dir;
133
+ const LOCKS_DIR_SOURCE = LOCKS.source;
134
+ const STATE_FILE = path.join(LOCKS_DIR, 'lanes.json');
135
+ const MUTEX_DIR = path.join(LOCKS_DIR, '.mutex');
136
+ const STALE_MINUTES = Number(process.env.QA_LANE_STALE_MINUTES || 20);
137
+
138
+ const LANE_INFO = laneConfig.loadLaneTable();
139
+
140
+ // The queue is capped at the lane count: a queue longer than the number of lanes
141
+ // means work is piling up faster than lanes can drain it, and the correct answer is
142
+ // to reject and let the caller back off, never to force a lane assignment past the
143
+ // cap.
144
+ const MAX_QUEUE = Object.keys(LANE_INFO).length;
145
+
146
+ function freeLane() {
147
+ return { status: 'free', holder: null, acquired_at: null, label: null, last_heartbeat: null };
148
+ }
149
+
150
+ function defaultState() {
151
+ const lanes = {};
152
+ for (const id of Object.keys(LANE_INFO)) lanes[id] = freeLane();
153
+ return { lanes, queue: [] };
154
+ }
155
+
156
+ function loadState() {
157
+ if (!fs.existsSync(STATE_FILE)) return defaultState();
158
+ let state;
159
+ try {
160
+ state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
161
+ } catch {
162
+ return defaultState();
163
+ }
164
+ if (!state || typeof state !== 'object') return defaultState();
165
+ if (!state.lanes || typeof state.lanes !== 'object') state.lanes = {};
166
+ if (!Array.isArray(state.queue)) state.queue = [];
167
+
168
+ // Backfill lanes added to the config since this lanes.json was written, and any
169
+ // lane missing last_heartbeat. Both loops only ADD - they never reset a lane that
170
+ // is already busy from a live session, because doing so would free an account
171
+ // someone is actively driving.
172
+ for (const id of Object.keys(LANE_INFO)) {
173
+ if (!state.lanes[id]) state.lanes[id] = freeLane();
174
+ }
175
+ for (const id of Object.keys(state.lanes)) {
176
+ if (!('last_heartbeat' in state.lanes[id])) state.lanes[id].last_heartbeat = null;
177
+ }
178
+ return state;
179
+ }
180
+
181
+ function saveState(state) {
182
+ fs.mkdirSync(LOCKS_DIR, { recursive: true });
183
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
184
+ }
185
+
186
+ function sleepSync(ms) {
187
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
188
+ }
189
+
190
+ function acquireMutex() {
191
+ fs.mkdirSync(LOCKS_DIR, { recursive: true });
192
+ const maxTries = 50; // ~5s worst case
193
+ for (let i = 0; i < maxTries; i++) {
194
+ try {
195
+ fs.mkdirSync(MUTEX_DIR);
196
+ return;
197
+ } catch (err) {
198
+ if (err.code !== 'EEXIST') throw err;
199
+ sleepSync(100);
200
+ }
201
+ }
202
+ throw new Error(
203
+ `[lane-lock] Could not take the lanes.json mutex at ${MUTEX_DIR} after 5s. ` +
204
+ 'If no other lane-lock process is running, remove that directory and retry.'
205
+ );
206
+ }
207
+
208
+ function releaseMutex() {
209
+ try { fs.rmdirSync(MUTEX_DIR); } catch { /* already released */ }
210
+ }
211
+
212
+ function withMutex(fn) {
213
+ acquireMutex();
214
+ try {
215
+ return fn();
216
+ } finally {
217
+ releaseMutex();
218
+ }
219
+ }
220
+
221
+ function genRequestId() {
222
+ return 'req-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6);
223
+ }
224
+
225
+ function laneIdForQaLaneOnly(qaLaneOnly) {
226
+ return laneConfig.laneIdForQaLaneOnly(qaLaneOnly, LANE_INFO);
227
+ }
228
+
229
+ function storageStateForLaneOnly(qaLaneOnly) {
230
+ return laneConfig.storageStateForLaneOnly(qaLaneOnly, LANE_INFO);
231
+ }
232
+
233
+ /**
234
+ * Composite staleness predicate. A busy lane is stale only when it has been held for
235
+ * >= STALE_MINUTES AND it either never sent a heartbeat OR its last heartbeat is
236
+ * itself >= STALE_MINUTES old.
237
+ *
238
+ * Age alone is a bad liveness signal: a lane legitimately held for a long-running
239
+ * investigation looks identical to one abandoned by a crashed process. The heartbeat
240
+ * is what distinguishes them, so a lane with a recent heartbeat is never stale no
241
+ * matter how old the acquisition is.
242
+ */
243
+ function isLaneStale(laneState, now, staleMinutes) {
244
+ const threshold = staleMinutes === undefined ? STALE_MINUTES : staleMinutes;
245
+ if (!laneState || laneState.status !== 'busy' || !laneState.acquired_at) return false;
246
+ const ageMinutes = (now - new Date(laneState.acquired_at).getTime()) / 60000;
247
+ if (ageMinutes < threshold) return false;
248
+ if (laneState.last_heartbeat === null || laneState.last_heartbeat === undefined) return true;
249
+ const heartbeatAgeMinutes = (now - new Date(laneState.last_heartbeat).getTime()) / 60000;
250
+ return heartbeatAgeMinutes >= threshold;
251
+ }
252
+
253
+ function promoteQueueInto(state, laneId) {
254
+ if (state.queue.length === 0) return null;
255
+ const next = state.queue.shift();
256
+ state.lanes[laneId] = {
257
+ status: 'busy',
258
+ holder: next.request_id,
259
+ acquired_at: new Date().toISOString(),
260
+ label: next.label,
261
+ last_heartbeat: null,
262
+ };
263
+ return next;
264
+ }
265
+
266
+ /**
267
+ * Frees every stale lane (composite predicate above), promotes the queue into each,
268
+ * and appends one JSON line per freed lane to <locks-dir>/stale-lane-alerts.log.
269
+ *
270
+ * The log is not optional: a swept lane means the mechanism overrode a holder that
271
+ * may still have been alive, which is exactly the situation that needs an audit trail
272
+ * after the fact. Mutates and returns `state`; callers save it (called lazily inside
273
+ * withMutex at the start of acquire/reserve/status).
274
+ */
275
+ function sweepStale(state) {
276
+ const now = Date.now();
277
+ const released = [];
278
+
279
+ for (const laneId of Object.keys(state.lanes)) {
280
+ const laneState = state.lanes[laneId];
281
+ if (!isLaneStale(laneState, now)) continue;
282
+
283
+ released.push({
284
+ timestamp: new Date().toISOString(),
285
+ lane: laneId,
286
+ holder: laneState.holder,
287
+ label: laneState.label,
288
+ acquired_at: laneState.acquired_at,
289
+ last_heartbeat: laneState.last_heartbeat,
290
+ });
291
+
292
+ state.lanes[laneId] = freeLane();
293
+ promoteQueueInto(state, laneId);
294
+ }
295
+
296
+ if (released.length > 0) {
297
+ fs.mkdirSync(LOCKS_DIR, { recursive: true });
298
+ const logFile = path.join(LOCKS_DIR, 'stale-lane-alerts.log');
299
+ fs.appendFileSync(logFile, released.map((r) => JSON.stringify(r)).join('\n') + '\n', 'utf8');
300
+ }
301
+
302
+ return state;
303
+ }
304
+
305
+ /**
306
+ * Takes a RANDOM free lane, queueing when none are free and rejecting when the queue
307
+ * is at the cap.
308
+ *
309
+ * `requireCapability` narrows the candidate lanes to those declaring it. This is the
310
+ * guard for the "6 accounts but only 3 MCP servers" case: without it, acquire happily
311
+ * hands out a CLI-only lane to a consumer that needs MCP tools, which fails later and
312
+ * confusingly inside the consumer.
313
+ */
314
+ function cmdAcquire(label, requireCapability) {
315
+ return withMutex(() => {
316
+ const state = sweepStale(loadState());
317
+
318
+ const eligible = Object.keys(state.lanes).filter((id) => {
319
+ if (!LANE_INFO[id]) return false; // in state but no longer in config
320
+ if (!requireCapability) return true;
321
+ return LANE_INFO[id].capabilities.includes(requireCapability);
322
+ });
323
+
324
+ if (eligible.length === 0) {
325
+ return {
326
+ status: 'rejected',
327
+ message: requireCapability
328
+ ? `No lane declares capability "${requireCapability}".`
329
+ : 'No lanes are declared in parallelLanes.lanes.',
330
+ };
331
+ }
332
+
333
+ const free = eligible.filter((id) => state.lanes[id].status === 'free');
334
+ if (free.length > 0) {
335
+ const chosen = free[Math.floor(Math.random() * free.length)];
336
+ const requestId = genRequestId();
337
+ state.lanes[chosen] = {
338
+ status: 'busy',
339
+ holder: requestId,
340
+ acquired_at: new Date().toISOString(),
341
+ label: label || null,
342
+ last_heartbeat: null,
343
+ };
344
+ saveState(state);
345
+ return { status: 'acquired', lane: chosen, request_id: requestId, ...LANE_INFO[chosen] };
346
+ }
347
+
348
+ if (state.queue.length < MAX_QUEUE) {
349
+ const requestId = genRequestId();
350
+ state.queue.push({
351
+ request_id: requestId,
352
+ requested_at: new Date().toISOString(),
353
+ label: label || null,
354
+ require_capability: requireCapability || null,
355
+ });
356
+ saveState(state);
357
+ return { status: 'queued', request_id: requestId, position: state.queue.length };
358
+ }
359
+
360
+ return {
361
+ status: 'rejected',
362
+ message:
363
+ `No free lane and the queue is already at its cap of ${MAX_QUEUE}. Retry later. ` +
364
+ 'Never force a lane assignment past the cap: that puts two workers on one account.',
365
+ };
366
+ });
367
+ }
368
+
369
+ /**
370
+ * Reserves an EXACT lane id. No random pick, no queue, throws hard if the lane is
371
+ * busy or unknown. Callers needing a specific predictable lane (local CLI runs, CI
372
+ * jobs) must use this instead of acquire.
373
+ */
374
+ function cmdReserve(laneId, label) {
375
+ return withMutex(() => {
376
+ const state = sweepStale(loadState());
377
+ if (!state.lanes[laneId] || !LANE_INFO[laneId]) {
378
+ throw new Error(
379
+ `[lane-lock] Unknown lane: ${laneId}. Declared lanes: ${Object.keys(LANE_INFO).join(', ')}.`
380
+ );
381
+ }
382
+ const laneState = state.lanes[laneId];
383
+ if (laneState.status === 'busy') {
384
+ throw new Error(
385
+ `[lane-lock] Lane ${laneId} is already busy (holder: ${laneState.holder}, label: ${laneState.label}). ` +
386
+ 'reserve does not queue - use acquire if you need to wait for a free lane.'
387
+ );
388
+ }
389
+
390
+ const requestId = genRequestId();
391
+ state.lanes[laneId] = {
392
+ status: 'busy',
393
+ holder: requestId,
394
+ acquired_at: new Date().toISOString(),
395
+ label: label || null,
396
+ last_heartbeat: null,
397
+ };
398
+ saveState(state);
399
+ return { status: 'reserved', lane: laneId, request_id: requestId, ...LANE_INFO[laneId] };
400
+ });
401
+ }
402
+
403
+ function cmdRelease(lane) {
404
+ return withMutex(() => {
405
+ const state = loadState();
406
+ if (!state.lanes[lane]) {
407
+ throw new Error(
408
+ `[lane-lock] Unknown lane: ${lane}. Declared lanes: ${Object.keys(LANE_INFO).join(', ')}.`
409
+ );
410
+ }
411
+
412
+ state.lanes[lane] = freeLane();
413
+ const promoted = promoteQueueInto(state, lane);
414
+ saveState(state);
415
+
416
+ return {
417
+ status: 'released',
418
+ lane,
419
+ promoted,
420
+ ...(promoted ? { promoted_lane_info: LANE_INFO[lane] } : {}),
421
+ };
422
+ });
423
+ }
424
+
425
+ /**
426
+ * Refreshes last_heartbeat on a busy lane, proving the holder is still alive.
427
+ * Throws on an unknown or free lane: a heartbeat on a lane you do not hold is a bug
428
+ * in the caller, not something to swallow.
429
+ */
430
+ function cmdHeartbeat(lane) {
431
+ return withMutex(() => {
432
+ const state = loadState();
433
+ if (!state.lanes[lane]) {
434
+ throw new Error(
435
+ `[lane-lock] Unknown lane: ${lane}. Declared lanes: ${Object.keys(LANE_INFO).join(', ')}.`
436
+ );
437
+ }
438
+ if (state.lanes[lane].status !== 'busy') {
439
+ throw new Error(
440
+ `[lane-lock] Lane ${lane} is free - cannot heartbeat a lane that is not reserved or acquired.`
441
+ );
442
+ }
443
+ state.lanes[lane].last_heartbeat = new Date().toISOString();
444
+ saveState(state);
445
+ return { status: 'heartbeat-ok', lane, last_heartbeat: state.lanes[lane].last_heartbeat };
446
+ });
447
+ }
448
+
449
+ function cmdStatus() {
450
+ return withMutex(() => {
451
+ const state = sweepStale(loadState());
452
+ saveState(state);
453
+ const now = Date.now();
454
+ const lanes = Object.keys(state.lanes).map((id) => {
455
+ const l = state.lanes[id];
456
+ const info = LANE_INFO[id] || {};
457
+ return {
458
+ lane: id,
459
+ account: info.account || null,
460
+ storageState: info.storageState || null,
461
+ mcpNamespace: info.mcpNamespace || null,
462
+ runnerProject: info.runnerProject || null,
463
+ capabilities: info.capabilities || [],
464
+ status: l.status,
465
+ holder: l.holder,
466
+ label: l.label,
467
+ age_minutes: l.acquired_at ? Math.round((now - new Date(l.acquired_at).getTime()) / 60000) : null,
468
+ last_heartbeat: l.last_heartbeat,
469
+ stale: isLaneStale(l, now),
470
+ };
471
+ });
472
+ return { locks_dir: LOCKS_DIR, locks_dir_source: LOCKS_DIR_SOURCE, lanes, queue: state.queue };
473
+ });
474
+ }
475
+
476
+ // Part of the fix, not a nicety: the underlying misconfiguration (each worktree
477
+ // silently using its own lock directory) produces no error at all, so the only way to
478
+ // tell a correctly configured session from a broken one is to state the answer.
479
+ function logLocksDir() {
480
+ const explain = {
481
+ shell: 'QA_LANE_LOCKS_DIR set in shell environment',
482
+ 'env-file': 'QA_LANE_LOCKS_DIR read from .env',
483
+ default: 'QA_LANE_LOCKS_DIR not set in shell environment or .env, using per-checkout default',
484
+ };
485
+ console.error(`[lane-lock] locks dir: ${LOCKS_DIR} (source: ${explain[LOCKS_DIR_SOURCE]})`);
486
+ }
487
+
488
+ function parseCapabilityFlag(args) {
489
+ const rest = [];
490
+ let capability = null;
491
+ for (let i = 0; i < args.length; i++) {
492
+ if (args[i] === '--require-capability') {
493
+ capability = args[i + 1];
494
+ if (!capability) throw new Error('[lane-lock] --require-capability needs a value.');
495
+ i++;
496
+ continue;
497
+ }
498
+ if (args[i].startsWith('--require-capability=')) {
499
+ capability = args[i].slice('--require-capability='.length);
500
+ if (!capability) throw new Error('[lane-lock] --require-capability needs a value.');
501
+ continue;
502
+ }
503
+ rest.push(args[i]);
504
+ }
505
+ return { capability, rest };
506
+ }
507
+
508
+ function main() {
509
+ logLocksDir();
510
+ const [, , cmd, ...rawArgs] = process.argv;
511
+ const { capability, rest: args } = parseCapabilityFlag(rawArgs);
512
+ let result;
513
+
514
+ switch (cmd) {
515
+ case 'acquire':
516
+ result = cmdAcquire(args.join(' '), capability);
517
+ break;
518
+ case 'reserve':
519
+ if (!args[0]) throw new Error('[lane-lock] Usage: lane-lock.js reserve <laneId> "<label>"');
520
+ result = cmdReserve(args[0], args.slice(1).join(' '));
521
+ break;
522
+ case 'release':
523
+ case 'force-release':
524
+ if (!args[0]) throw new Error('[lane-lock] Usage: lane-lock.js release <lane>');
525
+ result = cmdRelease(args[0]);
526
+ break;
527
+ case 'heartbeat':
528
+ if (!args[0]) throw new Error('[lane-lock] Usage: lane-lock.js heartbeat <lane>');
529
+ result = cmdHeartbeat(args[0]);
530
+ break;
531
+ case 'status':
532
+ result = cmdStatus();
533
+ break;
534
+ default:
535
+ console.error(
536
+ 'Commands: acquire <label> [--require-capability <cap>] | reserve <laneId> <label> |\n' +
537
+ ' release <lane> | force-release <lane> | heartbeat <lane> | status\n' +
538
+ ' acquire - random free lane, queues when none are free. For workers that do not\n' +
539
+ ' care which lane they get.\n' +
540
+ ' reserve - exact lane id, no queue, throws if busy. For CI jobs and local CLI runs\n' +
541
+ ' that need a specific predictable lane.\n' +
542
+ ' release - free a lane (promotes the next queued request if any).\n' +
543
+ ' heartbeat - refresh last_heartbeat on a lane you currently hold. The dispatcher\n' +
544
+ ' calls this from its wait loop, not the executor.\n' +
545
+ ' status - snapshot of all lanes and the queue (also sweeps stale lanes).'
546
+ );
547
+ process.exit(1);
548
+ return;
549
+ }
550
+ console.log(JSON.stringify(result, null, 2));
551
+ }
552
+
553
+ if (require.main === module) {
554
+ try {
555
+ main();
556
+ } catch (err) {
557
+ console.error(err.message);
558
+ process.exit(1);
559
+ }
560
+ }
561
+
562
+ module.exports = {
563
+ LANE_INFO,
564
+ LOCKS_DIR,
565
+ LOCKS_DIR_SOURCE,
566
+ STATE_FILE,
567
+ STALE_MINUTES,
568
+ MAX_QUEUE,
569
+ defaultState,
570
+ loadState,
571
+ saveState,
572
+ withMutex,
573
+ acquireMutex,
574
+ releaseMutex,
575
+ genRequestId,
576
+ laneIdForQaLaneOnly,
577
+ storageStateForLaneOnly,
578
+ isLaneStale,
579
+ sweepStale,
580
+ cmdAcquire,
581
+ cmdReserve,
582
+ cmdRelease,
583
+ cmdHeartbeat,
584
+ cmdStatus,
585
+ parseCapabilityFlag,
586
+ parseSingleEnvKey,
587
+ resolveLocksDir,
588
+ readLocksDirFromEnvFile,
589
+ };
@@ -1,8 +1,8 @@
1
- # Bug Report — DEF-{{NNN}}: {{Short Title}}
1
+ # Bug Report - DEF-{{PREFIX}}-{{NNN}}: {{Short Title}}
2
2
 
3
3
  | Field | Value |
4
4
  |-------|-------|
5
- | Bug ID | DEF-{{NNN}} |
5
+ | Bug ID | DEF-{{PREFIX}}-{{NNN}} |
6
6
  | Title | {{Short title (max 80 chars)}} |
7
7
  | Severity | Critical / High / Medium / Low |
8
8
  | Priority | P0 / P1 / P2 / P3 |
@@ -18,6 +18,20 @@
18
18
 
19
19
  ---
20
20
 
21
+ ## Known-issues check
22
+
23
+ > Filled in **before** this file is created. See the blocking step in the
24
+ > `qa-test-stabilization` skill. A defect filed without this section is not ready.
25
+
26
+ | Field | Value |
27
+ |-------|-------|
28
+ | Source consulted | {{tracker query / known-issues file / both}} |
29
+ | Terms searched | {{symptom terms, not the TC ID}} |
30
+ | Matches found | {{item IDs and their state, or "none"}} |
31
+ | Classification | New defect / Regression of {{ID}} / Duplicate of {{ID}} |
32
+
33
+ ---
34
+
21
35
  ## Description
22
36
 
23
37
  {{1-2 sentences describing what the bug is and where it occurs}}
@@ -76,7 +90,7 @@
76
90
  **Test skip command added**:
77
91
  ```typescript
78
92
  test.skip(true,
79
- 'DEF-{{NNN}}: {{description}}. Reactivate when ADO #{{WI_ID}} is resolved.'
93
+ 'DEF-{{PREFIX}}-{{NNN}}: {{description}}. Reactivate when ADO #{{WI_ID}} is resolved.'
80
94
  );
81
95
  ```
82
96
 
@@ -1,10 +1,10 @@
1
- # Execution Report — {{MODULE_NAME}}
1
+ # Execution Report - {{MODULE_NAME}}
2
2
 
3
3
  **Report ID**: EXEC-{{MODULE}}-{{YYYYMMDD}}-{{NNN}}
4
4
  **Sprint / Release**: Sprint {{NNN}}
5
5
  **Execution type**: Automated / Manual / Mixed
6
6
  **Executor**: (agent name or analyst name)
7
- **Environment**: QA — `{{QA_BASE_URL}}`
7
+ **Environment**: QA - `{{QA_BASE_URL}}`
8
8
  **Date**: YYYY-MM-DD HH:MM UTC
9
9
  **Playwright version**: {{version}} (if automated)
10
10
  **ADO Test Plan**: #{ADO_PLAN_ID} (if ADO enabled)
@@ -40,13 +40,13 @@
40
40
  |-------|-------|----------|--------|----------|-------|
41
41
  | TC-{{M}}-{{S}}-001 | {{title}} | P0 | ✅ Pass | {{Xs}} | |
42
42
  | TC-{{M}}-{{S}}-002 | {{title}} | P0 | ❌ Fail | {{Xs}} | DEF-{{NNN}} opened |
43
- | TC-{{M}}-{{S}}-003 | {{title}} | P1 | ⏭️ Skip | — | DEF-{{NNN}} blocks |
43
+ | TC-{{M}}-{{S}}-003 | {{title}} | P1 | ⏭️ Skip | - | DEF-{{NNN}} blocks |
44
44
 
45
45
  ---
46
46
 
47
47
  ## Failures Detail
48
48
 
49
- ### TC-{{M}}-{{S}}-{{NNN}} — {{Title}}
49
+ ### TC-{{M}}-{{S}}-{{NNN}} - {{Title}}
50
50
 
51
51
  **Error**:
52
52
  ```
@@ -71,7 +71,7 @@
71
71
 
72
72
  | TC-ID | Reason |
73
73
  |-------|--------|
74
- | TC-{{M}}-{{S}}-{{NNN}} | DEF-{{NNN}} — {{short description}} |
74
+ | TC-{{M}}-{{S}}-{{NNN}} | DEF-{{NNN}} - {{short description}} |
75
75
 
76
76
  ---
77
77
 
@@ -91,9 +91,9 @@ Auth state: .auth/user-{{role}}.json (not committed)
91
91
 
92
92
  ## Recommendations
93
93
 
94
- 1. **DEF-{{NNN}}**: High priority — unblocks {{N}} skipped P0 tests.
95
- 2. **Flaky risk**: TC-{{M}}-{{S}}-{{NNN}} passed on retry — investigate timing issue.
96
- 3. **Coverage gap**: {{submodule}} `{{scenario}}` has no automated coverage — manual execution required.
94
+ 1. **DEF-{{NNN}}**: High priority - unblocks {{N}} skipped P0 tests.
95
+ 2. **Flaky risk**: TC-{{M}}-{{S}}-{{NNN}} passed on retry - investigate timing issue.
96
+ 3. **Coverage gap**: {{submodule}} `{{scenario}}` has no automated coverage - manual execution required.
97
97
 
98
98
  ---
99
99
 
@@ -104,7 +104,7 @@ Auth state: .auth/user-{{role}}.json (not committed)
104
104
  | Playwright HTML report | `playwright-report/index.html` |
105
105
  | Test results JSON | `test-results/` |
106
106
  | Spec files | `qa/07-automation/e2e/{{module}}/` |
107
- | ADO sync log | (if ADO enabled — see `integrations/ado-powershell/` output) |
107
+ | ADO sync log | (if ADO enabled - see `integrations/ado-powershell/` output) |
108
108
 
109
109
  ---
110
110
 
@@ -1,4 +1,4 @@
1
- # -----------------------------------------------------------------------
1
+ # -----------------------------------------------------------------------
2
2
  # Integration test environment variables
3
3
  # -----------------------------------------------------------------------
4
4
  # Copy this file to .env and fill in real values.