@sun-asterisk/sungen 3.2.21-beta.1 → 3.2.21
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/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/audit.js +0 -8
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +0 -7
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/commands/trace.d.ts.map +1 -1
- package/dist/cli/commands/trace.js +0 -9
- package/dist/cli/commands/trace.js.map +1 -1
- package/dist/exporters/matrix/export.d.ts.map +1 -1
- package/dist/exporters/matrix/export.js +0 -11
- package/dist/exporters/matrix/export.js.map +1 -1
- package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
- package/dist/exporters/matrix/render-xlsx.js +0 -15
- package/dist/exporters/matrix/render-xlsx.js.map +1 -1
- package/dist/exporters/matrix/types.d.ts +0 -2
- package/dist/exporters/matrix/types.d.ts.map +1 -1
- package/dist/exporters/matrix/types.js.map +1 -1
- package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
- package/dist/exporters/playwright-report-parser.js +0 -1
- package/dist/exporters/playwright-report-parser.js.map +1 -1
- package/dist/exporters/types.d.ts +0 -2
- package/dist/exporters/types.d.ts.map +1 -1
- package/dist/generators/test-generator/diagnostics.d.ts +1 -6
- package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
- package/dist/generators/test-generator/diagnostics.js +0 -5
- package/dist/generators/test-generator/diagnostics.js.map +1 -1
- package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/index.js +19 -27
- package/dist/generators/test-generator/patterns/index.js.map +1 -1
- package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.js +0 -22
- package/dist/generators/test-generator/step-mapper.js.map +1 -1
- package/dist/harness/audit.d.ts +0 -2
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +9 -79
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/flow-plan.d.ts +0 -3
- package/dist/harness/flow-plan.d.ts.map +1 -1
- package/dist/harness/flow-plan.js +2 -6
- package/dist/harness/flow-plan.js.map +1 -1
- package/dist/harness/parse.d.ts +0 -5
- package/dist/harness/parse.d.ts.map +1 -1
- package/dist/harness/parse.js +1 -29
- package/dist/harness/parse.js.map +1 -1
- package/dist/harness/sensors.d.ts.map +1 -1
- package/dist/harness/sensors.js +1 -13
- package/dist/harness/sensors.js.map +1 -1
- package/dist/harness/spec-coverage.d.ts.map +1 -1
- package/dist/harness/spec-coverage.js +5 -29
- package/dist/harness/spec-coverage.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/add-flow.md +3 -41
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +0 -10
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +16 -35
- package/dist/orchestrator/templates/qa-context.md +1 -14
- package/package.json +3 -3
- package/src/cli/commands/audit.ts +0 -8
- package/src/cli/commands/delivery.ts +0 -6
- package/src/cli/commands/trace.ts +0 -9
- package/src/exporters/matrix/export.ts +0 -11
- package/src/exporters/matrix/render-xlsx.ts +0 -15
- package/src/exporters/matrix/types.ts +0 -2
- package/src/exporters/playwright-report-parser.ts +0 -2
- package/src/exporters/types.ts +0 -2
- package/src/generators/test-generator/diagnostics.ts +1 -6
- package/src/generators/test-generator/patterns/index.ts +24 -30
- package/src/generators/test-generator/step-mapper.ts +0 -22
- package/src/harness/audit.ts +10 -82
- package/src/harness/flow-plan.ts +3 -10
- package/src/harness/parse.ts +1 -31
- package/src/harness/sensors.ts +1 -13
- package/src/harness/spec-coverage.ts +4 -26
- package/src/orchestrator/templates/ai-src/commands/add-flow.md +3 -41
- package/src/orchestrator/templates/ai-src/commands/create-test.md +0 -10
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +16 -35
- package/src/orchestrator/templates/qa-context.md +1 -14
- package/dist/harness/flow-contract.d.ts +0 -71
- package/dist/harness/flow-contract.d.ts.map +0 -1
- package/dist/harness/flow-contract.js +0 -235
- package/dist/harness/flow-contract.js.map +0 -1
- package/dist/harness/perf.d.ts +0 -40
- package/dist/harness/perf.d.ts.map +0 -1
- package/dist/harness/perf.js +0 -136
- package/dist/harness/perf.js.map +0 -1
- package/src/harness/flow-contract.ts +0 -229
- package/src/harness/perf.ts +0 -112
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Flow Contract — the declared boundary of a flow, and the sensors that verify
|
|
3
|
-
* the suite against it. (#569, docs/spec/sungen-flow-quality-spec.md)
|
|
4
|
-
*
|
|
5
|
-
* A flow is the SMALLEST complete business action chain: one clear trigger ending
|
|
6
|
-
* in ONE observable, valuable outcome. Nothing enforced that: `add-flow` asked only
|
|
7
|
-
* "which screens, in order?", so a real example mixed three business goals (cart,
|
|
8
|
-
* filter, product-detail) in one "flow", and the harness scored flows with screen
|
|
9
|
-
* machinery — a registration flow was judged against the `form` page-type checklist
|
|
10
|
-
* (coverage 0%), and every flow audit showed taxonomy=0% because flow phase ids
|
|
11
|
-
* (FL-HP-001) don't even parse as categories.
|
|
12
|
-
*
|
|
13
|
-
* The contract is a declaration the QA owns (AI proposes at add-flow; a filled
|
|
14
|
-
* contract is an INPUT to generation, never an output — same rule as
|
|
15
|
-
* test-viewpoint.md). These sensors are deterministic checks against it:
|
|
16
|
-
*
|
|
17
|
-
* - outcome proof — the flow proves its own goal with an automated data assertion
|
|
18
|
-
* - scope creep — scenarios that never touch the outcome are a second goal
|
|
19
|
-
* - phase coverage — HP / ER / EH journey phases, the flow's coverage axis
|
|
20
|
-
* - handoff — a cross-screen transition is followed by an assertion
|
|
21
|
-
* - stateful depth — generalizes the cart-hardcoded regression dims to any
|
|
22
|
-
* declared collection (order, application, submission …)
|
|
23
|
-
*/
|
|
24
|
-
import * as fs from 'fs';
|
|
25
|
-
import * as path from 'path';
|
|
26
|
-
import { parse as parseYaml } from 'yaml';
|
|
27
|
-
import { ScenarioInfo } from './parse';
|
|
28
|
-
import { readTextFile } from './read-text';
|
|
29
|
-
|
|
30
|
-
export interface FlowContract {
|
|
31
|
-
goal: string;
|
|
32
|
-
actor?: string;
|
|
33
|
-
trigger?: string;
|
|
34
|
-
precondition?: string;
|
|
35
|
-
outcome: { screen: string; assertion?: string };
|
|
36
|
-
value?: string;
|
|
37
|
-
/** Journey phases this flow declares. Default [HP, ER, EH]; UI is allowed but
|
|
38
|
-
* never demanded (presentation is the balance axis's business, not coverage's). */
|
|
39
|
-
phases: string[];
|
|
40
|
-
/** The mutated collection (cart, order, application …) — enables regression dims. */
|
|
41
|
-
stateful?: string;
|
|
42
|
-
budgets?: Record<string, number>;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface FlowQualityResult {
|
|
46
|
-
hasContract: boolean;
|
|
47
|
-
contract?: FlowContract;
|
|
48
|
-
/** Parse/shape errors — a broken contract is reported, never silently ignored. */
|
|
49
|
-
errors: string[];
|
|
50
|
-
outcomeProven: boolean;
|
|
51
|
-
/** Manual-only proof: the goal is covered but not by automation. */
|
|
52
|
-
outcomeManualOnly: boolean;
|
|
53
|
-
/** Scenario names that never touch the outcome screen (guard/error phases excluded). */
|
|
54
|
-
offGoal: string[];
|
|
55
|
-
offGoalRatio: number;
|
|
56
|
-
/** Off-goal categories, for the split suggestion ("VP-FILTER-* looks like its own flow"). */
|
|
57
|
-
offGoalCategories: string[];
|
|
58
|
-
phases: { phase: string; covered: boolean; automated: boolean }[];
|
|
59
|
-
/** Covered-and-automated phases / declared phases (UI excluded) — the flow coverage axis. */
|
|
60
|
-
phaseRatio: number;
|
|
61
|
-
/** Cross-namespace transitions followed by an assertion / all transitions. */
|
|
62
|
-
handoffs: { total: number; asserted: number; ratio: number };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const DEFAULT_PHASES = ['HP', 'ER', 'EH'];
|
|
66
|
-
|
|
67
|
-
export function flowContractPath(unitDir: string): string {
|
|
68
|
-
return path.join(unitDir, 'requirements', 'flow-contract.yaml');
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Load + validate. Returns null when absent; a present-but-broken file returns errors. */
|
|
72
|
-
export function loadFlowContract(unitDir: string): { contract: FlowContract | null; errors: string[] } {
|
|
73
|
-
const p = flowContractPath(unitDir);
|
|
74
|
-
if (!fs.existsSync(p)) return { contract: null, errors: [] };
|
|
75
|
-
let raw: Record<string, unknown>;
|
|
76
|
-
try {
|
|
77
|
-
raw = parseYaml(readTextFile(p)) as Record<string, unknown>;
|
|
78
|
-
} catch (e) {
|
|
79
|
-
return { contract: null, errors: [`flow-contract.yaml does not parse: ${(e as Error).message}`] };
|
|
80
|
-
}
|
|
81
|
-
if (!raw || typeof raw !== 'object') return { contract: null, errors: ['flow-contract.yaml is empty'] };
|
|
82
|
-
const errors: string[] = [];
|
|
83
|
-
if (!raw.goal || typeof raw.goal !== 'string') errors.push('missing `goal:` (Verb + outcome, e.g. "Place an order for a product added from home")');
|
|
84
|
-
const outcome = raw.outcome as { screen?: unknown; assertion?: unknown } | undefined;
|
|
85
|
-
if (!outcome || typeof outcome.screen !== 'string' || !outcome.screen.trim()) {
|
|
86
|
-
errors.push('missing `outcome.screen:` — the screen namespace that carries the final proof');
|
|
87
|
-
}
|
|
88
|
-
if (errors.length > 0) return { contract: null, errors };
|
|
89
|
-
const phases = Array.isArray(raw.phases) && raw.phases.length > 0
|
|
90
|
-
? (raw.phases as unknown[]).map((x) => String(x).toUpperCase())
|
|
91
|
-
: DEFAULT_PHASES;
|
|
92
|
-
return {
|
|
93
|
-
contract: {
|
|
94
|
-
goal: String(raw.goal),
|
|
95
|
-
actor: raw.actor !== undefined ? String(raw.actor) : undefined,
|
|
96
|
-
trigger: raw.trigger !== undefined ? String(raw.trigger) : undefined,
|
|
97
|
-
precondition: raw.precondition !== undefined ? String(raw.precondition) : undefined,
|
|
98
|
-
outcome: { screen: String(outcome!.screen).toLowerCase(), assertion: outcome!.assertion !== undefined ? String(outcome!.assertion) : undefined },
|
|
99
|
-
value: raw.value !== undefined ? String(raw.value) : undefined,
|
|
100
|
-
phases,
|
|
101
|
-
stateful: raw.stateful !== undefined ? String(raw.stateful).toLowerCase() : undefined,
|
|
102
|
-
budgets: (raw.budgets && typeof raw.budgets === 'object') ? raw.budgets as Record<string, number> : undefined,
|
|
103
|
-
},
|
|
104
|
-
errors: [],
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** `[screen:element]` namespaces referenced by a scenario's steps, in step order. */
|
|
109
|
-
function namespacesInOrder(s: ScenarioInfo): string[] {
|
|
110
|
-
const out: string[] = [];
|
|
111
|
-
for (const m of s.stepsText.matchAll(/\[([a-z0-9_.-]+):/g)) out.push(m[1]);
|
|
112
|
-
return out;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function touchesOutcome(s: ScenarioInfo, outcomeScreen: string): boolean {
|
|
116
|
-
return namespacesInOrder(s).includes(outcomeScreen);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Phase of a scenario: its declared phase token (FL-HP-001 / VP-FLOW-ER-02 / MS-EH-005)
|
|
120
|
-
* when present, else vocabulary detection. */
|
|
121
|
-
export function phaseOf(s: ScenarioInfo, declared: string[]): string | null {
|
|
122
|
-
const id = (s.vpId ?? '').toUpperCase();
|
|
123
|
-
for (const ph of declared) {
|
|
124
|
-
if (new RegExp(`(^|-)${ph}(-|$)`).test(id)) return ph;
|
|
125
|
-
}
|
|
126
|
-
const hay = s.haystack;
|
|
127
|
-
if (declared.includes('EH') && /\b(direct access|without (a |the )?(submit|login)|browser back|refresh|expired|tamper|unauthoriz|redirect(ed)? (back )?to|guard)\b/.test(hay)) return 'EH';
|
|
128
|
-
if (declared.includes('ER') && /\b(invalid|error|required|validation|malformed|blocked|then correct|recover)\b/.test(hay)) return 'ER';
|
|
129
|
-
if (declared.includes('HP') && s.hasDataAssertion) return 'HP';
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Verify the suite against the contract. Deterministic; a flow without a contract
|
|
135
|
-
* returns hasContract:false and neutral values (the audit reports the checklist).
|
|
136
|
-
*/
|
|
137
|
-
export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQualityResult {
|
|
138
|
-
const { contract, errors } = loadFlowContract(unitDir);
|
|
139
|
-
const neutral: FlowQualityResult = {
|
|
140
|
-
hasContract: false, errors, outcomeProven: false, outcomeManualOnly: false,
|
|
141
|
-
offGoal: [], offGoalRatio: 0, offGoalCategories: [],
|
|
142
|
-
phases: [], phaseRatio: 1, handoffs: { total: 0, asserted: 0, ratio: 1 },
|
|
143
|
-
};
|
|
144
|
-
if (!contract) return neutral;
|
|
145
|
-
|
|
146
|
-
const outcomeScreen = contract.outcome.screen;
|
|
147
|
-
|
|
148
|
-
// --- Outcome proof: the flow proves its own goal, by automation -------------
|
|
149
|
-
const proofs = scenarios.filter((s) => touchesOutcome(s, outcomeScreen) && s.hasDataAssertion);
|
|
150
|
-
const outcomeProven = proofs.some((s) => !s.manual);
|
|
151
|
-
const outcomeManualOnly = !outcomeProven && proofs.length > 0;
|
|
152
|
-
|
|
153
|
-
// --- Scope creep: a scenario that never touches the outcome and is not a ----
|
|
154
|
-
// guard/error phase is evidence of a SECOND business goal in this flow.
|
|
155
|
-
const declaredPhases = contract.phases;
|
|
156
|
-
const offGoalScenarios = scenarios.filter((s) => {
|
|
157
|
-
if (touchesOutcome(s, outcomeScreen)) return false;
|
|
158
|
-
const ph = phaseOf(s, declaredPhases);
|
|
159
|
-
return ph !== 'EH' && ph !== 'ER'; // guards/error-recovery legitimately stop early
|
|
160
|
-
});
|
|
161
|
-
const offGoalRatio = scenarios.length ? offGoalScenarios.length / scenarios.length : 0;
|
|
162
|
-
const offGoalCategories = Array.from(new Set(
|
|
163
|
-
offGoalScenarios.map((s) => s.category ?? s.vpId?.replace(/-\d+.*$/, '') ?? '?')));
|
|
164
|
-
|
|
165
|
-
// --- Phase coverage: the flow's coverage axis (UI never demanded) -----------
|
|
166
|
-
const demanded = declaredPhases.filter((p) => p !== 'UI');
|
|
167
|
-
const phases = demanded.map((phase) => {
|
|
168
|
-
const inPhase = scenarios.filter((s) => phaseOf(s, declaredPhases) === phase);
|
|
169
|
-
// HP must additionally prove the outcome — a data assertion elsewhere is not the goal.
|
|
170
|
-
const relevant = phase === 'HP' ? inPhase.filter((s) => touchesOutcome(s, outcomeScreen)) : inPhase;
|
|
171
|
-
return {
|
|
172
|
-
phase,
|
|
173
|
-
covered: relevant.length > 0,
|
|
174
|
-
automated: relevant.some((s) => !s.manual),
|
|
175
|
-
};
|
|
176
|
-
});
|
|
177
|
-
const phaseRatio = demanded.length
|
|
178
|
-
? phases.filter((p) => p.covered && p.automated).length / demanded.length
|
|
179
|
-
: 1;
|
|
180
|
-
|
|
181
|
-
// --- Handoff integrity: no blind tail after a cross-namespace transition. ---
|
|
182
|
-
// A transition counts as asserted when ANY assertion follows it — in the entered
|
|
183
|
-
// namespace or later. Demanding the assertion in the entered namespace itself
|
|
184
|
-
// flagged two legitimate shapes: a guard that asserts the REDIRECT target
|
|
185
|
-
// ("go to [Checkout] → see [Home] page"), and a passthrough click en route
|
|
186
|
-
// ("click [Cart:Checkout]" asserting on the next screen). What the sensor
|
|
187
|
-
// actually guards against is a flow that clicks through screens and ends blind.
|
|
188
|
-
let total = 0; let asserted = 0;
|
|
189
|
-
for (const s of scenarios) {
|
|
190
|
-
if (s.manual) continue;
|
|
191
|
-
const steps = s.steps ?? [];
|
|
192
|
-
let current: string | null = null;
|
|
193
|
-
for (let i = 0; i < steps.length; i++) {
|
|
194
|
-
const ns = (steps[i].text.match(/\[([A-Za-z0-9_.-]+):/) || [])[1]?.toLowerCase() ?? null;
|
|
195
|
-
if (!ns) continue;
|
|
196
|
-
if (current !== null && ns !== current) {
|
|
197
|
-
total++;
|
|
198
|
-
if (steps.slice(i).some((st) => st.bucket === 'then')) asserted++;
|
|
199
|
-
}
|
|
200
|
-
current = ns;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
const handoffs = { total, asserted, ratio: total ? asserted / total : 1 };
|
|
204
|
-
|
|
205
|
-
return {
|
|
206
|
-
hasContract: true, contract, errors: [],
|
|
207
|
-
outcomeProven, outcomeManualOnly,
|
|
208
|
-
offGoal: offGoalScenarios.map((s) => s.name.slice(0, 80)),
|
|
209
|
-
offGoalRatio, offGoalCategories,
|
|
210
|
-
phases, phaseRatio, handoffs,
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Generalized stateful regression depth: the contract names the mutated collection,
|
|
216
|
-
* so the three dims (count-proof · teardown · multi-source) stop being cart-only.
|
|
217
|
-
*/
|
|
218
|
-
export function statefulDepthFor(collection: string, scenarios: ScenarioInfo[]): { countProof: boolean; teardown: boolean; multiSource: boolean; missing: string[]; ratio: number } {
|
|
219
|
-
const hay = scenarios.map((s) => s.haystack);
|
|
220
|
-
const any = (re: RegExp) => hay.some((h) => re.test(h));
|
|
221
|
-
const noun = collection.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
222
|
-
const countProof = any(new RegExp(`\\b(quantity|qty|row count|count|number of|two (rows|lines|items)|\\d+ (rows|lines|items))\\b`)) ;
|
|
223
|
-
const teardown = any(/\b(remove|delete|clear|cancel|withdraw)(?:s|d|ed|ing|n)?\b/) && any(new RegExp(`\\b(empty|emptied|no items|zero|removed|cleared|cancelled|withdrawn|0 items)\\b|empty[- ]${noun}`));
|
|
224
|
-
const adds = hay.filter((h) => new RegExp(`\\b(add|submit|create|place).{0,40}${noun}|${noun}.{0,40}\\b(add|submit|create|place)`).test(h));
|
|
225
|
-
const multiSource = any(/\b(recommended|related|you may also|another source|both sources|second (list|source))\b/) && adds.length > 0;
|
|
226
|
-
const dims: Array<[string, boolean]> = [['count-proof', countProof], ['teardown', teardown], ['multi-source', multiSource]];
|
|
227
|
-
const missing = dims.filter(([, v]) => !v).map(([k]) => k);
|
|
228
|
-
return { countProof, teardown, multiSource, missing, ratio: (dims.length - missing.length) / dims.length };
|
|
229
|
-
}
|
package/src/harness/perf.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Performance budgets — config + percentile math + the per-unit verdict. (#569)
|
|
3
|
-
*
|
|
4
|
-
* A flow's regression value includes "still fast enough": after a lib/framework
|
|
5
|
-
* upgrade the main journeys must not only pass but hold their response-time
|
|
6
|
-
* budget. Sungen had no perf concept at all — the Playwright JSON parser even
|
|
7
|
-
* dropped the `duration` field Playwright already emits on every result.
|
|
8
|
-
*
|
|
9
|
-
* Scope discipline:
|
|
10
|
-
* - This is config + measurement + report over runs sungen already makes.
|
|
11
|
-
* Real load tests stay @manual:M8 → a dedicated tool.
|
|
12
|
-
* - The AUDIT never reads it: the quality score is documented as a pure
|
|
13
|
-
* function of the design artifacts ("reads no test-results, live page, or
|
|
14
|
-
* clock"). Perf reports where runs are already read — `sungen delivery`
|
|
15
|
-
* and the dashboard. Advisory: a blown budget never fails the design gate.
|
|
16
|
-
*
|
|
17
|
-
* Config: qa/perf.yaml
|
|
18
|
-
* percentile: p75 # default p75 — "≥75% of runs meet the budget"
|
|
19
|
-
* defaults:
|
|
20
|
-
* scenario_ms: 30000 # whole-scenario wall clock (Playwright duration)
|
|
21
|
-
* page_load_ms: 3000 # Phase B — needs per-transition runtime timing
|
|
22
|
-
* transition_ms: 2000 # Phase B
|
|
23
|
-
* units:
|
|
24
|
-
* place-order: { scenario_ms: 20000 }
|
|
25
|
-
*/
|
|
26
|
-
import * as fs from 'fs';
|
|
27
|
-
import * as path from 'path';
|
|
28
|
-
import { parse as parseYaml } from 'yaml';
|
|
29
|
-
import { readTextFile } from './read-text';
|
|
30
|
-
|
|
31
|
-
export interface PerfConfig {
|
|
32
|
-
/** 0..100 — e.g. 75 for p75. */
|
|
33
|
-
percentile: number;
|
|
34
|
-
defaults: Record<string, number>;
|
|
35
|
-
units: Record<string, Record<string, number>>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface PerfVerdict {
|
|
39
|
-
unit: string;
|
|
40
|
-
metric: string; // 'scenario_ms' today; page_load_ms/transition_ms in Phase B
|
|
41
|
-
percentile: number; // 75
|
|
42
|
-
budgetMs: number;
|
|
43
|
-
measuredMs: number; // the pXX of the observed durations
|
|
44
|
-
samples: number;
|
|
45
|
-
pass: boolean;
|
|
46
|
-
/** Titles of the slowest offenders (only when failing), for the report. */
|
|
47
|
-
slowest: Array<{ title: string; ms: number }>;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function perfConfigPath(projectRoot: string): string {
|
|
51
|
-
return path.join(projectRoot, 'qa', 'perf.yaml');
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Absent file → null (perf reporting is opt-in; nothing changes until configured). */
|
|
55
|
-
export function loadPerfConfig(projectRoot: string): PerfConfig | null {
|
|
56
|
-
const p = perfConfigPath(projectRoot);
|
|
57
|
-
if (!fs.existsSync(p)) return null;
|
|
58
|
-
let raw: Record<string, unknown>;
|
|
59
|
-
try { raw = parseYaml(readTextFile(p)) as Record<string, unknown>; } catch { return null; }
|
|
60
|
-
if (!raw || typeof raw !== 'object') return null;
|
|
61
|
-
const pctRaw = String(raw.percentile ?? 'p75').toLowerCase().replace(/^p/, '');
|
|
62
|
-
const percentile = Math.min(100, Math.max(1, Number(pctRaw) || 75));
|
|
63
|
-
const num = (o: unknown): Record<string, number> => {
|
|
64
|
-
const out: Record<string, number> = {};
|
|
65
|
-
if (o && typeof o === 'object') {
|
|
66
|
-
for (const [k, v] of Object.entries(o as Record<string, unknown>)) {
|
|
67
|
-
const n = Number(v);
|
|
68
|
-
if (Number.isFinite(n) && n > 0) out[k] = n;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return out;
|
|
72
|
-
};
|
|
73
|
-
const units: Record<string, Record<string, number>> = {};
|
|
74
|
-
if (raw.units && typeof raw.units === 'object') {
|
|
75
|
-
for (const [u, o] of Object.entries(raw.units as Record<string, unknown>)) units[u] = num(o);
|
|
76
|
-
}
|
|
77
|
-
return { percentile, defaults: num(raw.defaults), units };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Nearest-rank percentile (ceil), the standard "≥pXX of samples meet the budget"
|
|
82
|
-
* reading: p75 of [a…] is the value at ceil(0.75·n) in the sorted list. One
|
|
83
|
-
* sample → that sample. Deterministic, no interpolation.
|
|
84
|
-
*/
|
|
85
|
-
export function percentileOf(p: number, values: number[]): number {
|
|
86
|
-
if (values.length === 0) return 0;
|
|
87
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
88
|
-
const rank = Math.min(sorted.length, Math.max(1, Math.ceil((p / 100) * sorted.length)));
|
|
89
|
-
return sorted[rank - 1];
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Budget for a metric on a unit: per-unit override, else defaults, else none. */
|
|
93
|
-
export function budgetFor(config: PerfConfig, unit: string, metric: string): number | undefined {
|
|
94
|
-
return config.units[unit]?.[metric] ?? config.defaults[metric];
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* The scenario_ms verdict for one unit's run. `durations` = per-test wall-clock ms
|
|
99
|
-
* (a @cases scenario contributes one sample per row-test — each is a real run).
|
|
100
|
-
*/
|
|
101
|
-
export function perfVerdict(
|
|
102
|
-
config: PerfConfig,
|
|
103
|
-
unit: string,
|
|
104
|
-
samples: Array<{ title: string; ms: number }>,
|
|
105
|
-
): PerfVerdict | null {
|
|
106
|
-
const budgetMs = budgetFor(config, unit, 'scenario_ms');
|
|
107
|
-
if (budgetMs === undefined || samples.length === 0) return null;
|
|
108
|
-
const measuredMs = percentileOf(config.percentile, samples.map((s) => s.ms));
|
|
109
|
-
const pass = measuredMs <= budgetMs;
|
|
110
|
-
const slowest = pass ? [] : [...samples].sort((a, b) => b.ms - a.ms).slice(0, 3);
|
|
111
|
-
return { unit, metric: 'scenario_ms', percentile: config.percentile, budgetMs, measuredMs, samples: samples.length, pass, slowest };
|
|
112
|
-
}
|