gaia-framework 1.127.3 → 1.127.6
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/CLAUDE.md +3 -3
- package/_gaia/_config/global.yaml +1 -1
- package/_gaia/core/bridge/adapters/flutter-adapter.js +530 -0
- package/_gaia/core/bridge/adapters/go-adapter.js +600 -0
- package/_gaia/core/bridge/adapters/index.js +218 -0
- package/_gaia/core/bridge/adapters/java-adapter.js +589 -0
- package/_gaia/core/bridge/adapters/js-adapter.js +729 -0
- package/_gaia/core/bridge/adapters/python-adapter.js +534 -0
- package/_gaia/core/bridge/bridge-orchestrator.js +152 -0
- package/_gaia/core/bridge/bridge-post-flip-checks.js +242 -0
- package/_gaia/core/bridge/bridge-scope-guard.js +169 -0
- package/_gaia/core/bridge/bridge-toggle.js +313 -0
- package/_gaia/core/bridge/layer-0-environment-check.js +91 -0
- package/_gaia/core/bridge/layer-1-test-runner-discovery.js +89 -0
- package/_gaia/core/bridge/layer-2-ci-execution.js +363 -0
- package/_gaia/core/bridge/layer-2-local-execution.js +251 -0
- package/_gaia/core/bridge/layer-2-tier-selection.js +252 -0
- package/_gaia/core/bridge/layer-3-result-parsing.js +177 -0
- package/_gaia/core/bridge/review-gate-tier-mapping.js +215 -0
- package/_gaia/core/bridge/runner-compatibility-guard.js +192 -0
- package/_gaia/core/workflows/bridge-toggle/checklist.md +3 -0
- package/_gaia/core/workflows/bridge-toggle/instructions.xml +15 -10
- package/_gaia/lifecycle/workflows/4-implementation/dev-story/instructions.xml +1 -1
- package/gaia-install.sh +22 -0
- package/package.json +2 -1
- package/src/brownfield/browser-matrix-detector.js +274 -0
- package/src/brownfield/ci-test-detector.js +231 -0
- package/src/brownfield/design-extractor.js +523 -0
- package/src/brownfield/docker-test-detector.js +252 -0
- package/src/brownfield/test-environment-generator.js +416 -0
- package/src/brownfield/test-runner-detector.js +259 -0
- package/src/design-lifecycle/delta-sync.js +127 -0
- package/src/design-lifecycle/design-state.js +266 -0
package/CLAUDE.md
CHANGED
|
@@ -155,7 +155,7 @@ If any review fails, the story returns to `in-progress`. The Review Gate table i
|
|
|
155
155
|
|
|
156
156
|
### Review Gate-to-Tier Mapping (E17-S12, FR-195)
|
|
157
157
|
|
|
158
|
-
When the Test Execution Bridge (ADR-028) is enabled, each review gate is linked to a set of test tiers (from the E17-S11 three-tier model) whose evidence is required to produce a PASSED verdict. The canonical mapping lives in `Gaia-framework/
|
|
158
|
+
When the Test Execution Bridge (ADR-028) is enabled, each review gate is linked to a set of test tiers (from the E17-S11 three-tier model) whose evidence is required to produce a PASSED verdict. The canonical mapping lives in `Gaia-framework/_gaia/core/bridge/review-gate-tier-mapping.js` (`DEFAULT_GATE_TIER_MAPPING`) and can be overridden per-project via the `tiers.gate_mapping` block in `test-environment.yaml`.
|
|
159
159
|
|
|
160
160
|
| Review Gate | Required Tiers |
|
|
161
161
|
|---|---|
|
|
@@ -189,7 +189,7 @@ The Test Execution Bridge (ADR-028, architecture §10.20) orchestrates test runs
|
|
|
189
189
|
|
|
190
190
|
**Supported stacks (built-in adapters, architecture §10.20.11):**
|
|
191
191
|
|
|
192
|
-
The bridge ships with five static-import stack adapters, selected automatically by `getAdapter()` in `Gaia-framework/
|
|
192
|
+
The bridge ships with five static-import stack adapters, selected automatically by `getAdapter()` in `Gaia-framework/_gaia/core/bridge/adapters/index.js`. Priority order is deterministic: `javascript → python → java → go → flutter`.
|
|
193
193
|
|
|
194
194
|
| Stack | Representative runner command | Detection pattern |
|
|
195
195
|
|---|---|---|
|
|
@@ -218,7 +218,7 @@ Adding a new stack adapter is documented in `docs/architecture/bridge-adapter-co
|
|
|
218
218
|
- Trigger any GitHub Actions workflow other than the `ci_workflow` declared in `test-environment.yaml`
|
|
219
219
|
|
|
220
220
|
**Enforcement points:**
|
|
221
|
-
- `Gaia-framework/
|
|
221
|
+
- `Gaia-framework/_gaia/core/bridge/bridge-scope-guard.js` — shared scope guard module exporting `assertInScope`, `assertCommandAllowed`, `assertCiWorkflowAllowed`
|
|
222
222
|
- Layer 2 local execution (`layer-2-local-execution.js`) calls all three guards before `spawn`
|
|
223
223
|
- Layer 2 CI execution (`layer-2-ci-execution.js`) calls the shell-operator guard on the runner command and the CI workflow allowlist guard before `gh workflow run`
|
|
224
224
|
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E25-S4: Flutter and Dart Stack Adapter
|
|
3
|
+
*
|
|
4
|
+
* Plugs into the E25-S5 adapter registry and satisfies the StackAdapter
|
|
5
|
+
* contract (architecture §10.20.11.1). A single adapter handles both Flutter
|
|
6
|
+
* and pure Dart projects because Flutter's `flutter test --machine` wraps
|
|
7
|
+
* `package:test` and emits the same line-delimited JSON event schema as
|
|
8
|
+
* `dart test --reporter json`. The `discoverRunners` layer branches between
|
|
9
|
+
* the two commands based on whether `pubspec.yaml` declares a top-level
|
|
10
|
+
* `flutter:` section.
|
|
11
|
+
*
|
|
12
|
+
* Responsibilities:
|
|
13
|
+
* - Layer 0: readinessCheck — detect flutter or dart on PATH + pubspec.yaml
|
|
14
|
+
* - Layer 1: discoverRunners — parse pubspec.yaml, branch flutter vs dart,
|
|
15
|
+
* emit Tier 1 (test/) and Tier 3 (integration_test/) when present,
|
|
16
|
+
* else a single all-tier fallback
|
|
17
|
+
* - Layer 3: parseOutput — streaming JSON event parser correlating testStart
|
|
18
|
+
* and testDone by integer testID; tolerant of truncated streams
|
|
19
|
+
*
|
|
20
|
+
* Detection semantics: AND over `pubspec.yaml` (single-file detection).
|
|
21
|
+
* No new runtime dependencies — js-yaml is already in devDependencies.
|
|
22
|
+
*
|
|
23
|
+
* Traces to: FR-311, NFR-047, ADR-028, ADR-038, architecture §10.20.11
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
27
|
+
import { join } from "path";
|
|
28
|
+
import { execFileSync as realExecFileSync } from "child_process";
|
|
29
|
+
import yaml from "js-yaml";
|
|
30
|
+
|
|
31
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const DETECTION_PATTERNS = ["pubspec.yaml"];
|
|
34
|
+
|
|
35
|
+
const FLUTTER_COMMAND = "flutter test --machine";
|
|
36
|
+
const DART_COMMAND = "dart test --reporter json";
|
|
37
|
+
|
|
38
|
+
const STDERR_SNIPPET_MAX = 2048;
|
|
39
|
+
const RAW_OUTPUT_SNIPPET_MAX = 2048;
|
|
40
|
+
|
|
41
|
+
const REMEDIATION = {
|
|
42
|
+
missingPubspec: "pubspec.yaml not found at project root",
|
|
43
|
+
missingFlutter: "flutter CLI not found on PATH — install Flutter SDK from https://flutter.dev",
|
|
44
|
+
missingDart: "dart CLI not found on PATH — install Dart SDK from https://dart.dev/get-dart",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ─── Layer 0 helpers ────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
function cliAvailable(execFile, bin) {
|
|
50
|
+
try {
|
|
51
|
+
execFile(bin, ["--version"], { stdio: "ignore" });
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasPubspec(projectPath) {
|
|
59
|
+
return existsSync(join(projectPath, "pubspec.yaml"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readPubspec(projectPath) {
|
|
63
|
+
try {
|
|
64
|
+
const raw = readFileSync(join(projectPath, "pubspec.yaml"), "utf8");
|
|
65
|
+
return yaml.load(raw) || {};
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Is this project a Flutter project (top-level `flutter:` section in pubspec.yaml)?
|
|
73
|
+
* Returns false for pure Dart libraries that only declare `dependencies.flutter` etc.
|
|
74
|
+
*/
|
|
75
|
+
function isFlutterProject(pubspec) {
|
|
76
|
+
if (!pubspec || typeof pubspec !== "object") return false;
|
|
77
|
+
return Object.prototype.hasOwnProperty.call(pubspec, "flutter");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasDir(projectPath, name) {
|
|
81
|
+
try {
|
|
82
|
+
return statSync(join(projectPath, name)).isDirectory();
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Layer 0: readinessCheck ────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Readiness check for Flutter/Dart projects (AC2).
|
|
92
|
+
*
|
|
93
|
+
* @param {string} projectPath
|
|
94
|
+
* @param {object} [options]
|
|
95
|
+
* @param {function} [options._execFile] - execFileSync override (tests only)
|
|
96
|
+
* @returns {object}
|
|
97
|
+
*/
|
|
98
|
+
function readinessCheck(projectPath, options = {}) {
|
|
99
|
+
const started = Date.now();
|
|
100
|
+
const execFile = options._execFile || realExecFileSync;
|
|
101
|
+
|
|
102
|
+
if (!projectPath || typeof projectPath !== "string") {
|
|
103
|
+
throw new TypeError("readinessCheck: projectPath is required");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// NFR-035 bridge_enabled guard — parity with other adapters.
|
|
107
|
+
if (options?.test_execution_bridge?.bridge_enabled === false) {
|
|
108
|
+
return {
|
|
109
|
+
passed: true,
|
|
110
|
+
remediation: null,
|
|
111
|
+
ready: true,
|
|
112
|
+
skipped: true,
|
|
113
|
+
checks: [],
|
|
114
|
+
remediations: [],
|
|
115
|
+
report: "",
|
|
116
|
+
elapsedMs: Date.now() - started,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const checks = [];
|
|
121
|
+
|
|
122
|
+
const pubspecOk = hasPubspec(projectPath);
|
|
123
|
+
checks.push({
|
|
124
|
+
name: "pubspec",
|
|
125
|
+
passed: pubspecOk,
|
|
126
|
+
remediation: pubspecOk ? null : REMEDIATION.missingPubspec,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// If pubspec exists, determine whether this is a Flutter or pure Dart project.
|
|
130
|
+
// Then check for the appropriate toolchain binary.
|
|
131
|
+
let pubspec = null;
|
|
132
|
+
let flutterProject = false;
|
|
133
|
+
if (pubspecOk) {
|
|
134
|
+
pubspec = readPubspec(projectPath);
|
|
135
|
+
flutterProject = isFlutterProject(pubspec);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const flutterOk = flutterProject ? cliAvailable(execFile, "flutter") : null;
|
|
139
|
+
const dartOk = !flutterProject ? cliAvailable(execFile, "dart") : null;
|
|
140
|
+
|
|
141
|
+
if (flutterProject) {
|
|
142
|
+
checks.push({
|
|
143
|
+
name: "flutter-toolchain",
|
|
144
|
+
passed: flutterOk === true,
|
|
145
|
+
detected: flutterOk ? "flutter" : null,
|
|
146
|
+
remediation: flutterOk ? null : REMEDIATION.missingFlutter,
|
|
147
|
+
});
|
|
148
|
+
} else if (pubspecOk) {
|
|
149
|
+
checks.push({
|
|
150
|
+
name: "dart-toolchain",
|
|
151
|
+
passed: dartOk === true,
|
|
152
|
+
detected: dartOk ? "dart" : null,
|
|
153
|
+
remediation: dartOk ? null : REMEDIATION.missingDart,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Priority: missing pubspec is most actionable, then toolchain.
|
|
158
|
+
let remediation = null;
|
|
159
|
+
if (!pubspecOk) {
|
|
160
|
+
remediation = REMEDIATION.missingPubspec;
|
|
161
|
+
} else if (flutterProject && !flutterOk) {
|
|
162
|
+
remediation = REMEDIATION.missingFlutter;
|
|
163
|
+
} else if (!flutterProject && !dartOk) {
|
|
164
|
+
remediation = REMEDIATION.missingDart;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const passed = checks.every((c) => c.passed);
|
|
168
|
+
const elapsedMs = Date.now() - started;
|
|
169
|
+
const remediations = checks.filter((c) => !c.passed && c.remediation).map((c) => c.remediation);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
passed,
|
|
173
|
+
remediation,
|
|
174
|
+
ready: passed,
|
|
175
|
+
skipped: false,
|
|
176
|
+
checks,
|
|
177
|
+
remediations,
|
|
178
|
+
report: buildReport(checks, passed, elapsedMs),
|
|
179
|
+
elapsedMs,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildReport(checks, ready, elapsedMs) {
|
|
184
|
+
const rows = checks.map((c) => {
|
|
185
|
+
const status = c.passed ? "PASS" : "FAIL";
|
|
186
|
+
const detail = c.detected || "";
|
|
187
|
+
return ` ${status.padEnd(4)} ${c.name.padEnd(24)} ${detail}`;
|
|
188
|
+
});
|
|
189
|
+
return (
|
|
190
|
+
"Bridge Layer 0 — Flutter/Dart Readiness\n" +
|
|
191
|
+
"──────────────────────────────────────\n" +
|
|
192
|
+
rows.join("\n") +
|
|
193
|
+
`\n──────────────────────────────────────\n Overall: ${
|
|
194
|
+
ready ? "READY" : "NOT READY"
|
|
195
|
+
} (${elapsedMs}ms)`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Layer 1: discoverRunners ───────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Discover Flutter/Dart runners (AC3, AC6, AC7).
|
|
203
|
+
*
|
|
204
|
+
* Flutter projects use `flutter test --machine`; pure Dart libraries use
|
|
205
|
+
* `dart test --reporter json`. Tier emission depends on project layout:
|
|
206
|
+
*
|
|
207
|
+
* - Flutter project with `integration_test/` alongside `test/`:
|
|
208
|
+
* Tier 1 (unit, `flutter test test/`) + Tier 3 (e2e, `flutter test integration_test/`).
|
|
209
|
+
* Flutter does not emit a Tier 2 entry — the testing model lacks a
|
|
210
|
+
* standard unit-vs-integration distinction (AC6).
|
|
211
|
+
* - Otherwise: single `all` tier runner with a fallback log message (AC7).
|
|
212
|
+
*
|
|
213
|
+
* @param {string} projectPath
|
|
214
|
+
* @param {object} [manifest]
|
|
215
|
+
* @returns {Promise<object>}
|
|
216
|
+
*/
|
|
217
|
+
async function discoverRunners(projectPath /*, manifest */) {
|
|
218
|
+
if (!projectPath || typeof projectPath !== "string") {
|
|
219
|
+
throw new TypeError("discoverRunners: projectPath is required");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!hasPubspec(projectPath)) {
|
|
223
|
+
return {
|
|
224
|
+
status: "error",
|
|
225
|
+
message: "No pubspec.yaml found at the project root.",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const pubspec = readPubspec(projectPath);
|
|
230
|
+
if (pubspec === null) {
|
|
231
|
+
return {
|
|
232
|
+
status: "error",
|
|
233
|
+
message: "Failed to parse pubspec.yaml.",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const flutterProject = isFlutterProject(pubspec);
|
|
238
|
+
const hasTestDir = hasDir(projectPath, "test");
|
|
239
|
+
const hasIntegrationDir = hasDir(projectPath, "integration_test");
|
|
240
|
+
|
|
241
|
+
// Flutter with integration_test/ — emit Tier 1 + Tier 3.
|
|
242
|
+
if (flutterProject && hasTestDir && hasIntegrationDir) {
|
|
243
|
+
const tier1 = {
|
|
244
|
+
runner_name: "flutter-test",
|
|
245
|
+
command: `${FLUTTER_COMMAND} test/`,
|
|
246
|
+
source: "pubspec.yaml",
|
|
247
|
+
tier_mapping: { tier: "unit", gates: [] },
|
|
248
|
+
tier: "unit",
|
|
249
|
+
};
|
|
250
|
+
const tier3 = {
|
|
251
|
+
runner_name: "flutter-test-integration",
|
|
252
|
+
command: `${FLUTTER_COMMAND} integration_test/`,
|
|
253
|
+
source: "pubspec.yaml",
|
|
254
|
+
tier_mapping: { tier: "e2e", gates: [] },
|
|
255
|
+
tier: "e2e",
|
|
256
|
+
};
|
|
257
|
+
return {
|
|
258
|
+
status: "ok",
|
|
259
|
+
primary: tier1,
|
|
260
|
+
manifest: {
|
|
261
|
+
mode: "flutter-tiered",
|
|
262
|
+
primary_runner: tier1,
|
|
263
|
+
runners: [tier1, tier3],
|
|
264
|
+
tiers: {
|
|
265
|
+
unit: { description: "Flutter widget/unit tests (test/)" },
|
|
266
|
+
e2e: { description: "Flutter integration tests (integration_test/)" },
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Pure Dart library OR Flutter without integration_test/ — single all-tier runner.
|
|
273
|
+
const command = flutterProject ? FLUTTER_COMMAND : DART_COMMAND;
|
|
274
|
+
const runnerName = flutterProject ? "flutter-test" : "dart-test";
|
|
275
|
+
const logMessage =
|
|
276
|
+
"no standard unit/integration/e2e convention for Flutter/Dart — using all-tier fallback";
|
|
277
|
+
|
|
278
|
+
const primary = {
|
|
279
|
+
runner_name: runnerName,
|
|
280
|
+
command,
|
|
281
|
+
source: "pubspec.yaml",
|
|
282
|
+
tier_mapping: { tier: "all", gates: [] },
|
|
283
|
+
tier: "all",
|
|
284
|
+
};
|
|
285
|
+
return {
|
|
286
|
+
status: "ok",
|
|
287
|
+
primary,
|
|
288
|
+
manifest: {
|
|
289
|
+
mode: flutterProject ? "flutter-all" : "dart-all",
|
|
290
|
+
primary_runner: primary,
|
|
291
|
+
runners: [primary],
|
|
292
|
+
tiers: {
|
|
293
|
+
all: {
|
|
294
|
+
description: flutterProject
|
|
295
|
+
? "Flutter widget/unit tests (all)"
|
|
296
|
+
: "Dart package tests (all)",
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
log: logMessage,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── Layer 3: streaming JSON parser ─────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Split a package:test JSON stdout buffer into parsed event objects.
|
|
308
|
+
* Silently skips non-JSON lines and the final partial line after a truncation.
|
|
309
|
+
*
|
|
310
|
+
* @param {string} stdout
|
|
311
|
+
* @returns {Array<object>}
|
|
312
|
+
*/
|
|
313
|
+
function parseJsonEventStream(stdout) {
|
|
314
|
+
if (!stdout || typeof stdout !== "string") return [];
|
|
315
|
+
const events = [];
|
|
316
|
+
const lines = stdout.split(/\r?\n/);
|
|
317
|
+
for (const line of lines) {
|
|
318
|
+
const trimmed = line.trim();
|
|
319
|
+
if (!trimmed || trimmed.charAt(0) !== "{") continue;
|
|
320
|
+
try {
|
|
321
|
+
const obj = JSON.parse(trimmed);
|
|
322
|
+
if (obj && typeof obj === "object") events.push(obj);
|
|
323
|
+
} catch {
|
|
324
|
+
// Skip malformed event — common with truncated / SIGTERM'd output.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return events;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Correlate testStart and testDone events into per-test records keyed by
|
|
332
|
+
* integer testID. error events are folded into the matching record's
|
|
333
|
+
* failure_message field. print events are ignored for summary but do not
|
|
334
|
+
* break correlation.
|
|
335
|
+
*
|
|
336
|
+
* @param {Array<object>} events
|
|
337
|
+
* @returns {{ tests: Array<object>, sawDone: boolean }}
|
|
338
|
+
*/
|
|
339
|
+
function correlateEvents(events) {
|
|
340
|
+
const byId = new Map(); // testID → record
|
|
341
|
+
let sawDone = false;
|
|
342
|
+
|
|
343
|
+
for (const ev of events) {
|
|
344
|
+
const type = ev.type || "";
|
|
345
|
+
|
|
346
|
+
if (type === "done") {
|
|
347
|
+
sawDone = true;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (type === "testStart" && ev.test && typeof ev.test.id === "number") {
|
|
352
|
+
const t = ev.test;
|
|
353
|
+
byId.set(t.id, {
|
|
354
|
+
id: t.id,
|
|
355
|
+
name: t.name || `test#${t.id}`,
|
|
356
|
+
status: "incomplete",
|
|
357
|
+
duration_ms: 0,
|
|
358
|
+
startMs: typeof ev.time === "number" ? ev.time : 0,
|
|
359
|
+
failure_message: null,
|
|
360
|
+
});
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (type === "testDone" && typeof ev.testID === "number") {
|
|
365
|
+
const rec = byId.get(ev.testID);
|
|
366
|
+
if (!rec) continue;
|
|
367
|
+
// package:test result values: "success" | "failure" | "error".
|
|
368
|
+
const result = ev.result || "";
|
|
369
|
+
if (ev.skipped === true) {
|
|
370
|
+
rec.status = "skipped";
|
|
371
|
+
} else if (result === "success") {
|
|
372
|
+
rec.status = "passed";
|
|
373
|
+
} else if (result === "failure" || result === "error") {
|
|
374
|
+
rec.status = "failed";
|
|
375
|
+
}
|
|
376
|
+
if (typeof ev.time === "number" && typeof rec.startMs === "number") {
|
|
377
|
+
rec.duration_ms = Math.max(0, ev.time - rec.startMs);
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (type === "error" && typeof ev.testID === "number") {
|
|
383
|
+
const rec = byId.get(ev.testID);
|
|
384
|
+
if (!rec) continue;
|
|
385
|
+
const err = ev.error || "";
|
|
386
|
+
const stack = ev.stackTrace || "";
|
|
387
|
+
const msg = (err + (stack ? "\n" + stack : "")).trim();
|
|
388
|
+
if (msg) {
|
|
389
|
+
rec.failure_message =
|
|
390
|
+
msg.length > RAW_OUTPUT_SNIPPET_MAX ? msg.slice(0, RAW_OUTPUT_SNIPPET_MAX) : msg;
|
|
391
|
+
}
|
|
392
|
+
// Ensure status is failed if an error event arrives.
|
|
393
|
+
if (rec.status !== "failed" && rec.status !== "skipped") {
|
|
394
|
+
rec.status = "failed";
|
|
395
|
+
}
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// print, suite, group, allSuites, start — ignored for correlation.
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const tests = [];
|
|
403
|
+
for (const rec of byId.values()) {
|
|
404
|
+
const entry = {
|
|
405
|
+
id: rec.id,
|
|
406
|
+
name: rec.name,
|
|
407
|
+
status: rec.status,
|
|
408
|
+
duration_ms: rec.duration_ms,
|
|
409
|
+
};
|
|
410
|
+
if (rec.failure_message) entry.failure_message = rec.failure_message;
|
|
411
|
+
tests.push(entry);
|
|
412
|
+
}
|
|
413
|
+
return { tests, sawDone };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Parse `flutter test --machine` / `dart test --reporter json` output
|
|
418
|
+
* (AC4, AC5).
|
|
419
|
+
*
|
|
420
|
+
* @param {string} stdout
|
|
421
|
+
* @param {string} stderr
|
|
422
|
+
* @param {number} exitCode
|
|
423
|
+
* @param {object} [options] - { event?: "timeout" } to signal Layer-2 SIGTERM
|
|
424
|
+
* @returns {object}
|
|
425
|
+
*/
|
|
426
|
+
function parseOutput(stdout, stderr, exitCode, options = {}) {
|
|
427
|
+
const stdoutStr = typeof stdout === "string" ? stdout : "";
|
|
428
|
+
const stderrStr = typeof stderr === "string" ? stderr : "";
|
|
429
|
+
|
|
430
|
+
const events = parseJsonEventStream(stdoutStr);
|
|
431
|
+
|
|
432
|
+
// No parseable events at all — fall back to parse_error record.
|
|
433
|
+
if (events.length === 0) {
|
|
434
|
+
return {
|
|
435
|
+
parse_error: true,
|
|
436
|
+
stderr_snippet: stderrStr.slice(0, STDERR_SNIPPET_MAX),
|
|
437
|
+
summary: { total: 0, passed: 0, failed: 0, skipped: 0, incomplete: 0 },
|
|
438
|
+
tests: [],
|
|
439
|
+
exit_code: exitCode,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const { tests, sawDone } = correlateEvents(events);
|
|
444
|
+
|
|
445
|
+
const summary = {
|
|
446
|
+
total: tests.length,
|
|
447
|
+
passed: tests.filter((t) => t.status === "passed").length,
|
|
448
|
+
failed: tests.filter((t) => t.status === "failed").length,
|
|
449
|
+
skipped: tests.filter((t) => t.status === "skipped").length,
|
|
450
|
+
incomplete: tests.filter((t) => t.status === "incomplete").length,
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// Layer 2 can explicitly tag the run as a timeout via options.event.
|
|
454
|
+
// Otherwise, infer timeout from: no `done` event received + at least one
|
|
455
|
+
// incomplete test + non-zero exit code.
|
|
456
|
+
const inferredTimeout = !sawDone && summary.incomplete > 0 && exitCode !== 0;
|
|
457
|
+
const isTimeout = options.event === "timeout" || inferredTimeout;
|
|
458
|
+
|
|
459
|
+
const result = {
|
|
460
|
+
parse_error: false,
|
|
461
|
+
summary,
|
|
462
|
+
tests,
|
|
463
|
+
exit_code: exitCode,
|
|
464
|
+
};
|
|
465
|
+
if (isTimeout) {
|
|
466
|
+
result.event = "timeout";
|
|
467
|
+
result.raw_output_snippet = stderrStr.slice(0, RAW_OUTPUT_SNIPPET_MAX);
|
|
468
|
+
}
|
|
469
|
+
return result;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ─── E25-S6: resolveTierMapping ─────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
const DEFAULT_FLUTTER_SUITES = Object.freeze({
|
|
475
|
+
unit: "test/",
|
|
476
|
+
integration: "integration_test/",
|
|
477
|
+
e2e: "integration_test/",
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Resolve per-tier Flutter suite directory mapping. Honours the optional
|
|
482
|
+
* `stackHints.flutter_suites` override from test-environment.yaml
|
|
483
|
+
* tiers.stack_hints. Partial hint blocks are merged on top of the defaults
|
|
484
|
+
* so unset tiers fall back to the directory convention. Each resulting
|
|
485
|
+
* entry records `tier_source: "stack_hints"` when the hint supplied that
|
|
486
|
+
* tier, `"adapter_default"` otherwise (E25-S6 FR-312).
|
|
487
|
+
*
|
|
488
|
+
* @param {string} _projectPath
|
|
489
|
+
* @param {object} [options]
|
|
490
|
+
* @param {{unit?: string, integration?: string, e2e?: string}} [options.stackHints]
|
|
491
|
+
* @returns {{ mapping: { unit: string, integration: string, e2e: string },
|
|
492
|
+
* entries: Array<{ tier: string, suite: string, tier_source: "stack_hints"|"adapter_default" }> }}
|
|
493
|
+
*/
|
|
494
|
+
function resolveTierMapping(_projectPath, options = {}) {
|
|
495
|
+
const hints =
|
|
496
|
+
options.stackHints && typeof options.stackHints === "object" ? options.stackHints : {};
|
|
497
|
+
const mapping = { ...DEFAULT_FLUTTER_SUITES };
|
|
498
|
+
const entries = [];
|
|
499
|
+
for (const tier of ["unit", "integration", "e2e"]) {
|
|
500
|
+
const hint = hints[tier];
|
|
501
|
+
if (typeof hint === "string" && hint.length > 0) {
|
|
502
|
+
mapping[tier] = hint;
|
|
503
|
+
entries.push({ tier, suite: hint, tier_source: "stack_hints" });
|
|
504
|
+
} else {
|
|
505
|
+
entries.push({
|
|
506
|
+
tier,
|
|
507
|
+
suite: DEFAULT_FLUTTER_SUITES[tier],
|
|
508
|
+
tier_source: "adapter_default",
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return { mapping, entries };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ─── Export ─────────────────────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* @type {import('./index.js').StackAdapter}
|
|
519
|
+
*/
|
|
520
|
+
const flutterAdapter = {
|
|
521
|
+
name: "flutter",
|
|
522
|
+
detectionPatterns: DETECTION_PATTERNS,
|
|
523
|
+
readinessCheck,
|
|
524
|
+
discoverRunners,
|
|
525
|
+
parseOutput,
|
|
526
|
+
// E25-S6 — exposed for per-stack tier mapping consumers (FR-312).
|
|
527
|
+
resolveTierMapping,
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
export default flutterAdapter;
|