@codyswann/lisa 3.0.0 โ 3.2.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/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +50 -23
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/copy-overwrite/scripts/bdd/baseline.mjs +211 -121
- package/expo/copy-overwrite/scripts/bdd/contract.mjs +10 -2
- package/expo/copy-overwrite/scripts/bdd/envelope.mjs +3 -2
- package/expo/copy-overwrite/scripts/bdd/render.mjs +2 -2
- package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +45 -8
- package/expo/copy-overwrite/scripts/classify-maestro-failures.mjs +775 -0
- package/expo/create-only/.github/workflows/nightly-e2e-report.yml +71 -0
- package/expo/create-only/.maestro/flake-classification.json +21 -0
- package/expo/create-only/bdd/coverage-map.json +1 -2
- package/expo/package-lisa/package.lisa.json +1 -0
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-atlassian-access/SKILL.md +75 -64
- package/plugins/lisa/.codex-plugin/skills/lisa-jam-access/SKILL.md +13 -5
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-access/SKILL.md +30 -10
- package/plugins/lisa/.codex-plugin/skills/lisa-notion-access/SKILL.md +36 -23
- package/plugins/lisa/.codex-plugin/skills/lisa-posthog-access/SKILL.md +16 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/SKILL.md +8 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-sentry-access/SKILL.md +16 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-sonarcloud-access/SKILL.md +7 -1
- package/plugins/lisa/hooks/threshold-ratchet-families.mjs +24 -0
- package/plugins/lisa/rules/eager/credential-substrate-precedence.md +52 -0
- package/plugins/lisa/rules/eager/integration-access-layer.md +7 -3
- package/plugins/lisa/rules/reference/bdd-e2e-coverage.md +19 -8
- package/plugins/lisa/rules/reference/credential-substrate-precedence.md +166 -0
- package/plugins/lisa/rules/reference/integration-access-layer.md +27 -15
- package/plugins/lisa/skills/lisa-atlassian-access/SKILL.md +76 -65
- package/plugins/lisa/skills/lisa-jam-access/SKILL.md +14 -6
- package/plugins/lisa/skills/lisa-linear-access/SKILL.md +31 -11
- package/plugins/lisa/skills/lisa-notion-access/SKILL.md +37 -24
- package/plugins/lisa/skills/lisa-posthog-access/SKILL.md +17 -7
- package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +8 -0
- package/plugins/lisa/skills/lisa-sentry-access/SKILL.md +17 -7
- package/plugins/lisa/skills/lisa-sonarcloud-access/SKILL.md +7 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-atlassian-access/SKILL.md +76 -65
- package/plugins/lisa-agy/skills/lisa-jam-access/SKILL.md +14 -6
- package/plugins/lisa-agy/skills/lisa-linear-access/SKILL.md +31 -11
- package/plugins/lisa-agy/skills/lisa-notion-access/SKILL.md +37 -24
- package/plugins/lisa-agy/skills/lisa-posthog-access/SKILL.md +17 -7
- package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +8 -0
- package/plugins/lisa-agy/skills/lisa-sentry-access/SKILL.md +17 -7
- package/plugins/lisa-agy/skills/lisa-sonarcloud-access/SKILL.md +7 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/threshold-ratchet-families.mjs +24 -0
- package/plugins/lisa-copilot/rules/eager/credential-substrate-precedence.md +52 -0
- package/plugins/lisa-copilot/rules/eager/integration-access-layer.md +7 -3
- package/plugins/lisa-copilot/rules/reference/bdd-e2e-coverage.md +19 -8
- package/plugins/lisa-copilot/rules/reference/credential-substrate-precedence.md +166 -0
- package/plugins/lisa-copilot/rules/reference/integration-access-layer.md +27 -15
- package/plugins/lisa-copilot/skills/lisa-atlassian-access/SKILL.md +76 -65
- package/plugins/lisa-copilot/skills/lisa-jam-access/SKILL.md +14 -6
- package/plugins/lisa-copilot/skills/lisa-linear-access/SKILL.md +31 -11
- package/plugins/lisa-copilot/skills/lisa-notion-access/SKILL.md +37 -24
- package/plugins/lisa-copilot/skills/lisa-posthog-access/SKILL.md +17 -7
- package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +8 -0
- package/plugins/lisa-copilot/skills/lisa-sentry-access/SKILL.md +17 -7
- package/plugins/lisa-copilot/skills/lisa-sonarcloud-access/SKILL.md +7 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/threshold-ratchet-families.mjs +24 -0
- package/plugins/lisa-cursor/rules/bdd-e2e-coverage-reference.mdc +19 -8
- package/plugins/lisa-cursor/rules/credential-substrate-precedence-reference.mdc +171 -0
- package/plugins/lisa-cursor/rules/credential-substrate-precedence.mdc +57 -0
- package/plugins/lisa-cursor/rules/integration-access-layer-reference.mdc +27 -15
- package/plugins/lisa-cursor/rules/integration-access-layer.mdc +7 -3
- package/plugins/lisa-cursor/skills/lisa-atlassian-access/SKILL.md +76 -65
- package/plugins/lisa-cursor/skills/lisa-jam-access/SKILL.md +14 -6
- package/plugins/lisa-cursor/skills/lisa-linear-access/SKILL.md +31 -11
- package/plugins/lisa-cursor/skills/lisa-notion-access/SKILL.md +37 -24
- package/plugins/lisa-cursor/skills/lisa-posthog-access/SKILL.md +17 -7
- package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +8 -0
- package/plugins/lisa-cursor/skills/lisa-sentry-access/SKILL.md +17 -7
- package/plugins/lisa-cursor/skills/lisa-sonarcloud-access/SKILL.md +7 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/threshold-ratchet-families.mjs +24 -0
- package/plugins/src/base/rules/eager/credential-substrate-precedence.md +52 -0
- package/plugins/src/base/rules/eager/integration-access-layer.md +7 -3
- package/plugins/src/base/rules/reference/bdd-e2e-coverage.md +19 -8
- package/plugins/src/base/rules/reference/credential-substrate-precedence.md +166 -0
- package/plugins/src/base/rules/reference/integration-access-layer.md +27 -15
- package/plugins/src/base/skills/lisa-atlassian-access/SKILL.md +76 -65
- package/plugins/src/base/skills/lisa-jam-access/SKILL.md +14 -6
- package/plugins/src/base/skills/lisa-linear-access/SKILL.md +31 -11
- package/plugins/src/base/skills/lisa-notion-access/SKILL.md +37 -24
- package/plugins/src/base/skills/lisa-posthog-access/SKILL.md +17 -7
- package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +8 -0
- package/plugins/src/base/skills/lisa-sentry-access/SKILL.md +17 -7
- package/plugins/src/base/skills/lisa-sonarcloud-access/SKILL.md +7 -1
- package/rails/copy-overwrite/scripts/threshold-ratchet-families.mjs +24 -0
- package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +631 -5
- package/typescript/copy-overwrite/scripts/threshold-ratchet-families.mjs +24 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* classify-maestro-failures โ tell a preamble loss apart from a product
|
|
4
|
+
* regression when reading a Maestro JUnit report.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node scripts/classify-maestro-failures.mjs <report.xml> [more.xml ...]
|
|
8
|
+
* node scripts/classify-maestro-failures.mjs --json <report.xml>
|
|
9
|
+
* node scripts/classify-maestro-failures.mjs --markdown <report.xml>
|
|
10
|
+
* node scripts/classify-maestro-failures.mjs --platform=android <report.xml>
|
|
11
|
+
*
|
|
12
|
+
* ## This is a DIAGNOSTIC, never a gate
|
|
13
|
+
*
|
|
14
|
+
* It scores a run so a reader can attribute a red night correctly; it does not
|
|
15
|
+
* decide anything. It exits 0 on every readable report โ including one full of
|
|
16
|
+
* product failures โ and the only non-zero exit is usage (no report named).
|
|
17
|
+
* The nightly e2e merge gate (`check-nightly-e2e-health.mjs`,
|
|
18
|
+
* `docs/nightly-e2e-gate.md`) is the thing that blocks, it reads run history
|
|
19
|
+
* rather than artifacts, and nothing here feeds it. Keep it that way: a
|
|
20
|
+
* heuristic that can turn a red run green is a fail-open path, and a heuristic
|
|
21
|
+
* that can turn a green run red is a flaky gate.
|
|
22
|
+
*
|
|
23
|
+
* ## Why this exists
|
|
24
|
+
*
|
|
25
|
+
* Every authenticated flow runs a sign-in preamble before it asserts anything
|
|
26
|
+
* about the product. When the preamble fails, the flow reds having tested
|
|
27
|
+
* nothing โ and it reds on an assertion naming a preamble gate, not the
|
|
28
|
+
* feature, so a reader scoring the run by flow name counts it as a product
|
|
29
|
+
* regression. Upstreamed from TunnlAI/frontend, where proof runs 6
|
|
30
|
+
* (31424664541) and 7 (31446155638) lost four flows each that way; run 6's
|
|
31
|
+
* Android arm was written up as a possible product regression and every one of
|
|
32
|
+
* those flows came back green in run 7 with no code addressing them.
|
|
33
|
+
*
|
|
34
|
+
* ## How a failure is classified
|
|
35
|
+
*
|
|
36
|
+
* The preamble gate identities are DERIVED from the preamble subflows rather
|
|
37
|
+
* than hardcoded: a hardcoded filename list silently stops matching the moment
|
|
38
|
+
* somebody edits a gate, and the failure mode of that drift is preamble noise
|
|
39
|
+
* quietly re-entering the product column.
|
|
40
|
+
*
|
|
41
|
+
* For each failing test case:
|
|
42
|
+
*
|
|
43
|
+
* 1. Resolve which subflows the flow transitively runs (`runFlow`).
|
|
44
|
+
* 2. Split them into PREAMBLE subflows and everything else. A subflow is a
|
|
45
|
+
* preamble iff it transitively performs the sign-in sequence โ it touches
|
|
46
|
+
* one of the project's sign-in marker selectors. That is an objective
|
|
47
|
+
* trait read off the file, not a filename list to maintain.
|
|
48
|
+
* 3. If the failing assertion names a gate from a preamble subflow, and NO
|
|
49
|
+
* other surface the flow runs asserts that same gate, it is a PREAMBLE
|
|
50
|
+
* loss.
|
|
51
|
+
*
|
|
52
|
+
* Navigation helpers are deliberately NOT preambles. A helper that opens a
|
|
53
|
+
* detail screen runs mid-scenario, after sign-in, as the flow's own product
|
|
54
|
+
* work; a flow that dies in one HAS begun testing its subject and its failure
|
|
55
|
+
* belongs in the product column.
|
|
56
|
+
*
|
|
57
|
+
* ## The tie-break always favors the product column
|
|
58
|
+
*
|
|
59
|
+
* A selector the flow's own body asserts, or that a non-preamble subflow
|
|
60
|
+
* asserts, is reported as a product failure even when a preamble shares it.
|
|
61
|
+
* Misfiling a preamble loss as a product regression costs a reader some time;
|
|
62
|
+
* misfiling a real product regression as preamble noise HIDES it, and this
|
|
63
|
+
* whole file exists because hidden signal is expensive. Make the cheap error.
|
|
64
|
+
*
|
|
65
|
+
* The same asymmetry sets the default behavior of an UNCONFIGURED project: with
|
|
66
|
+
* no sign-in markers that match anything, no subflow qualifies as a preamble
|
|
67
|
+
* and every failure reads as product. Silence in the config produces the safe
|
|
68
|
+
* column, never the flattering one.
|
|
69
|
+
*
|
|
70
|
+
* ## Elapsed-at-gate, and why it is reported
|
|
71
|
+
*
|
|
72
|
+
* `extendedWaitUntil` polls until its ceiling and only then asserts, so a
|
|
73
|
+
* timed-out gate has always consumed its FULL timeout โ verified on Maestro
|
|
74
|
+
* 2.7.0, where a 3000ms gate failed a flow at 5s against a ~2s launch. That
|
|
75
|
+
* makes `elapsed_at_gate = flow_duration - gate_ceiling` the time the flow took
|
|
76
|
+
* to REACH the gate, which is the measurement that decides whether a gate is
|
|
77
|
+
* under-tolerant or the device is unstable:
|
|
78
|
+
*
|
|
79
|
+
* - reach-time near the healthy value, gate expired => the screen never
|
|
80
|
+
* came; raising the ceiling has no measured basis.
|
|
81
|
+
* - reach-time far above the healthy value => the arm is degraded
|
|
82
|
+
* and tolerance is the wrong lever anyway.
|
|
83
|
+
*
|
|
84
|
+
* No gate should be raised again without quoting this number.
|
|
85
|
+
*
|
|
86
|
+
* ## Known-intermittent registry
|
|
87
|
+
*
|
|
88
|
+
* `.maestro/flake-classification.json` (create-only; the project owns it) also
|
|
89
|
+
* carries the flows that fail SOMETIMES on an unchanged build. Every entry must
|
|
90
|
+
* carry a measured rate and the methodology behind it, and an entry that does
|
|
91
|
+
* not is reported as a registry defect and annotates nothing. That rule is the
|
|
92
|
+
* whole point: an unmeasured "known flake" entry is how a real regression gets
|
|
93
|
+
* dismissed, so a claim with no measurement behind it must have no power to
|
|
94
|
+
* excuse a failure.
|
|
95
|
+
*
|
|
96
|
+
* The contract this file implements is `docs/maestro-flake-classification.md`
|
|
97
|
+
* in Lisa; the non-gating property is proved by executing the workflow step's
|
|
98
|
+
* own shell in `tests/integration/maestro-native-flake-classification.test.ts`.
|
|
99
|
+
*
|
|
100
|
+
* @module scripts/classify-maestro-failures
|
|
101
|
+
*/
|
|
102
|
+
import * as fs from "node:fs";
|
|
103
|
+
import * as path from "node:path";
|
|
104
|
+
import { pathToFileURL } from "node:url";
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Wait commands whose `timeout:` is a ceiling the command burns before failing.
|
|
108
|
+
*
|
|
109
|
+
* `scrollUntilVisible` is included: it also polls to a ceiling and reports a
|
|
110
|
+
* not-found against its `element:` selector.
|
|
111
|
+
*/
|
|
112
|
+
const GATE_COMMANDS = ["extendedWaitUntil", "scrollUntilVisible"];
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Selectors whose presence marks a subflow as performing sign-in.
|
|
116
|
+
*
|
|
117
|
+
* These are the REFERENCE project's selectors, shipped as a starting default
|
|
118
|
+
* and meant to be replaced in `.maestro/flake-classification.json` with the
|
|
119
|
+
* ones your own sign-in flow uses. Leaving them unmatched is safe by
|
|
120
|
+
* construction โ nothing qualifies as a preamble, so every failure reads as
|
|
121
|
+
* product, which is the direction this tool is required to err in.
|
|
122
|
+
*/
|
|
123
|
+
export const DEFAULT_SIGN_IN_MARKERS = [
|
|
124
|
+
"landing:sign-in",
|
|
125
|
+
"signin:email-input",
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/** Project-owned configuration file, relative to the project root. */
|
|
129
|
+
export const CONFIG_REL_PATH = path.join(
|
|
130
|
+
".maestro",
|
|
131
|
+
"flake-classification.json"
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
/** Methodology strings that assert nothing and must not be accepted as one. */
|
|
135
|
+
const PLACEHOLDER_METHODS = new Set([
|
|
136
|
+
"",
|
|
137
|
+
"-",
|
|
138
|
+
"n/a",
|
|
139
|
+
"na",
|
|
140
|
+
"tbd",
|
|
141
|
+
"todo",
|
|
142
|
+
"unknown",
|
|
143
|
+
"none",
|
|
144
|
+
"?",
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Pull `id:`/`text:` selectors and their ceilings out of a flow's YAML source.
|
|
149
|
+
*
|
|
150
|
+
* Deliberately a line scanner rather than a YAML parse: this script must run
|
|
151
|
+
* with zero dependencies in a CI job that has not installed anything, and the
|
|
152
|
+
* shapes Maestro accepts under a wait command are uniform enough that a scanner
|
|
153
|
+
* reads them exactly. It tracks the ceiling seen most recently inside the
|
|
154
|
+
* current gate block so a selector is paired with its own timeout.
|
|
155
|
+
* @param {string} source - Flow YAML source
|
|
156
|
+
* @returns {{kind: string, selector: string, timeoutMs: number | null}[]} Gates
|
|
157
|
+
*/
|
|
158
|
+
export function extractGates(source) {
|
|
159
|
+
const lines = source.split("\n");
|
|
160
|
+
const gates = [];
|
|
161
|
+
let inGate = false;
|
|
162
|
+
let indent = 0;
|
|
163
|
+
let pending = [];
|
|
164
|
+
let timeout = null;
|
|
165
|
+
|
|
166
|
+
const flush = () => {
|
|
167
|
+
for (const selector of pending) {
|
|
168
|
+
gates.push({ ...selector, timeoutMs: timeout });
|
|
169
|
+
}
|
|
170
|
+
pending = [];
|
|
171
|
+
timeout = null;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
for (const line of lines) {
|
|
175
|
+
if (/^\s*#/.test(line) || line.trim() === "") continue;
|
|
176
|
+
const command = line.match(/^(\s*)-?\s*(\w+):/);
|
|
177
|
+
if (command && GATE_COMMANDS.includes(command[2])) {
|
|
178
|
+
if (inGate) flush();
|
|
179
|
+
inGate = true;
|
|
180
|
+
indent = command[1].length;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!inGate) continue;
|
|
184
|
+
const currentIndent = line.match(/^\s*/)[0].length;
|
|
185
|
+
// A line at or left of the gate's own indent ends the block.
|
|
186
|
+
if (currentIndent <= indent && line.trim().startsWith("-")) {
|
|
187
|
+
flush();
|
|
188
|
+
inGate = false;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const timeoutMatch = line.match(/^\s*timeout:\s*(\d+)/);
|
|
192
|
+
if (timeoutMatch) {
|
|
193
|
+
timeout = Number(timeoutMatch[1]);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const idMatch = line.match(/^\s*id:\s*['"]?([^'"\n]+?)['"]?\s*$/);
|
|
197
|
+
if (idMatch) {
|
|
198
|
+
pending.push({ kind: "id", selector: idMatch[1] });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const textMatch = line.match(/^\s*text:\s*['"]?([^'"\n]+?)['"]?\s*$/);
|
|
202
|
+
if (textMatch) pending.push({ kind: "text", selector: textMatch[1] });
|
|
203
|
+
}
|
|
204
|
+
if (inGate) flush();
|
|
205
|
+
return gates;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolve the `runFlow:` targets a flow references, as absolute paths.
|
|
210
|
+
* @param {string} source - Flow YAML source
|
|
211
|
+
* @param {string} flowPath - Absolute path of the flow being read
|
|
212
|
+
* @returns {string[]} Absolute paths of referenced flows
|
|
213
|
+
*/
|
|
214
|
+
export function extractRunFlowTargets(source, flowPath) {
|
|
215
|
+
const dir = path.dirname(flowPath);
|
|
216
|
+
const targets = [];
|
|
217
|
+
const inline = source.matchAll(/^\s*-?\s*runFlow:\s*['"]?([^'"\s]+\.yaml)/gm);
|
|
218
|
+
for (const match of inline) targets.push(path.resolve(dir, match[1]));
|
|
219
|
+
const nested = source.matchAll(/^\s*file:\s*['"]?([^'"\s]+\.yaml)/gm);
|
|
220
|
+
for (const match of nested) targets.push(path.resolve(dir, match[1]));
|
|
221
|
+
return targets;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Walk a flow's `runFlow` graph and return every subflow it transitively runs.
|
|
226
|
+
*
|
|
227
|
+
* Cycle-safe via the visited set โ a subflow that re-enters one already on the
|
|
228
|
+
* stack would otherwise recurse forever.
|
|
229
|
+
* @param {string} flowPath - Absolute path of the entry flow
|
|
230
|
+
* @param {(target: string) => string | null} readFile - Source reader
|
|
231
|
+
* @param {Set<string>} [seen] - Visited set, supplied by the recursion
|
|
232
|
+
* @returns {string[]} Absolute paths of every subflow reached
|
|
233
|
+
*/
|
|
234
|
+
export function resolveSubflows(flowPath, readFile, seen = new Set()) {
|
|
235
|
+
const resolved = [];
|
|
236
|
+
const source = readFile(flowPath);
|
|
237
|
+
if (source === null) return resolved;
|
|
238
|
+
for (const target of extractRunFlowTargets(source, flowPath)) {
|
|
239
|
+
if (seen.has(target)) continue;
|
|
240
|
+
seen.add(target);
|
|
241
|
+
resolved.push(target);
|
|
242
|
+
resolved.push(...resolveSubflows(target, readFile, seen));
|
|
243
|
+
}
|
|
244
|
+
return resolved;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Is this subflow a sign-in preamble โ directly, or through one it runs?
|
|
249
|
+
*
|
|
250
|
+
* Cycle-safe via `seen` for the same reason `resolveSubflows` is.
|
|
251
|
+
* @param {string} flowPath - Absolute path of the subflow
|
|
252
|
+
* @param {(target: string) => string | null} readFile - Source reader
|
|
253
|
+
* @param {readonly string[]} [markers] - Project sign-in marker selectors
|
|
254
|
+
* @param {Set<string>} [seen] - Visited set, supplied by the recursion
|
|
255
|
+
* @returns {boolean} True when the subflow signs somebody in
|
|
256
|
+
*/
|
|
257
|
+
export function isPreambleSubflow(
|
|
258
|
+
flowPath,
|
|
259
|
+
readFile,
|
|
260
|
+
markers = DEFAULT_SIGN_IN_MARKERS,
|
|
261
|
+
seen = new Set()
|
|
262
|
+
) {
|
|
263
|
+
if (seen.has(flowPath)) return false;
|
|
264
|
+
seen.add(flowPath);
|
|
265
|
+
const source = readFile(flowPath);
|
|
266
|
+
if (source === null) return false;
|
|
267
|
+
if (markers.some(marker => source.includes(marker))) return true;
|
|
268
|
+
return extractRunFlowTargets(source, flowPath).some(target =>
|
|
269
|
+
isPreambleSubflow(target, readFile, markers, seen)
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Does a Maestro failure message name this selector?
|
|
275
|
+
*
|
|
276
|
+
* Maestro renders a timed-out gate as `Assertion is false: id: <sel> is
|
|
277
|
+
* visible` and a not-found as `Element not found: Id matching regex: <sel>`.
|
|
278
|
+
* Both embed the selector verbatim, so an exact substring match is enough and
|
|
279
|
+
* avoids the false positives a loose regex would invite.
|
|
280
|
+
*
|
|
281
|
+
* NOTE (verified on Maestro 2.7.0): a command's `label:` does NOT reach the
|
|
282
|
+
* JUnit failure text โ a labelled gate still reports the raw assertion. That is
|
|
283
|
+
* why classification reads the selector rather than asking flow authors to
|
|
284
|
+
* label their gates.
|
|
285
|
+
* @param {string | null} message - Failure message from the report
|
|
286
|
+
* @param {{kind: string, selector: string}} gate - Gate to look for
|
|
287
|
+
* @returns {boolean} True when the message names this gate
|
|
288
|
+
*/
|
|
289
|
+
export function messageNamesSelector(message, { kind, selector }) {
|
|
290
|
+
if (!message || !selector) return false;
|
|
291
|
+
const needle = kind === "id" ? `id: ${selector}` : selector;
|
|
292
|
+
return (
|
|
293
|
+
message.includes(needle) ||
|
|
294
|
+
message.includes(`Id matching regex: ${selector}`) ||
|
|
295
|
+
message.includes(`Text matching regex: ${selector}`)
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Parse the `<testcase>` rows out of a Maestro JUnit report.
|
|
301
|
+
* @param {string} xml - JUnit report source
|
|
302
|
+
* @returns {{file: string, status: string, durationSec: number, message: string | null}[]} Rows
|
|
303
|
+
*/
|
|
304
|
+
export function parseReport(xml) {
|
|
305
|
+
const cases = [];
|
|
306
|
+
// Attributes are matched LAZILY and the self-closing form is an alternative
|
|
307
|
+
// of the same match, not a separate pattern: a greedy `[^>]*` swallows the
|
|
308
|
+
// `/` of `<testcase .../>` and then hunts for the next `</testcase>`,
|
|
309
|
+
// silently merging a passing case into the following failing one โ which
|
|
310
|
+
// reports the PASSING flow's name against the failing flow's message.
|
|
311
|
+
for (const match of xml.matchAll(
|
|
312
|
+
/<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g
|
|
313
|
+
)) {
|
|
314
|
+
const attrs = match[1] ?? "";
|
|
315
|
+
const body = match[2] ?? "";
|
|
316
|
+
const attr = name =>
|
|
317
|
+
(attrs.match(new RegExp(`${name}="([^"]*)"`)) || [])[1];
|
|
318
|
+
const failure = body.match(
|
|
319
|
+
/<(?:failure|error)\b[^>]*>([\s\S]*?)<\/(?:failure|error)>/
|
|
320
|
+
);
|
|
321
|
+
cases.push({
|
|
322
|
+
file: attr("file") || "",
|
|
323
|
+
status: attr("status") || "",
|
|
324
|
+
durationSec: Number(attr("time") || 0),
|
|
325
|
+
message: failure
|
|
326
|
+
? decodeEntities(failure[1].trim().split("\n")[0].trim())
|
|
327
|
+
: null,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return cases;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Decode the five XML entities Maestro emits into failure text.
|
|
335
|
+
* @param {string} value - Encoded text
|
|
336
|
+
* @returns {string} Decoded text
|
|
337
|
+
*/
|
|
338
|
+
function decodeEntities(value) {
|
|
339
|
+
return value
|
|
340
|
+
.replace(/</g, "<")
|
|
341
|
+
.replace(/>/g, ">")
|
|
342
|
+
.replace(/"/g, '"')
|
|
343
|
+
.replace(/'/g, "'")
|
|
344
|
+
.replace(/&/g, "&");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Reject a registry entry that cannot support the claim it makes.
|
|
349
|
+
* @param {unknown} entry - Candidate registry entry
|
|
350
|
+
* @returns {string | null} Defect reason, or null when the entry is sound
|
|
351
|
+
*/
|
|
352
|
+
function intermittentDefect(entry) {
|
|
353
|
+
if (!entry || typeof entry !== "object") return "entry is not an object";
|
|
354
|
+
const { flow, measured } = /** @type {Record<string, any>} */ (entry);
|
|
355
|
+
if (typeof flow !== "string" || flow.trim() === "")
|
|
356
|
+
return "entry has no `flow`";
|
|
357
|
+
if (!measured || typeof measured !== "object")
|
|
358
|
+
return "entry has no `measured` block โ a rate nobody measured is not evidence";
|
|
359
|
+
const { failures, runs, measuredAt, method } = measured;
|
|
360
|
+
if (!Number.isInteger(runs) || runs <= 0)
|
|
361
|
+
return "`measured.runs` must be a positive integer";
|
|
362
|
+
if (!Number.isInteger(failures) || failures < 1)
|
|
363
|
+
return "`measured.failures` must be at least 1 โ an entry that never failed is not a known intermittent";
|
|
364
|
+
if (failures > runs) return "`measured.failures` exceeds `measured.runs`";
|
|
365
|
+
if (typeof measuredAt !== "string" || !/^\d{4}-\d{2}-\d{2}/.test(measuredAt))
|
|
366
|
+
return "`measured.measuredAt` must be an ISO date";
|
|
367
|
+
if (
|
|
368
|
+
typeof method !== "string" ||
|
|
369
|
+
PLACEHOLDER_METHODS.has(method.trim().toLowerCase())
|
|
370
|
+
)
|
|
371
|
+
return "`measured.method` must describe how the rate was measured";
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Validate the known-intermittent registry, keeping only entries that earn it.
|
|
377
|
+
*
|
|
378
|
+
* Defective entries are REPORTED and DISCARDED rather than tolerated: the whole
|
|
379
|
+
* purpose of the registry is to let a reader discount a failure, so an entry
|
|
380
|
+
* with no measurement behind it must be unable to discount anything.
|
|
381
|
+
* @param {unknown} entries - Raw `knownIntermittent` array from the config
|
|
382
|
+
* @returns {{entries: object[], defects: {flow: string, reason: string}[]}} Verdict
|
|
383
|
+
*/
|
|
384
|
+
export function validateIntermittentRegistry(entries) {
|
|
385
|
+
if (!Array.isArray(entries)) return { entries: [], defects: [] };
|
|
386
|
+
const accepted = [];
|
|
387
|
+
const defects = [];
|
|
388
|
+
for (const entry of entries) {
|
|
389
|
+
const reason = intermittentDefect(entry);
|
|
390
|
+
if (reason) {
|
|
391
|
+
const named =
|
|
392
|
+
entry && typeof entry === "object" && typeof entry.flow === "string"
|
|
393
|
+
? entry.flow
|
|
394
|
+
: "(unnamed)";
|
|
395
|
+
defects.push({ flow: named, reason });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
accepted.push({
|
|
399
|
+
flow: entry.flow,
|
|
400
|
+
platforms: Array.isArray(entry.platforms)
|
|
401
|
+
? entry.platforms.map(String)
|
|
402
|
+
: [],
|
|
403
|
+
measured: entry.measured,
|
|
404
|
+
ticket: typeof entry.ticket === "string" ? entry.ticket : null,
|
|
405
|
+
notes: typeof entry.notes === "string" ? entry.notes : null,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return { entries: accepted, defects };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Find the measured-rate annotation for one flow on one platform.
|
|
413
|
+
* @param {string} flow - Flow basename from the report
|
|
414
|
+
* @param {string | null} platform - Arm the report came from, when known
|
|
415
|
+
* @param {readonly object[]} registry - Validated registry entries
|
|
416
|
+
* @returns {object | null} Annotation, or null when nothing applies
|
|
417
|
+
*/
|
|
418
|
+
function intermittentFor(flow, platform, registry) {
|
|
419
|
+
const entry = registry.find(candidate => {
|
|
420
|
+
if (path.basename(candidate.flow) !== flow) return false;
|
|
421
|
+
if (candidate.platforms.length === 0) return true;
|
|
422
|
+
if (!platform) return false;
|
|
423
|
+
return candidate.platforms.some(
|
|
424
|
+
value => value.toLowerCase() === platform.toLowerCase()
|
|
425
|
+
);
|
|
426
|
+
});
|
|
427
|
+
if (!entry) return null;
|
|
428
|
+
const { failures, runs, measuredAt, method } = entry.measured;
|
|
429
|
+
return {
|
|
430
|
+
ratePercent: Number(((failures / runs) * 100).toFixed(1)),
|
|
431
|
+
failures,
|
|
432
|
+
runs,
|
|
433
|
+
measuredAt,
|
|
434
|
+
method,
|
|
435
|
+
ticket: entry.ticket,
|
|
436
|
+
notes: entry.notes,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Resolve a report's `file` attribute to a path on disk.
|
|
442
|
+
*
|
|
443
|
+
* Lisa's reusable workflow takes a configurable flows directory, so the
|
|
444
|
+
* conventional `<root>/.maestro/flows/<name>` cannot be assumed. The reported
|
|
445
|
+
* path is tried first (it is usually repo-relative and exact); the conventional
|
|
446
|
+
* layout is the fallback, which is what keeps reports from older runners
|
|
447
|
+
* readable.
|
|
448
|
+
* @param {string} reported - `file` attribute from the testcase row
|
|
449
|
+
* @param {{projectRoot: string, maestroRoot: string, readFile: Function}} context - Lookup context
|
|
450
|
+
* @returns {string} Absolute path of the best candidate
|
|
451
|
+
*/
|
|
452
|
+
function resolveFlowPath(reported, { projectRoot, maestroRoot, readFile }) {
|
|
453
|
+
const candidates = [
|
|
454
|
+
path.resolve(projectRoot, reported),
|
|
455
|
+
path.join(maestroRoot, "flows", path.basename(reported)),
|
|
456
|
+
path.join(maestroRoot, path.basename(reported)),
|
|
457
|
+
];
|
|
458
|
+
return (
|
|
459
|
+
candidates.find(candidate => readFile(candidate) !== null) ?? candidates[0]
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Classify every failure in a report.
|
|
465
|
+
*
|
|
466
|
+
* `readFile` returns a flow's source or `null` when it is not on disk โ a
|
|
467
|
+
* report may name a flow deleted since the run, and that must degrade to
|
|
468
|
+
* "product" rather than throw.
|
|
469
|
+
* @param {string} reportXml - JUnit report source
|
|
470
|
+
* @param {object} options - Classification context
|
|
471
|
+
* @param {string} options.maestroRoot - Absolute path of the `.maestro` directory
|
|
472
|
+
* @param {(target: string) => string | null} options.readFile - Source reader
|
|
473
|
+
* @param {string} [options.projectRoot] - Absolute project root
|
|
474
|
+
* @param {readonly string[]} [options.signInMarkers] - Project sign-in markers
|
|
475
|
+
* @param {readonly unknown[]} [options.knownIntermittent] - Raw registry entries
|
|
476
|
+
* @param {string} [options.platform] - Arm the report came from
|
|
477
|
+
* @returns {object[]} One record per failing flow
|
|
478
|
+
*/
|
|
479
|
+
export function classify(reportXml, options) {
|
|
480
|
+
const {
|
|
481
|
+
maestroRoot,
|
|
482
|
+
readFile,
|
|
483
|
+
projectRoot = path.dirname(maestroRoot),
|
|
484
|
+
signInMarkers = DEFAULT_SIGN_IN_MARKERS,
|
|
485
|
+
knownIntermittent = [],
|
|
486
|
+
platform = null,
|
|
487
|
+
} = options;
|
|
488
|
+
const registry = validateIntermittentRegistry(knownIntermittent).entries;
|
|
489
|
+
const results = [];
|
|
490
|
+
for (const testCase of parseReport(reportXml)) {
|
|
491
|
+
if (!testCase.message) continue;
|
|
492
|
+
const flowPath = resolveFlowPath(testCase.file, {
|
|
493
|
+
projectRoot,
|
|
494
|
+
maestroRoot,
|
|
495
|
+
readFile,
|
|
496
|
+
});
|
|
497
|
+
const matched = matchPreambleGate(flowPath, testCase, {
|
|
498
|
+
readFile,
|
|
499
|
+
signInMarkers,
|
|
500
|
+
});
|
|
501
|
+
const flow = path.basename(testCase.file);
|
|
502
|
+
results.push({
|
|
503
|
+
flow,
|
|
504
|
+
durationSec: testCase.durationSec,
|
|
505
|
+
message: testCase.message,
|
|
506
|
+
kind: matched ? "preamble" : "product",
|
|
507
|
+
gate: matched ? matched.selector : null,
|
|
508
|
+
subflow: matched ? matched.subflow : null,
|
|
509
|
+
gateCeilingSec:
|
|
510
|
+
matched && matched.timeoutMs ? matched.timeoutMs / 1000 : null,
|
|
511
|
+
elapsedAtGateSec:
|
|
512
|
+
matched && matched.timeoutMs
|
|
513
|
+
? Number((testCase.durationSec - matched.timeoutMs / 1000).toFixed(1))
|
|
514
|
+
: null,
|
|
515
|
+
intermittent: intermittentFor(flow, platform, registry),
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
return results;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Find the preamble gate a failure died on, if any.
|
|
523
|
+
*
|
|
524
|
+
* Split out of `classify` so the tie-break rule sits in one readable place: a
|
|
525
|
+
* gate any product surface also asserts is skipped, which sends the failure to
|
|
526
|
+
* the product column.
|
|
527
|
+
* @param {string} flowPath - Absolute path of the failing flow
|
|
528
|
+
* @param {{message: string}} testCase - Parsed testcase row
|
|
529
|
+
* @param {{readFile: Function, signInMarkers: readonly string[]}} context - Lookup context
|
|
530
|
+
* @returns {object | null} Matched gate plus its subflow, or null
|
|
531
|
+
*/
|
|
532
|
+
function matchPreambleGate(flowPath, testCase, { readFile, signInMarkers }) {
|
|
533
|
+
const ownSource = readFile(flowPath);
|
|
534
|
+
const subflows = ownSource ? resolveSubflows(flowPath, readFile) : [];
|
|
535
|
+
const preambles = subflows.filter(subflow =>
|
|
536
|
+
isPreambleSubflow(subflow, readFile, signInMarkers)
|
|
537
|
+
);
|
|
538
|
+
const others = subflows.filter(subflow => !preambles.includes(subflow));
|
|
539
|
+
|
|
540
|
+
// Every gate the flow could have died on that is NOT preamble setup: its own
|
|
541
|
+
// body, plus every navigation helper it runs mid-scenario.
|
|
542
|
+
const productGates = [
|
|
543
|
+
...(ownSource ? extractGates(ownSource) : []),
|
|
544
|
+
...others.flatMap(subflow => extractGates(readFile(subflow) ?? "")),
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
for (const subflow of preambles) {
|
|
548
|
+
const source = readFile(subflow);
|
|
549
|
+
if (source === null) continue;
|
|
550
|
+
for (const gate of extractGates(source)) {
|
|
551
|
+
if (!messageNamesSelector(testCase.message, gate)) continue;
|
|
552
|
+
// Tie-break toward the product column: a selector any product surface
|
|
553
|
+
// also asserts is the flow's own business, even if a preamble shares it.
|
|
554
|
+
const contested = productGates.some(
|
|
555
|
+
own => own.kind === gate.kind && own.selector === gate.selector
|
|
556
|
+
);
|
|
557
|
+
if (contested) continue;
|
|
558
|
+
return { ...gate, subflow: path.basename(subflow) };
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Read a file, or null when it is absent or unreadable.
|
|
566
|
+
* @param {string} target - Absolute path
|
|
567
|
+
* @returns {string | null} Contents, or null
|
|
568
|
+
*/
|
|
569
|
+
function readFileOrNull(target) {
|
|
570
|
+
try {
|
|
571
|
+
return fs.readFileSync(target, "utf8");
|
|
572
|
+
} catch {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Load the project's classification config, tolerating absence and damage.
|
|
579
|
+
*
|
|
580
|
+
* A malformed config degrades to defaults with a reported defect rather than
|
|
581
|
+
* throwing: this tool runs beside a failing test suite, and a diagnostic that
|
|
582
|
+
* dies on a typo is a diagnostic nobody consults on the night they need it.
|
|
583
|
+
* @param {string} projectRoot - Absolute project root
|
|
584
|
+
* @returns {{signInMarkers: string[], knownIntermittent: unknown[], defects: object[]}} Config
|
|
585
|
+
*/
|
|
586
|
+
export function loadConfig(projectRoot) {
|
|
587
|
+
const raw = readFileOrNull(path.join(projectRoot, CONFIG_REL_PATH));
|
|
588
|
+
if (raw === null)
|
|
589
|
+
return {
|
|
590
|
+
signInMarkers: DEFAULT_SIGN_IN_MARKERS,
|
|
591
|
+
knownIntermittent: [],
|
|
592
|
+
defects: [],
|
|
593
|
+
};
|
|
594
|
+
try {
|
|
595
|
+
const parsed = JSON.parse(raw);
|
|
596
|
+
return {
|
|
597
|
+
signInMarkers: Array.isArray(parsed.signInMarkers)
|
|
598
|
+
? parsed.signInMarkers.map(String)
|
|
599
|
+
: DEFAULT_SIGN_IN_MARKERS,
|
|
600
|
+
knownIntermittent: Array.isArray(parsed.knownIntermittent)
|
|
601
|
+
? parsed.knownIntermittent
|
|
602
|
+
: [],
|
|
603
|
+
defects: [],
|
|
604
|
+
};
|
|
605
|
+
} catch (error) {
|
|
606
|
+
return {
|
|
607
|
+
signInMarkers: DEFAULT_SIGN_IN_MARKERS,
|
|
608
|
+
knownIntermittent: [],
|
|
609
|
+
defects: [
|
|
610
|
+
{ flow: CONFIG_REL_PATH, reason: `unreadable JSON: ${error.message}` },
|
|
611
|
+
],
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Classify every named report against one project checkout.
|
|
618
|
+
* @param {readonly string[]} reportPaths - Report paths
|
|
619
|
+
* @param {{projectRoot: string, platform: string | null}} context - Run context
|
|
620
|
+
* @returns {{report: string, failures: object[], defects: object[]}[]} Results
|
|
621
|
+
*/
|
|
622
|
+
export function run(reportPaths, { projectRoot, platform }) {
|
|
623
|
+
const maestroRoot = path.join(projectRoot, ".maestro");
|
|
624
|
+
const config = loadConfig(projectRoot);
|
|
625
|
+
const registry = validateIntermittentRegistry(config.knownIntermittent);
|
|
626
|
+
const defects = [...config.defects, ...registry.defects];
|
|
627
|
+
return reportPaths.map(reportPath => ({
|
|
628
|
+
report: path.basename(reportPath),
|
|
629
|
+
defects,
|
|
630
|
+
failures: classify(readFileOrNull(reportPath) ?? "", {
|
|
631
|
+
maestroRoot,
|
|
632
|
+
projectRoot,
|
|
633
|
+
readFile: readFileOrNull,
|
|
634
|
+
signInMarkers: config.signInMarkers,
|
|
635
|
+
knownIntermittent: config.knownIntermittent,
|
|
636
|
+
platform,
|
|
637
|
+
}),
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Render one report's classification as GitHub step-summary markdown.
|
|
643
|
+
* @param {{report: string, failures: object[], defects: object[]}} result - One report's result
|
|
644
|
+
* @returns {string} Markdown block
|
|
645
|
+
*/
|
|
646
|
+
export function renderMarkdown({ report, failures, defects }) {
|
|
647
|
+
const preamble = failures.filter(failure => failure.kind === "preamble");
|
|
648
|
+
const product = failures.filter(failure => failure.kind === "product");
|
|
649
|
+
const lines = [
|
|
650
|
+
`### ๐ Flake classification โ \`${report}\``,
|
|
651
|
+
"",
|
|
652
|
+
`**${product.length} product** ยท **${preamble.length} preamble** (tested nothing) ยท ${failures.length} failing flow(s)`,
|
|
653
|
+
"",
|
|
654
|
+
"_Diagnostic only โ this never changes the result of any gate._",
|
|
655
|
+
"",
|
|
656
|
+
];
|
|
657
|
+
if (product.length > 0) {
|
|
658
|
+
lines.push(
|
|
659
|
+
"| flow | failure | known intermittent |",
|
|
660
|
+
"| --- | --- | --- |"
|
|
661
|
+
);
|
|
662
|
+
for (const failure of product) {
|
|
663
|
+
const known = failure.intermittent
|
|
664
|
+
? `${failure.intermittent.ratePercent}% (${failure.intermittent.failures}/${failure.intermittent.runs}, measured ${failure.intermittent.measuredAt})`
|
|
665
|
+
: "โ";
|
|
666
|
+
lines.push(
|
|
667
|
+
`| \`${failure.flow}\` | ${escapeCell(failure.message)} | ${known} |`
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
lines.push("");
|
|
671
|
+
}
|
|
672
|
+
if (preamble.length > 0) {
|
|
673
|
+
lines.push(
|
|
674
|
+
"| flow | preamble gate | reached gate at | ceiling |",
|
|
675
|
+
"| --- | --- | --- | --- |"
|
|
676
|
+
);
|
|
677
|
+
for (const failure of preamble) {
|
|
678
|
+
lines.push(
|
|
679
|
+
`| \`${failure.flow}\` | \`${failure.gate}\` (${failure.subflow}) | ${
|
|
680
|
+
failure.elapsedAtGateSec === null
|
|
681
|
+
? "โ"
|
|
682
|
+
: `${failure.elapsedAtGateSec}s`
|
|
683
|
+
} | ${failure.gateCeilingSec === null ? "โ" : `${failure.gateCeilingSec}s`} |`
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
lines.push("");
|
|
687
|
+
}
|
|
688
|
+
for (const defect of defects) {
|
|
689
|
+
lines.push(
|
|
690
|
+
`> โ ๏ธ known-intermittent entry \`${defect.flow}\` was IGNORED: ${defect.reason}`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
return lines.join("\n");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Escape a value so it cannot break out of a markdown table cell.
|
|
698
|
+
* @param {string} value - Raw text
|
|
699
|
+
* @returns {string} Escaped text
|
|
700
|
+
*/
|
|
701
|
+
function escapeCell(value) {
|
|
702
|
+
return String(value).replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Render one report's classification as plain text.
|
|
707
|
+
* @param {{report: string, failures: object[], defects: object[]}} result - One report's result
|
|
708
|
+
* @returns {string} Text block
|
|
709
|
+
*/
|
|
710
|
+
function renderText({ report, failures, defects }) {
|
|
711
|
+
const preamble = failures.filter(failure => failure.kind === "preamble");
|
|
712
|
+
const product = failures.filter(failure => failure.kind === "product");
|
|
713
|
+
const lines = [
|
|
714
|
+
"",
|
|
715
|
+
`${report}: ${failures.length} failures`,
|
|
716
|
+
` ${product.length} product ยท ${preamble.length} preamble (tested nothing)`,
|
|
717
|
+
];
|
|
718
|
+
for (const failure of preamble) {
|
|
719
|
+
const reach =
|
|
720
|
+
failure.elapsedAtGateSec === null
|
|
721
|
+
? ""
|
|
722
|
+
: ` โ reached the gate at ${failure.elapsedAtGateSec}s, then burned its ${failure.gateCeilingSec}s ceiling`;
|
|
723
|
+
lines.push(` [preamble] ${failure.flow} (${failure.subflow})${reach}`);
|
|
724
|
+
}
|
|
725
|
+
for (const failure of product) {
|
|
726
|
+
const known = failure.intermittent
|
|
727
|
+
? ` [known intermittent ${failure.intermittent.ratePercent}% โ ${failure.intermittent.failures}/${failure.intermittent.runs} measured ${failure.intermittent.measuredAt}]`
|
|
728
|
+
: "";
|
|
729
|
+
lines.push(` [product ] ${failure.flow}: ${failure.message}${known}`);
|
|
730
|
+
}
|
|
731
|
+
for (const defect of defects) {
|
|
732
|
+
lines.push(
|
|
733
|
+
` [ignored ] known-intermittent entry ${defect.flow}: ${defect.reason}`
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
return lines.join("\n");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* CLI entry point. Exits non-zero only on usage error โ never on findings.
|
|
741
|
+
* @param {readonly string[]} argv - Arguments after the script name
|
|
742
|
+
* @returns {void}
|
|
743
|
+
*/
|
|
744
|
+
function main(argv) {
|
|
745
|
+
const asJson = argv.includes("--json");
|
|
746
|
+
const asMarkdown = argv.includes("--markdown");
|
|
747
|
+
const platformArg = argv.find(arg => arg.startsWith("--platform="));
|
|
748
|
+
const reports = argv.filter(arg => !arg.startsWith("--"));
|
|
749
|
+
if (reports.length === 0) {
|
|
750
|
+
console.error(
|
|
751
|
+
"usage: node scripts/classify-maestro-failures.mjs [--json|--markdown] [--platform=android] <report.xml> [...]"
|
|
752
|
+
);
|
|
753
|
+
process.exitCode = 1;
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const projectRoot = process.env.MAESTRO_CHECK_ROOT || process.cwd();
|
|
757
|
+
const results = run(reports, {
|
|
758
|
+
projectRoot,
|
|
759
|
+
platform: platformArg ? platformArg.slice("--platform=".length) : null,
|
|
760
|
+
});
|
|
761
|
+
if (asJson) {
|
|
762
|
+
console.log(JSON.stringify(results, null, 2));
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
for (const result of results) {
|
|
766
|
+
console.log(asMarkdown ? renderMarkdown(result) : renderText(result));
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (
|
|
771
|
+
process.argv[1] &&
|
|
772
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
773
|
+
) {
|
|
774
|
+
main(process.argv.slice(2));
|
|
775
|
+
}
|