@mandujs/ate 0.17.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.
@@ -0,0 +1,191 @@
1
+ import { existsSync } from "node:fs";
2
+ import { getAtePaths, readJson, writeJson } from "./fs";
3
+
4
+ /**
5
+ * Selector Map Schema
6
+ * Maps mandu-id to multiple selector strategies for resilient UI testing
7
+ */
8
+ export interface SelectorMap {
9
+ schemaVersion: 1;
10
+ generatedAt: string;
11
+ entries: SelectorMapEntry[];
12
+ }
13
+
14
+ export interface SelectorMapEntry {
15
+ manduId: string;
16
+ file: string;
17
+ element: string; // e.g., "button", "input", "a"
18
+ primary: SelectorStrategy;
19
+ alternatives: SelectorStrategy[];
20
+ }
21
+
22
+ export interface SelectorStrategy {
23
+ type: "mandu-id" | "text" | "class" | "xpath" | "role";
24
+ value: string;
25
+ priority: number; // 0 = highest
26
+ }
27
+
28
+ /**
29
+ * Read selector-map.json from .mandu directory
30
+ */
31
+ export function readSelectorMap(repoRoot: string): SelectorMap | null {
32
+ const paths = getAtePaths(repoRoot);
33
+ if (!existsSync(paths.selectorMapPath)) {
34
+ return null;
35
+ }
36
+ return readJson<SelectorMap>(paths.selectorMapPath);
37
+ }
38
+
39
+ /**
40
+ * Write selector-map.json to .mandu directory
41
+ */
42
+ export function writeSelectorMap(repoRoot: string, map: SelectorMap): void {
43
+ const paths = getAtePaths(repoRoot);
44
+ writeJson(paths.selectorMapPath, map);
45
+ }
46
+
47
+ /**
48
+ * Initialize empty selector map
49
+ */
50
+ export function initSelectorMap(): SelectorMap {
51
+ return {
52
+ schemaVersion: 1,
53
+ generatedAt: new Date().toISOString(),
54
+ entries: [],
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Add or update a selector entry
60
+ */
61
+ export function addSelectorEntry(
62
+ map: SelectorMap,
63
+ entry: Omit<SelectorMapEntry, "alternatives"> & { alternatives?: SelectorStrategy[] }
64
+ ): SelectorMap {
65
+ const existing = map.entries.findIndex((e) => e.manduId === entry.manduId);
66
+
67
+ const fullEntry: SelectorMapEntry = {
68
+ ...entry,
69
+ alternatives: entry.alternatives ?? [],
70
+ };
71
+
72
+ if (existing >= 0) {
73
+ map.entries[existing] = fullEntry;
74
+ } else {
75
+ map.entries.push(fullEntry);
76
+ }
77
+
78
+ map.generatedAt = new Date().toISOString();
79
+ return map;
80
+ }
81
+
82
+ /**
83
+ * Generate alternative selectors for a given mandu-id element
84
+ *
85
+ * Fallback chain priority:
86
+ * 0. mandu-id (primary)
87
+ * 1. text-based (exact text match)
88
+ * 2. class-based (CSS class)
89
+ * 3. xpath (structural fallback)
90
+ * 4. role (ARIA role)
91
+ */
92
+ export function generateAlternatives(opts: {
93
+ manduId: string;
94
+ element: string;
95
+ text?: string;
96
+ className?: string;
97
+ ariaRole?: string;
98
+ }): SelectorStrategy[] {
99
+ const alternatives: SelectorStrategy[] = [];
100
+
101
+ // Text-based selector
102
+ if (opts.text) {
103
+ alternatives.push({
104
+ type: "text",
105
+ value: `:has-text("${opts.text}")`,
106
+ priority: 1,
107
+ });
108
+ }
109
+
110
+ // Class-based selector
111
+ if (opts.className) {
112
+ alternatives.push({
113
+ type: "class",
114
+ value: `.${opts.className}`,
115
+ priority: 2,
116
+ });
117
+ }
118
+
119
+ // ARIA role selector
120
+ if (opts.ariaRole) {
121
+ alternatives.push({
122
+ type: "role",
123
+ value: `role=${opts.ariaRole}`,
124
+ priority: 3,
125
+ });
126
+ }
127
+
128
+ // XPath fallback (structural)
129
+ const xpathValue = `//${opts.element}[@data-mandu-id="${opts.manduId}"]`;
130
+ alternatives.push({
131
+ type: "xpath",
132
+ value: xpathValue,
133
+ priority: 4,
134
+ });
135
+
136
+ return alternatives.sort((a, b) => a.priority - b.priority);
137
+ }
138
+
139
+ /**
140
+ * Get selector entry by mandu-id
141
+ */
142
+ export function getSelectorEntry(
143
+ map: SelectorMap,
144
+ manduId: string
145
+ ): SelectorMapEntry | undefined {
146
+ return map.entries.find((e) => e.manduId === manduId);
147
+ }
148
+
149
+ /**
150
+ * Build Playwright .or() chain from selector entry
151
+ * Returns: page.locator(primary).or(alt1).or(alt2)...
152
+ */
153
+ export function buildPlaywrightLocatorChain(entry: SelectorMapEntry): string {
154
+ const primaryLocator = `page.locator('[data-mandu-id="${entry.manduId}"]')`;
155
+
156
+ if (entry.alternatives.length === 0) {
157
+ return primaryLocator;
158
+ }
159
+
160
+ const alternativeChains = entry.alternatives
161
+ .map((alt) => {
162
+ switch (alt.type) {
163
+ case "text":
164
+ return `page.locator('${entry.element}${alt.value}')`;
165
+ case "class":
166
+ return `page.locator('${entry.element}${alt.value}')`;
167
+ case "role":
168
+ return `page.getByRole('${alt.value.replace("role=", "")}')`;
169
+ case "xpath":
170
+ return `page.locator('xpath=${alt.value}')`;
171
+ default:
172
+ return null;
173
+ }
174
+ })
175
+ .filter(Boolean);
176
+
177
+ if (alternativeChains.length === 0) {
178
+ return primaryLocator;
179
+ }
180
+
181
+ return `${primaryLocator}.or(${alternativeChains.join(").or(")})`;
182
+ }
183
+
184
+ /**
185
+ * Remove selector entry by mandu-id
186
+ */
187
+ export function removeSelectorEntry(map: SelectorMap, manduId: string): SelectorMap {
188
+ map.entries = map.entries.filter((e) => e.manduId !== manduId);
189
+ map.generatedAt = new Date().toISOString();
190
+ return map;
191
+ }
@@ -0,0 +1,270 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { gunzipSync } from "node:zlib";
3
+ import type { JsonValue, JsonObject } from "./types";
4
+
5
+ export interface TraceAction {
6
+ type: string;
7
+ selector?: string;
8
+ method?: string;
9
+ error?: string;
10
+ params?: JsonObject;
11
+ beforeSnapshot?: string;
12
+ afterSnapshot?: string;
13
+ }
14
+
15
+ export interface FailedLocator {
16
+ selector: string;
17
+ error: string;
18
+ context?: string;
19
+ actionType?: string;
20
+ }
21
+
22
+ export interface TraceParseResult {
23
+ actions: TraceAction[];
24
+ failedLocators: FailedLocator[];
25
+ metadata: {
26
+ testFile?: string;
27
+ testName?: string;
28
+ timestamp?: string;
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Parse Playwright trace.zip file
34
+ * Trace format: ZIP archive containing trace JSON + resources
35
+ *
36
+ * @param tracePath - Path to trace.zip file
37
+ * @returns Parsed trace with failed locators
38
+ */
39
+ export function parseTrace(tracePath: string): TraceParseResult {
40
+ let content: Buffer;
41
+
42
+ try {
43
+ content = readFileSync(tracePath);
44
+ } catch (err) {
45
+ throw new Error(`Failed to read trace file: ${tracePath}`);
46
+ }
47
+
48
+ // Playwright trace.zip is a ZIP archive
49
+ // For MVP: assume trace is actually JSON (playwright-report.json)
50
+ // Full ZIP parsing would require JSZip or similar
51
+
52
+ let traceData: JsonValue;
53
+ try {
54
+ const text = content.toString("utf8");
55
+ traceData = JSON.parse(text);
56
+ } catch {
57
+ // Try gunzip if it's compressed
58
+ try {
59
+ const decompressed = gunzipSync(content);
60
+ const text = decompressed.toString("utf8");
61
+ traceData = JSON.parse(text);
62
+ } catch (err) {
63
+ throw new Error(`Failed to parse trace JSON: ${String(err)}`);
64
+ }
65
+ }
66
+
67
+ const result: TraceParseResult = {
68
+ actions: [],
69
+ failedLocators: [],
70
+ metadata: {},
71
+ };
72
+
73
+ // Parse Playwright report structure
74
+ if (typeof traceData !== "object" || !traceData || Array.isArray(traceData)) {
75
+ return result;
76
+ }
77
+
78
+ const report = traceData as JsonObject;
79
+
80
+ // Extract metadata
81
+ if (typeof report.config === "object" && report.config) {
82
+ const config = report.config as JsonObject;
83
+ result.metadata.testFile = String(config.rootDir || "");
84
+ }
85
+
86
+ // Parse suites and tests
87
+ const suites = Array.isArray(report.suites) ? report.suites : [];
88
+
89
+ for (const suite of suites) {
90
+ if (typeof suite !== "object" || !suite) continue;
91
+ const suiteObj = suite as JsonObject;
92
+
93
+ const tests = Array.isArray(suiteObj.tests) ? suiteObj.tests : [];
94
+
95
+ for (const test of tests) {
96
+ if (typeof test !== "object" || !test) continue;
97
+ const testObj = test as JsonObject;
98
+
99
+ result.metadata.testName = String(testObj.title || "");
100
+
101
+ const results = Array.isArray(testObj.results) ? testObj.results : [];
102
+
103
+ for (const testResult of results) {
104
+ if (typeof testResult !== "object" || !testResult) continue;
105
+ const resultObj = testResult as JsonObject;
106
+
107
+ const steps = Array.isArray(resultObj.steps) ? resultObj.steps : [];
108
+
109
+ for (const step of steps) {
110
+ if (typeof step !== "object" || !step) continue;
111
+ const stepObj = step as JsonObject;
112
+
113
+ const action: TraceAction = {
114
+ type: String(stepObj.title || "unknown"),
115
+ };
116
+
117
+ // Extract error info
118
+ if (stepObj.error) {
119
+ const errorObj = typeof stepObj.error === "object" && stepObj.error ? stepObj.error as JsonObject : {};
120
+ action.error = String(errorObj.message || stepObj.error);
121
+
122
+ // Parse locator from error message
123
+ const errorMsg = action.error;
124
+ const locatorMatch = errorMsg.match(/locator\(['"](.+?)['"]\)/);
125
+ if (locatorMatch) {
126
+ action.selector = locatorMatch[1];
127
+ }
128
+
129
+ // Detect failed locator
130
+ if (errorMsg.includes("not found") || errorMsg.includes("timeout") || errorMsg.includes("failed")) {
131
+ const selector = action.selector || extractSelectorFromTitle(String(stepObj.title || ""));
132
+
133
+ if (selector) {
134
+ result.failedLocators.push({
135
+ selector,
136
+ error: errorMsg,
137
+ context: String(stepObj.title || ""),
138
+ actionType: detectActionType(String(stepObj.title || "")),
139
+ });
140
+ }
141
+ }
142
+ }
143
+
144
+ result.actions.push(action);
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ return result;
151
+ }
152
+
153
+ /**
154
+ * Extract selector from step title
155
+ * Examples:
156
+ * - "click getByRole('button', { name: 'Submit' })" → "getByRole('button', { name: 'Submit' })"
157
+ * - "fill #username" → "#username"
158
+ */
159
+ function extractSelectorFromTitle(title: string): string | null {
160
+ // getByRole, getByText, etc.
161
+ const playwrightMatch = title.match(/get\w+\([^)]+\)/);
162
+ if (playwrightMatch) {
163
+ return playwrightMatch[0];
164
+ }
165
+
166
+ // CSS selectors
167
+ const cssMatch = title.match(/[#.]\w[\w-]*/);
168
+ if (cssMatch) {
169
+ return cssMatch[0];
170
+ }
171
+
172
+ // XPath
173
+ const xpathMatch = title.match(/\/\/[\w/[\]@='".\s]+/);
174
+ if (xpathMatch) {
175
+ return xpathMatch[0];
176
+ }
177
+
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Detect action type from step title
183
+ */
184
+ function detectActionType(title: string): string {
185
+ const lower = title.toLowerCase();
186
+
187
+ if (lower.includes("click")) return "click";
188
+ if (lower.includes("fill") || lower.includes("type")) return "fill";
189
+ if (lower.includes("select")) return "select";
190
+ if (lower.includes("check")) return "check";
191
+ if (lower.includes("navigate") || lower.includes("goto")) return "navigate";
192
+ if (lower.includes("wait")) return "wait";
193
+
194
+ return "unknown";
195
+ }
196
+
197
+ /**
198
+ * Generate alternative selectors for a failed locator
199
+ *
200
+ * @param selector - Original failed selector
201
+ * @param actionType - Type of action (click, fill, etc.)
202
+ * @returns Array of alternative selectors to try
203
+ */
204
+ export function generateAlternativeSelectors(selector: string, actionType?: string): string[] {
205
+ const alternatives: string[] = [];
206
+
207
+ // CSS ID → alternatives
208
+ if (selector.startsWith("#")) {
209
+ const id = selector.slice(1);
210
+ alternatives.push(
211
+ `[data-testid="${id}"]`,
212
+ `[id="${id}"]`,
213
+ `[name="${id}"]`,
214
+ `[aria-label="${id}"]`,
215
+ );
216
+ }
217
+
218
+ // CSS class → alternatives
219
+ if (selector.startsWith(".")) {
220
+ const cls = selector.slice(1);
221
+ alternatives.push(
222
+ `[data-testid="${cls}"]`,
223
+ `.${cls}`,
224
+ `[class*="${cls}"]`,
225
+ );
226
+ }
227
+
228
+ // getByRole → alternatives
229
+ if (selector.includes("getByRole")) {
230
+ const roleMatch = selector.match(/getByRole\(['"](\w+)['"]/);
231
+ const nameMatch = selector.match(/name:\s*['"](.+?)['"]/);
232
+
233
+ if (roleMatch && nameMatch) {
234
+ const role = roleMatch[1];
235
+ const name = nameMatch[1];
236
+
237
+ alternatives.push(
238
+ `getByRole('${role}', { name: /${name}/i })`,
239
+ `getByText('${name}')`,
240
+ `[aria-label="${name}"]`,
241
+ `button:has-text("${name}")`,
242
+ );
243
+ }
244
+ }
245
+
246
+ // getByText → alternatives
247
+ if (selector.includes("getByText")) {
248
+ const textMatch = selector.match(/getByText\(['"](.+?)['"]/);
249
+ if (textMatch) {
250
+ const text = textMatch[1];
251
+ alternatives.push(
252
+ `getByText(/${text}/i)`,
253
+ `text=${text}`,
254
+ `:has-text("${text}")`,
255
+ );
256
+ }
257
+ }
258
+
259
+ // Generic fallbacks
260
+ if (actionType === "click" || actionType === "fill") {
261
+ alternatives.push(
262
+ `[data-testid="${selector}"]`,
263
+ `[aria-label="${selector}"]`,
264
+ `[name="${selector}"]`,
265
+ );
266
+ }
267
+
268
+ // Remove duplicates
269
+ return [...new Set(alternatives)];
270
+ }
package/src/types.ts ADDED
@@ -0,0 +1,106 @@
1
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
2
+ export type JsonObject = { [k: string]: JsonValue };
3
+
4
+ export type OracleLevel = "L0" | "L1" | "L2" | "L3";
5
+
6
+ export interface AtePaths {
7
+ repoRoot: string;
8
+ manduDir: string; // .mandu
9
+ interactionGraphPath: string;
10
+ selectorMapPath: string;
11
+ scenariosPath: string;
12
+ reportsDir: string;
13
+ autoE2eDir: string;
14
+ manualE2eDir: string;
15
+ }
16
+
17
+ export interface ExtractInput {
18
+ repoRoot: string;
19
+ tsconfigPath?: string;
20
+ routeGlobs?: string[];
21
+ buildSalt?: string;
22
+ }
23
+
24
+ export interface InteractionGraph {
25
+ schemaVersion: 1;
26
+ generatedAt: string;
27
+ buildSalt: string;
28
+ nodes: InteractionNode[];
29
+ edges: InteractionEdge[];
30
+ stats: {
31
+ routes: number;
32
+ navigations: number;
33
+ modals: number;
34
+ actions: number;
35
+ };
36
+ }
37
+
38
+ export type InteractionNode =
39
+ | { kind: "route"; id: string; file: string; path: string }
40
+ | { kind: "modal"; id: string; file: string; name: string }
41
+ | { kind: "action"; id: string; file: string; name: string };
42
+
43
+ export type InteractionEdge =
44
+ | { kind: "navigate"; from?: string; to: string; file: string; source: string }
45
+ | { kind: "openModal"; from?: string; modal: string; file: string; source: string }
46
+ | { kind: "runAction"; from?: string; action: string; file: string; source: string };
47
+
48
+ export interface GenerateInput {
49
+ repoRoot: string;
50
+ oracleLevel?: OracleLevel;
51
+ onlyRoutes?: string[];
52
+ }
53
+
54
+ export interface RunInput {
55
+ repoRoot: string;
56
+ baseURL?: string;
57
+ ci?: boolean;
58
+ headless?: boolean;
59
+ browsers?: ("chromium" | "firefox" | "webkit")[];
60
+ }
61
+
62
+ export interface ImpactInput {
63
+ repoRoot: string;
64
+ base?: string;
65
+ head?: string;
66
+ }
67
+
68
+ export interface HealInput {
69
+ repoRoot: string;
70
+ runId: string;
71
+ }
72
+
73
+ export interface SummaryJson {
74
+ schemaVersion: 1;
75
+ runId: string;
76
+ startedAt: string;
77
+ finishedAt: string;
78
+ ok: boolean;
79
+ oracle: {
80
+ level: OracleLevel;
81
+ l0: { ok: boolean; errors: string[] };
82
+ l1: { ok: boolean; signals: string[] };
83
+ l2: { ok: boolean; signals: string[] };
84
+ l3: { ok: boolean; notes: string[] };
85
+ };
86
+ playwright: {
87
+ exitCode: number;
88
+ reportDir: string;
89
+ jsonReportPath?: string;
90
+ junitPath?: string;
91
+ };
92
+ mandu: {
93
+ interactionGraphPath?: string;
94
+ selectorMapPath?: string;
95
+ scenariosPath?: string;
96
+ };
97
+ heal: {
98
+ attempted: boolean;
99
+ suggestions: Array<{ kind: string; title: string; diff: string }>;
100
+ };
101
+ impact: {
102
+ mode: "full" | "subset";
103
+ changedFiles: string[];
104
+ selectedRoutes: string[];
105
+ };
106
+ }