@keber/qa-framework 1.11.3 → 1.13.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.
- package/CHANGELOG.md +340 -0
- package/README.md +42 -2
- package/docs/architecture.md +30 -26
- package/docs/decisions/lane-heartbeat-caller.md +86 -0
- package/docs/final-report.md +23 -23
- package/docs/folder-structure-guide.md +100 -80
- package/docs/installation.md +42 -7
- package/docs/skills-architecture.md +23 -23
- package/docs/spec-driven-philosophy.md +9 -9
- package/docs/usage-with-agent.md +88 -9
- package/docs/visuals/diag-01-ejecutivo-1.png +0 -0
- package/docs/visuals/diag-02-tactico-1.png +0 -0
- package/docs/visuals/diag-03-tecnico-1.png +0 -0
- package/docs/visuals/info-01-ejecutivo-1.png +0 -0
- package/docs/visuals/info-02-tactico-1.png +0 -0
- package/docs/visuals/info-03-tecnico-1.png +0 -0
- package/docs/visuals/pres-01-ejecutivo-1.png +0 -0
- package/docs/visuals/pres-02-tactico-1.png +0 -0
- package/docs/visuals/pres-03-tecnico-1.png +0 -0
- package/docs/visuals/qa-01-problema-1.png +0 -0
- package/docs/visuals/qa-02-que-es-1.png +0 -0
- package/docs/visuals/qa-03-pipeline-1.png +0 -0
- package/docs/visuals/qa-04-6-archivos-1.png +0 -0
- package/docs/visuals/qa-05-humano-1.png +0 -0
- package/docs/visuals/qa-06-agente-1.png +0 -0
- package/docs/visuals/qa-07-skills-arch-1.png +0 -0
- package/docs/visuals/qa-08-traceability-1.png +0 -0
- package/docs/visuals/qa-en-01-problem-1.png +0 -0
- package/docs/visuals/qa-en-02-what-is-1.png +0 -0
- package/docs/visuals/qa-en-03-pipeline-1.png +0 -0
- package/docs/visuals/qa-en-04-6files-1.png +0 -0
- package/docs/visuals/qa-en-05-human-1.png +0 -0
- package/docs/visuals/qa-en-06-agent-1.png +0 -0
- package/docs/visuals/qa-en-07-skills-arch-1.png +0 -0
- package/docs/visuals/qa-en-08-traceability-1.png +0 -0
- package/examples/module-example/suppliers/00-inventory.md +1 -1
- package/examples/module-example/suppliers/suppliers-create.spec.ts +8 -8
- package/integrations/ado-powershell/README.md +1 -1
- package/integrations/ado-powershell/scripts/sync-ado-titles.ps1 +2 -2
- package/integrations/playwright/README.md +4 -4
- package/package.json +7 -3
- package/qa-framework.config.json +25 -3
- package/scripts/check-forbidden-chars.js +187 -0
- package/scripts/check-spanish-accents.js +159 -0
- package/scripts/cli.js +1 -1
- package/scripts/generate.js +2 -2
- package/scripts/init.js +101 -12
- package/scripts/lib/claude-agents.js +347 -0
- package/scripts/lib/claude-commands.js +56 -0
- package/scripts/lib/module-keys.js +54 -0
- package/scripts/sync-version.js +63 -0
- package/scripts/upgrade.js +369 -20
- package/scripts/validate.js +46 -21
- package/skills/qa-ado-integration/SKILL.md +19 -19
- package/skills/qa-ado-integration/references/scripts-and-config.md +6 -6
- package/skills/qa-automation/SKILL.md +49 -27
- package/skills/qa-automation/references/config-checklist.md +4 -4
- package/skills/qa-automation/references/dom-inspection-template.js +7 -7
- package/skills/qa-automation/references/patterns.md +20 -20
- package/skills/qa-automation/references/pom-template.md +161 -0
- package/skills/qa-maintenance/SKILL.md +11 -11
- package/skills/qa-maintenance/references/update-rules.md +8 -8
- package/skills/qa-module-analysis/SKILL.md +20 -19
- package/skills/qa-module-analysis/references/exploration-checklist.md +4 -4
- package/skills/qa-module-analysis/references/output-verification.md +69 -0
- package/skills/qa-module-analysis/references/spec-file-formats.md +14 -14
- package/skills/qa-spec-generation/SKILL.md +13 -7
- package/skills/qa-test-cases/SKILL.md +11 -81
- package/skills/qa-test-cases/references/test-case-template.md +5 -5
- package/skills/qa-test-plan/SKILL.md +22 -86
- package/skills/qa-test-plan/references/plan-de-pruebas-template.md +24 -24
- package/skills/qa-test-stabilization/SKILL.md +59 -30
- package/skills/qa-test-stabilization/references/classification-protocol.md +35 -35
- package/templates/agent-next-steps.md +10 -10
- package/templates/agents/qa-analisis.md +142 -0
- package/templates/agents/qa-asesoria.md +48 -0
- package/templates/agents/qa-informe-resultados.md +236 -0
- package/templates/agents/qa-investigador.md +143 -0
- package/templates/agents/qa-plan.md +131 -0
- package/templates/agents/qa-reporte-ejecucion.md +138 -0
- package/templates/automation-scaffold/.env.example +36 -6
- package/templates/automation-scaffold/fixtures/auth.ts +27 -5
- package/templates/automation-scaffold/fixtures/base.ts +23 -1
- package/templates/automation-scaffold/fixtures/test-helpers.ts +1 -1
- package/templates/automation-scaffold/global-setup.ts +117 -48
- package/templates/automation-scaffold/playwright.config.ts +72 -32
- package/templates/automation-scaffold/scripts/global-setup-guards.js +184 -0
- package/templates/automation-scaffold/scripts/lane-config.js +269 -0
- package/templates/automation-scaffold/scripts/lane-lock.js +589 -0
- package/templates/defect-report.md +17 -3
- package/templates/execution-report.md +9 -9
- package/templates/integration-scaffold/.env.example +1 -1
- package/templates/integration-scaffold/global-setup.ts +21 -1
- package/templates/integration-scaffold/playwright.config.ts +15 -1
- package/templates/qa-framework.instructions.md +9 -8
- package/templates/qa-framework.rules.md +53 -0
- package/templates/qa-readme.md +8 -8
- package/templates/session-summary.md +2 -2
- package/templates/specification/00-inventory.md +3 -3
- package/templates/specification/01-business-rules.md +6 -6
- package/templates/specification/02-workflows.md +1 -1
- package/templates/specification/03-roles-permissions.md +1 -1
- package/templates/specification/04-test-data.md +8 -8
- package/templates/specification/05-test-scenarios.md +5 -5
- package/templates/test-case.md +3 -3
- package/templates/test-data-guidelines.md +7 -7
- package/templates/test-plan-sprint.md +13 -13
- package/templates/test-plan.md +3 -3
- 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
|
|
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
|
|
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
|
|
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 |
|
|
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}}
|
|
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}}
|
|
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
|
|
95
|
-
2. **Flaky risk**: TC-{{M}}-{{S}}-{{NNN}} passed on retry
|
|
96
|
-
3. **Coverage gap**: {{submodule}} `{{scenario}}` has no automated coverage
|
|
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
|
|
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.
|