@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,184 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Guard functions for global-setup.ts.
6
+ *
7
+ * These live in a plain CommonJS module rather than as private functions inside
8
+ * global-setup.ts so they are directly testable with `node --test`, without adding a
9
+ * TypeScript test runner (ts-node/tsx) as a dependency. global-setup.ts requires this
10
+ * module the same way it requires ./lane-lock.js.
11
+ *
12
+ * WHY GUARDS AT ALL: a lock nothing consults is decoration. global-setup is the one
13
+ * place that actually performs a login, so it is the last point at which a run about
14
+ * to drive an account it does not hold can still be stopped.
15
+ *
16
+ * global-setup NEVER calls acquire or reserve itself. It only READS lane state and
17
+ * verifies the lane is already held. Owning the reserve/release lifecycle is the
18
+ * caller's job - the CLI operator, the orchestrator, or the CI job. A global-setup
19
+ * that reserved its own lane would make every accidental run look legitimate.
20
+ *
21
+ * Each guard takes an explicit `env` (defaulting to process.env) and an injectable
22
+ * lane-lock module, so tests can pass a synthetic matrix without mutating globals.
23
+ */
24
+
25
+ /**
26
+ * Fail-closed guard. Runs BEFORE any login or browser work.
27
+ *
28
+ * Without QA_LANE_ONLY this is a "cold setup" that re-logs in EVERY lane's account,
29
+ * which silently invalidates any other agent's or CI job's concurrently running
30
+ * session on the shared QA server. That is occasionally what you actually want, so
31
+ * QA_COLD_SETUP=1 is the single explicit escape hatch - explicit so it can never be
32
+ * an accident. If neither is set, throw before touching anything.
33
+ */
34
+ function assertColdSetupIsIntentional(env) {
35
+ env = env || process.env;
36
+ if (env.QA_LANE_ONLY) return;
37
+ if (env.QA_COLD_SETUP === '1') return;
38
+ throw new Error(
39
+ '[global-setup] Refusing to run a cold setup (no QA_LANE_ONLY set). A cold setup re-logs in ' +
40
+ "EVERY lane account and can silently invalidate another agent or CI job's concurrently " +
41
+ 'running session on the shared QA server. Set QA_LANE_ONLY=<lane id> to log in a single lane ' +
42
+ 'only, or set QA_COLD_SETUP=1 explicitly if you really intend to relogin ALL lanes.'
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Lane-lock awareness guard. Only meaningful when QA_LANE_ONLY is set; the cold-setup
48
+ * guard above already rejects the "neither set" case.
49
+ *
50
+ * Skipped entirely in CI (env.CI === 'true'): the locks directory is local, ephemeral
51
+ * and per-machine, and does not persist between CI agents, so a CI job's lane will
52
+ * never appear busy in a fresh agent's lanes.json and enforcing this there would fail
53
+ * every time. The CI-side equivalent belongs at the pipeline level - fail the job in
54
+ * seconds if QA_LANE_ONLY is empty, before npm ci and browser installation.
55
+ */
56
+ function assertLaneIsLocked(laneOnly, env, laneLockModule) {
57
+ env = env || process.env;
58
+ if (env.CI === 'true') return;
59
+
60
+ const laneLock = laneLockModule || require('./lane-lock.js');
61
+
62
+ const laneId = laneLock.laneIdForQaLaneOnly(laneOnly);
63
+ const state = laneLock.loadState();
64
+ const laneState = state.lanes[laneId];
65
+
66
+ if (!laneState || laneState.status !== 'busy') {
67
+ throw new Error(
68
+ `[global-setup] QA_LANE_ONLY=${laneOnly} (lane ${laneId}) is not reserved in the lane lock state. ` +
69
+ 'Run `node scripts/lane-lock.js reserve <laneId> "<label>"` before running Playwright locally, ' +
70
+ 'so global-setup knows this lane is intentionally in use.'
71
+ );
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Parses --project=<name> and --project <name> out of an argv-shaped array. Returns
77
+ * every match in order, because Playwright accepts --project more than once.
78
+ */
79
+ function selectedProjectNamesFromArgv(argv) {
80
+ const names = [];
81
+ for (let i = 0; i < argv.length; i++) {
82
+ const arg = argv[i];
83
+ if (arg.startsWith('--project=')) {
84
+ names.push(arg.slice('--project='.length));
85
+ continue;
86
+ }
87
+ if (arg === '--project' && i + 1 < argv.length) {
88
+ names.push(argv[i + 1]);
89
+ i++;
90
+ }
91
+ }
92
+ return names;
93
+ }
94
+
95
+ /**
96
+ * Project/lane correspondence guard.
97
+ *
98
+ * The guards above verify the LANE named by QA_LANE_ONLY is held. They never check
99
+ * that the --project actually being run points its storageState at that same lane.
100
+ * This closes that gap by comparing the storageState of the requested project(s)
101
+ * against the storageState the lane table says QA_LANE_ONLY should be using.
102
+ *
103
+ * With playwright.config.ts resolving storageState through the lane table, this
104
+ * should never fire in the happy path. It is kept as cheap defense in depth for two
105
+ * cases that stay real: a regression in the resolver itself, and a future project
106
+ * added with a hand-hardcoded storageState (an easy mistake to make by copy-pasting
107
+ * an older project block). That second case is exactly what caused evidence
108
+ * corruption before the resolver existed, and this guard is the only thing that
109
+ * catches it at runtime.
110
+ *
111
+ * `projects` is the COMPLETE unfiltered list Playwright passes to globalSetup, not
112
+ * just the ones selected by --project, so process.argv is the only reliable signal
113
+ * for what was actually requested.
114
+ */
115
+ function assertProjectsMatchLane(qaLaneOnly, projects, argv, laneLockModule) {
116
+ if (!qaLaneOnly) return; // cold-setup path, already gated above
117
+
118
+ argv = argv || process.argv;
119
+ const laneLock = laneLockModule || require('./lane-lock.js');
120
+
121
+ const selectedNames = selectedProjectNamesFromArgv(argv);
122
+ if (selectedNames.length === 0) {
123
+ throw new Error(
124
+ `[global-setup] QA_LANE_ONLY=${qaLaneOnly} is set but no --project was passed on the CLI. ` +
125
+ 'Running without --project executes EVERY project declared in playwright.config.ts, most of ' +
126
+ "which point at other lanes' storageState - this would silently drive another lane's session " +
127
+ 'instead of the one you reserved. Pass --project=<name> explicitly.'
128
+ );
129
+ }
130
+
131
+ const selectedProjects = selectedNames.map((name) => {
132
+ const found = (projects || []).find((p) => p.name === name);
133
+ if (!found) {
134
+ throw new Error(
135
+ `[global-setup] --project=${name} does not match any project declared in playwright.config.ts. ` +
136
+ 'Check for a typo in the --project value.'
137
+ );
138
+ }
139
+ return found;
140
+ });
141
+
142
+ const laneId = laneLock.laneIdForQaLaneOnly(qaLaneOnly);
143
+ const expectedStorageState = laneLock.LANE_INFO[laneId].storageState;
144
+
145
+ // Projects without a string use.storageState (the 'setup' project, for instance)
146
+ // do not authenticate against any lane, so there is nothing to compare. Filtering
147
+ // by "has a string storageState" rather than by a name allowlist means any future
148
+ // no-auth project is excluded automatically.
149
+ const mismatches = selectedProjects.filter((p) => {
150
+ const storageState = p.use && p.use.storageState;
151
+ return typeof storageState === 'string' && storageState !== expectedStorageState;
152
+ });
153
+
154
+ if (mismatches.length > 0) {
155
+ const reverseLookup = (storageState) => {
156
+ for (const [id, info] of Object.entries(laneLock.LANE_INFO)) {
157
+ if (info.storageState === storageState) return id;
158
+ }
159
+ return 'unknown';
160
+ };
161
+
162
+ const details = mismatches
163
+ .map((p) => {
164
+ const actual = p.use.storageState;
165
+ return ` - project "${p.name}" uses storageState "${actual}" (that storageState belongs to ` +
166
+ `lane ${reverseLookup(actual)}, not lane ${laneId})`;
167
+ })
168
+ .join('\n');
169
+
170
+ throw new Error(
171
+ `[global-setup] QA_LANE_ONLY=${qaLaneOnly} (lane ${laneId}, expected storageState ` +
172
+ `"${expectedStorageState}") does not match the storageState of the --project(s) requested:\n${details}\n` +
173
+ 'Either fix QA_LANE_ONLY to match the --project you are running, or pass a --project whose ' +
174
+ 'storageState matches the reserved lane.'
175
+ );
176
+ }
177
+ }
178
+
179
+ module.exports = {
180
+ assertColdSetupIsIntentional,
181
+ assertLaneIsLocked,
182
+ selectedProjectNamesFromArgv,
183
+ assertProjectsMatchLane,
184
+ };
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Lane table resolution for the parallel QA lanes.
6
+ *
7
+ * WHY LANES EXIST: not for speed. The applications under test invalidate a session
8
+ * when the same account logs in again, so two concurrent workers sharing one account
9
+ * silently destroy each other's session. Each lane is a distinct QA account, and the
10
+ * lock in lane-lock.js is what stops two workers landing on the same one.
11
+ *
12
+ * This module owns the lane TABLE (which lanes exist and what each one is bound to).
13
+ * lane-lock.js owns the lane STATE (which lanes are currently held). Keeping them
14
+ * apart means the table can be parsed and tested without touching the filesystem
15
+ * state, and means playwright.config.ts can resolve a storageState without pulling
16
+ * in the lock machinery.
17
+ *
18
+ * A lane is four coupled values, not just an account. All four come from config
19
+ * because none of them can be reliably inferred:
20
+ *
21
+ * account env var prefix for credentials, e.g. QA_USER4 -> QA_USER4_EMAIL
22
+ * storageState the Playwright storage state file for that account
23
+ * mcpNamespace tool prefix when an agent drives this lane over Playwright MCP
24
+ * runnerProject the playwright.config.ts project name bound to this lane
25
+ *
26
+ * CAPABILITIES ARE PER-LANE, NOT PER-SET. A project can have 6 accounts but only 3
27
+ * MCP servers. Handing lane 5 to a consumer that needs MCP tools lane 5 lacks is a
28
+ * real failure that happened in production use, not a hypothetical. So each lane
29
+ * declares its own capabilities and consumers state what they require; see
30
+ * lanesWithCapability() and assertLaneHasCapability().
31
+ *
32
+ * BACKWARD COMPATIBILITY: a project with no `parallelLanes` block in its config gets
33
+ * the implicit single default lane (lane '1', account QA_USER, storageState
34
+ * .auth/user-default.json) - exactly the shape the pre-N-lane scaffold hardcoded.
35
+ * See DEFAULT_LANE_TABLE below.
36
+ */
37
+
38
+ const fs = require('fs');
39
+ const path = require('path');
40
+
41
+ // The implicit lane table used when a project declares no `parallelLanes` config.
42
+ // These values match what the single-lane scaffold used before lanes were
43
+ // configurable: one lane, QA_USER credentials, .auth/user-default.json, the
44
+ // 'chromium' project. A project that never opts in must keep behaving exactly as it
45
+ // did, so this table is not merely a nice default - it is the compatibility contract.
46
+ const DEFAULT_LANE_TABLE = Object.freeze({
47
+ '1': Object.freeze({
48
+ account: 'QA_USER',
49
+ storageState: '.auth/user-default.json',
50
+ mcpNamespace: null,
51
+ runnerProject: 'chromium',
52
+ capabilities: Object.freeze([]),
53
+ }),
54
+ });
55
+
56
+ // A lane id is referenced from shell env (QA_LANE_ONLY), CI variables and JSON keys,
57
+ // so it is restricted to a conservative token: alphanumerics, dash and underscore.
58
+ const LANE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
59
+
60
+ /**
61
+ * Locates qa-framework.config.json by walking up from `startDir`.
62
+ *
63
+ * The scaffold lives at qa/07-automation/e2e/ while the config lives at either the
64
+ * project root or qa/, so a fixed relative path would be wrong for one of them.
65
+ * Walking up finds it either way and returns undefined rather than throwing, so a
66
+ * project with no config at all falls through to DEFAULT_LANE_TABLE.
67
+ */
68
+ function findConfigPath(startDir) {
69
+ let dir = path.resolve(startDir || process.cwd());
70
+ const seen = new Set();
71
+ while (!seen.has(dir)) {
72
+ seen.add(dir);
73
+ for (const candidate of [
74
+ path.join(dir, 'qa-framework.config.json'),
75
+ path.join(dir, 'qa', 'qa-framework.config.json'),
76
+ ]) {
77
+ if (fs.existsSync(candidate)) return candidate;
78
+ }
79
+ const parent = path.dirname(dir);
80
+ if (parent === dir) break;
81
+ dir = parent;
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ /**
87
+ * Validates and normalizes a `parallelLanes` config block into a lane table.
88
+ *
89
+ * Throws on any malformed lane rather than dropping it. A silently ignored lane is
90
+ * the worst outcome available here: the project believes it has N lanes of
91
+ * isolation, the lock hands out fewer, and two workers end up on one account - which
92
+ * is the exact failure the whole mechanism exists to prevent.
93
+ */
94
+ function laneTableFromConfig(config) {
95
+ const block = config && config.parallelLanes;
96
+
97
+ if (block === undefined || block === null) return DEFAULT_LANE_TABLE;
98
+ if (typeof block !== 'object' || Array.isArray(block)) {
99
+ throw new Error('[lane-config] parallelLanes must be an object.');
100
+ }
101
+
102
+ // `enabled: false` is an explicit opt-out that must land on the same behavior as
103
+ // no config at all, so a project can disable lanes without deleting its lane
104
+ // definitions.
105
+ if (block.enabled === false) return DEFAULT_LANE_TABLE;
106
+
107
+ const lanes = block.lanes;
108
+ if (!Array.isArray(lanes) || lanes.length === 0) {
109
+ throw new Error('[lane-config] parallelLanes.lanes must be a non-empty array.');
110
+ }
111
+
112
+ const table = {};
113
+ for (const [index, lane] of lanes.entries()) {
114
+ const where = `parallelLanes.lanes[${index}]`;
115
+ if (!lane || typeof lane !== 'object' || Array.isArray(lane)) {
116
+ throw new Error(`[lane-config] ${where} must be an object.`);
117
+ }
118
+
119
+ const id = lane.id;
120
+ if (typeof id !== 'string' || !LANE_ID_PATTERN.test(id)) {
121
+ throw new Error(
122
+ `[lane-config] ${where}.id must be a non-empty string of letters, digits, "-" or "_". Got: ${JSON.stringify(id)}`
123
+ );
124
+ }
125
+ if (table[id]) {
126
+ throw new Error(`[lane-config] duplicate lane id "${id}" at ${where}.`);
127
+ }
128
+
129
+ for (const field of ['account', 'storageState']) {
130
+ if (typeof lane[field] !== 'string' || lane[field].length === 0) {
131
+ throw new Error(`[lane-config] ${where}.${field} is required and must be a non-empty string.`);
132
+ }
133
+ }
134
+
135
+ // mcpNamespace and runnerProject are optional: a lane may be CLI-only (no MCP
136
+ // server provisioned for it) or may not be bound to a runner project at all.
137
+ // Both are normalized to null so consumers never have to distinguish "absent"
138
+ // from "explicitly none".
139
+ for (const field of ['mcpNamespace', 'runnerProject']) {
140
+ const value = lane[field];
141
+ if (value !== undefined && value !== null && (typeof value !== 'string' || value.length === 0)) {
142
+ throw new Error(`[lane-config] ${where}.${field} must be a non-empty string when present.`);
143
+ }
144
+ }
145
+
146
+ let capabilities = lane.capabilities;
147
+ if (capabilities === undefined || capabilities === null) {
148
+ capabilities = [];
149
+ } else if (!Array.isArray(capabilities) || capabilities.some((c) => typeof c !== 'string' || !c)) {
150
+ throw new Error(`[lane-config] ${where}.capabilities must be an array of non-empty strings.`);
151
+ }
152
+
153
+ table[id] = Object.freeze({
154
+ account: lane.account,
155
+ storageState: lane.storageState,
156
+ mcpNamespace: lane.mcpNamespace === undefined ? null : lane.mcpNamespace,
157
+ runnerProject: lane.runnerProject === undefined ? null : lane.runnerProject,
158
+ capabilities: Object.freeze([...capabilities]),
159
+ });
160
+ }
161
+
162
+ // Two lanes sharing a storageState means two "isolated" lanes writing the same
163
+ // session file - they are not isolated at all, and the corruption is silent.
164
+ const byStorageState = new Map();
165
+ for (const [id, lane] of Object.entries(table)) {
166
+ const clash = byStorageState.get(lane.storageState);
167
+ if (clash) {
168
+ throw new Error(
169
+ `[lane-config] lanes "${clash}" and "${id}" share storageState "${lane.storageState}". ` +
170
+ 'Each lane must have its own storageState file, otherwise the lanes are not isolated.'
171
+ );
172
+ }
173
+ byStorageState.set(lane.storageState, id);
174
+ }
175
+
176
+ return Object.freeze(table);
177
+ }
178
+
179
+ /** Reads the config file at `configPath` and returns its lane table. */
180
+ function loadLaneTable(configPath) {
181
+ const resolved = configPath || findConfigPath(process.cwd());
182
+ if (!resolved || !fs.existsSync(resolved)) return DEFAULT_LANE_TABLE;
183
+
184
+ let config;
185
+ try {
186
+ config = JSON.parse(fs.readFileSync(resolved, 'utf8'));
187
+ } catch (err) {
188
+ throw new Error(`[lane-config] Could not parse ${resolved}: ${err.message}`);
189
+ }
190
+ return laneTableFromConfig(config);
191
+ }
192
+
193
+ /**
194
+ * Maps a QA_LANE_ONLY value to a lane id.
195
+ *
196
+ * 'default' is accepted as an alias for the first declared lane, because the
197
+ * pre-N-lane scaffold and its CI pipelines already used QA_LANE_ONLY=default and
198
+ * those must keep working. Every other value must name a declared lane exactly.
199
+ *
200
+ * Throws on anything unrecognized. Callers must never silently fall back to a
201
+ * guessed lane: guessing is how a run lands on an account it does not hold.
202
+ */
203
+ function laneIdForQaLaneOnly(qaLaneOnly, laneTable) {
204
+ const table = laneTable || loadLaneTable();
205
+ const ids = Object.keys(table);
206
+
207
+ if (qaLaneOnly === 'default') {
208
+ if (table.default) return 'default';
209
+ return ids[0];
210
+ }
211
+ if (typeof qaLaneOnly === 'string' && table[qaLaneOnly]) return qaLaneOnly;
212
+
213
+ throw new Error(
214
+ `[lane-config] Invalid QA_LANE_ONLY: ${JSON.stringify(qaLaneOnly)}. ` +
215
+ `Allowed values: default, ${ids.join(', ')}.`
216
+ );
217
+ }
218
+
219
+ /**
220
+ * Single source of truth for the lane -> storageState mapping.
221
+ *
222
+ * playwright.config.ts resolves every project's storageState through this function
223
+ * instead of hardcoding one per project. A parallel hardcoded mapping caused real
224
+ * evidence corruption once already - a run recorded under the wrong account, because
225
+ * the project's storageState was pinned to one lane while QA_LANE_ONLY named
226
+ * another. There must be exactly one place this mapping lives, and this is it.
227
+ */
228
+ function storageStateForLaneOnly(qaLaneOnly, laneTable) {
229
+ const table = laneTable || loadLaneTable();
230
+ return table[laneIdForQaLaneOnly(qaLaneOnly, table)].storageState;
231
+ }
232
+
233
+ /** Lane ids that declare `capability`. */
234
+ function lanesWithCapability(capability, laneTable) {
235
+ const table = laneTable || loadLaneTable();
236
+ return Object.keys(table).filter((id) => table[id].capabilities.includes(capability));
237
+ }
238
+
239
+ /**
240
+ * Throws unless lane `laneId` declares `capability`.
241
+ *
242
+ * This is the guard for the "6 accounts but only 3 MCP servers" failure: handing a
243
+ * consumer a lane that lacks a capability it needs fails later, confusingly, and
244
+ * inside the consumer. Failing here names the lane, the capability and what the lane
245
+ * actually has.
246
+ */
247
+ function assertLaneHasCapability(laneId, capability, laneTable) {
248
+ const table = laneTable || loadLaneTable();
249
+ const lane = table[laneId];
250
+ if (!lane) throw new Error(`[lane-config] Unknown lane: ${laneId}`);
251
+ if (!lane.capabilities.includes(capability)) {
252
+ const have = lane.capabilities.length ? lane.capabilities.join(', ') : 'none';
253
+ throw new Error(
254
+ `[lane-config] Lane ${laneId} does not have capability "${capability}" (declared: ${have}). ` +
255
+ `Lanes with that capability: ${lanesWithCapability(capability, table).join(', ') || 'none'}.`
256
+ );
257
+ }
258
+ }
259
+
260
+ module.exports = {
261
+ DEFAULT_LANE_TABLE,
262
+ findConfigPath,
263
+ laneTableFromConfig,
264
+ loadLaneTable,
265
+ laneIdForQaLaneOnly,
266
+ storageStateForLaneOnly,
267
+ lanesWithCapability,
268
+ assertLaneHasCapability,
269
+ };