@selfcure/runner 0.1.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/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # @selfcure/runner
2
+
3
+ > Playwright execution layer for **selfcure** — runs tests programmatically and captures traces on failure.
4
+
5
+ Part of selfcure's **legacy BYOK pipeline**. Wraps the `@playwright/test` programmatic API to execute generated specs and capture traces/errors for the self-healing loop.
6
+
7
+ Internal library powering `selfcure run`. The headline, preventive flow is `selfcure lint` — see [`@selfcure/cli`](https://www.npmjs.com/package/@selfcure/cli).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @selfcure/runner
13
+ ```
14
+
15
+ ## Docs
16
+
17
+ Full documentation: https://github.com/ricardofrancocustodio/selfcure#readme
@@ -0,0 +1,133 @@
1
+ import { WcagLevel, AccessibilityFinding, A11ySeverity } from '@selfcure/analyzer';
2
+
3
+ interface DynamicScanRoute {
4
+ /** Relative route path, e.g. "/" or "/checkout/summary" */
5
+ path: string;
6
+ /** Optional: Playwright storage-state JSON path for pre-authenticated sessions */
7
+ storageState?: string;
8
+ /** Wait condition after navigation — default: 'networkidle' */
9
+ waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
10
+ }
11
+ interface DynamicScanOptions {
12
+ /** Base URL of the running app, e.g. "http://localhost:3000" */
13
+ baseURL: string;
14
+ /** Routes to scan — defaults to [{path: '/'}] */
15
+ routes?: DynamicScanRoute[] | string[];
16
+ /** WCAG level to check — default 'AA' */
17
+ level?: WcagLevel;
18
+ /** Page navigation timeout ms — default 30000 */
19
+ timeout?: number;
20
+ /** Run browser without UI — default true */
21
+ headless?: boolean;
22
+ /**
23
+ * URL or local path to axe-core script.
24
+ * Defaults to the axe-core CDN. For offline use, point to
25
+ * node_modules/axe-core/axe.min.js or a local copy.
26
+ */
27
+ axeSource?: string;
28
+ }
29
+ interface DynamicScanResult {
30
+ findings: AccessibilityFinding[];
31
+ scannedRoutes: number;
32
+ errors: Array<{
33
+ route: string;
34
+ error: string;
35
+ }>;
36
+ }
37
+ /** Extract WCAG criterion numbers like "4.1.2" from axe tags like "wcag412". */
38
+ declare function wcagRefsFromTags(tags: string[]): string[];
39
+ /** Map axe impact string to our A11ySeverity. */
40
+ declare function impactToSeverity(impact: string | null): A11ySeverity;
41
+ /** Derive the highest applicable WCAG level from axe tags. */
42
+ declare function levelFromTags(tags: string[]): WcagLevel;
43
+ /**
44
+ * Launch a Playwright browser, navigate to each configured route, inject
45
+ * axe-core, run WCAG checks, and return findings mapped to AccessibilityFinding[].
46
+ *
47
+ * Requires Playwright browsers to be installed (`npx playwright install`).
48
+ * Requires the target app to be running and accessible at baseURL.
49
+ */
50
+ declare function runDynamicScan(opts: DynamicScanOptions): Promise<DynamicScanResult>;
51
+
52
+ interface RuntimeElement {
53
+ tag: string;
54
+ role?: string;
55
+ /** Accessible name — aria-label or visible text */
56
+ name?: string;
57
+ /** Best selector available */
58
+ selector: string;
59
+ testId?: string;
60
+ /** Testability score 0–100 */
61
+ score: number;
62
+ }
63
+ interface RuntimeRouteEvidence {
64
+ url: string;
65
+ route: string;
66
+ status: 'reachable' | 'error' | 'timeout' | 'auth-required';
67
+ title?: string;
68
+ domSnapshotPath?: string;
69
+ accessibilityTreePath?: string;
70
+ screenshotPath?: string;
71
+ interactiveElements: RuntimeElement[];
72
+ consoleErrors: string[];
73
+ loadTimeMs: number;
74
+ }
75
+ interface RuntimeDiscoveryOptions {
76
+ baseURL: string;
77
+ routes: string[];
78
+ outDir: string;
79
+ timeout?: number;
80
+ headless?: boolean;
81
+ storageState?: string;
82
+ screenshots?: boolean;
83
+ }
84
+ interface RuntimeDiscoveryResult {
85
+ routes: RuntimeRouteEvidence[];
86
+ scannedRoutes: number;
87
+ reachable: number;
88
+ errored: number;
89
+ }
90
+ /** Compute a testability score for a single element from its attributes. */
91
+ declare function scoreElement(opts: {
92
+ testId?: string;
93
+ id?: string;
94
+ name?: string;
95
+ type?: string;
96
+ tag: string;
97
+ }): number;
98
+ /** Build the best CSS selector for an element from its attributes. */
99
+ declare function buildRuntimeSelector(opts: {
100
+ testId?: string;
101
+ id?: string;
102
+ name?: string;
103
+ tag: string;
104
+ }): string;
105
+ /**
106
+ * Launch Playwright, visit each route, and capture runtime evidence.
107
+ * Requires the app to be running at `baseURL`.
108
+ */
109
+ declare function runRuntimeDiscovery(opts: RuntimeDiscoveryOptions): Promise<RuntimeDiscoveryResult>;
110
+
111
+ interface RunOptions {
112
+ /** Absolute path to playwright.config.ts */
113
+ playwrightConfig: string;
114
+ /** Subset of test files to run; omit to run all */
115
+ testFiles?: string[];
116
+ /** Base URL forwarded to Playwright via env */
117
+ baseURL?: string;
118
+ }
119
+ interface TestResult {
120
+ filePath: string;
121
+ passed: boolean;
122
+ error?: string;
123
+ /** Absolute path to the .zip trace if Playwright captured one */
124
+ tracePath?: string;
125
+ durationMs: number;
126
+ }
127
+ /**
128
+ * Execute Playwright tests via the CLI and parse the JSON reporter output.
129
+ * Always enables `--trace on-first-retry` so healer has trace data on failure.
130
+ */
131
+ declare function run(options: RunOptions): Promise<TestResult[]>;
132
+
133
+ export { type DynamicScanOptions, type DynamicScanResult, type DynamicScanRoute, type RunOptions, type RuntimeDiscoveryOptions, type RuntimeDiscoveryResult, type RuntimeElement, type RuntimeRouteEvidence, type TestResult, buildRuntimeSelector, run as default, impactToSeverity, levelFromTags, run, runDynamicScan, runRuntimeDiscovery, scoreElement, wcagRefsFromTags };
package/dist/index.js ADDED
@@ -0,0 +1,367 @@
1
+ // src/index.ts
2
+ import { execFile } from "child_process";
3
+ import { promisify } from "util";
4
+ import path3 from "path";
5
+
6
+ // src/a11y/dynamic.ts
7
+ import { chromium } from "@playwright/test";
8
+ import { readFile } from "fs/promises";
9
+ import path from "path";
10
+ var DEFAULT_AXE_CDN = "https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js";
11
+ var WCAG_TAGS = {
12
+ A: ["wcag2a"],
13
+ AA: ["wcag2a", "wcag2aa"],
14
+ AAA: ["wcag2a", "wcag2aa", "wcag2aaa"]
15
+ };
16
+ var IMPACT_SEVERITY = {
17
+ critical: "critical",
18
+ serious: "major",
19
+ moderate: "minor",
20
+ minor: "info"
21
+ };
22
+ var _seq = 0;
23
+ function makeFindingId() {
24
+ _seq++;
25
+ return `a11y_dyn_${Date.now().toString(36)}${_seq.toString(36).padStart(3, "0")}`;
26
+ }
27
+ function wcagRefsFromTags(tags) {
28
+ const refs = [];
29
+ for (const tag of tags) {
30
+ if (!/^wcag\d+$/.test(tag)) continue;
31
+ const d = tag.slice(4);
32
+ if (d.length === 3) refs.push(`${d[0]}.${d[1]}.${d[2]}`);
33
+ else if (d.length === 2) refs.push(`${d[0]}.${d[1]}`);
34
+ else refs.push(d);
35
+ }
36
+ return refs;
37
+ }
38
+ function impactToSeverity(impact) {
39
+ return IMPACT_SEVERITY[impact ?? ""] ?? "minor";
40
+ }
41
+ function levelFromTags(tags) {
42
+ if (tags.includes("wcag2aaa")) return "AAA";
43
+ if (tags.includes("wcag2aa")) return "AA";
44
+ return "A";
45
+ }
46
+ async function injectAxe(page, source) {
47
+ if (/^https?:\/\//.test(source)) {
48
+ await page.addScriptTag({ url: source });
49
+ } else {
50
+ const abs = path.resolve(source);
51
+ const content = await readFile(abs, "utf-8");
52
+ await page.addScriptTag({ content });
53
+ }
54
+ }
55
+ function mapViolations(violations, pageUrl, now) {
56
+ const findings = [];
57
+ for (const v of violations) {
58
+ const severity = impactToSeverity(v.impact);
59
+ const level = levelFromTags(v.tags);
60
+ const wcag = wcagRefsFromTags(v.tags);
61
+ const ruleId = `a11y-dynamic.${v.id}`;
62
+ for (let nodeIdx = 0; nodeIdx < v.nodes.length; nodeIdx++) {
63
+ const node = v.nodes[nodeIdx];
64
+ const selector = node.target.join(", ");
65
+ findings.push({
66
+ id: makeFindingId(),
67
+ ruleId,
68
+ wcag,
69
+ level,
70
+ severity,
71
+ status: "open",
72
+ sourceFile: pageUrl,
73
+ line: 0,
74
+ column: nodeIdx,
75
+ // used as finding-key differentiator per node
76
+ selector,
77
+ message: v.help,
78
+ remediation: node.failureSummary || v.description,
79
+ firstSeenAt: now,
80
+ lastSeenAt: now
81
+ });
82
+ }
83
+ }
84
+ return findings;
85
+ }
86
+ async function scanPage(page, url, level, source, timeout, waitUntil) {
87
+ await page.goto(url, { timeout, waitUntil });
88
+ await injectAxe(page, source);
89
+ const results = await page.evaluate((tags) => {
90
+ const g = globalThis;
91
+ return g.axe.run(g.document, { runOnly: { type: "tag", values: tags } });
92
+ }, WCAG_TAGS[level]);
93
+ const now = (/* @__PURE__ */ new Date()).toISOString();
94
+ return mapViolations(results.violations, url, now);
95
+ }
96
+ async function runDynamicScan(opts) {
97
+ const {
98
+ baseURL,
99
+ level = "AA",
100
+ timeout = 3e4,
101
+ headless = true,
102
+ axeSource = DEFAULT_AXE_CDN
103
+ } = opts;
104
+ const rawRoutes = opts.routes ?? ["/"];
105
+ const routes = rawRoutes.map(
106
+ (r) => typeof r === "string" ? { path: r } : r
107
+ );
108
+ const allFindings = [];
109
+ const errors = [];
110
+ const browser = await chromium.launch({ headless });
111
+ try {
112
+ for (const route of routes) {
113
+ const url = new URL(route.path, baseURL).toString();
114
+ const ctx = await browser.newContext(
115
+ route.storageState ? { storageState: route.storageState } : {}
116
+ );
117
+ const page = await ctx.newPage();
118
+ try {
119
+ const findings = await scanPage(
120
+ page,
121
+ url,
122
+ level,
123
+ axeSource,
124
+ timeout,
125
+ route.waitUntil ?? "networkidle"
126
+ );
127
+ allFindings.push(...findings);
128
+ } catch (err) {
129
+ errors.push({
130
+ route: url,
131
+ error: err instanceof Error ? err.message : String(err)
132
+ });
133
+ } finally {
134
+ await ctx.close();
135
+ }
136
+ }
137
+ } finally {
138
+ await browser.close();
139
+ }
140
+ return { findings: allFindings, scannedRoutes: routes.length, errors };
141
+ }
142
+
143
+ // src/discovery/runtime.ts
144
+ import { chromium as chromium2 } from "@playwright/test";
145
+ import { writeFile, mkdir } from "fs/promises";
146
+ import path2 from "path";
147
+ function scoreElement(opts) {
148
+ if (opts.testId) return 100;
149
+ if (opts.id) return 85;
150
+ if (opts.name) return 75;
151
+ if (opts.type) return 50;
152
+ return opts.tag === "button" || opts.tag === "a" ? 30 : 20;
153
+ }
154
+ function buildRuntimeSelector(opts) {
155
+ if (opts.testId) return `[data-testid="${opts.testId}"]`;
156
+ if (opts.id) return `#${opts.id}`;
157
+ if (opts.name) return `[aria-label="${opts.name}"]`;
158
+ return opts.tag;
159
+ }
160
+ var DOM_SCAN_SELECTORS = [
161
+ "button",
162
+ "input:not([type=hidden])",
163
+ "select",
164
+ "textarea",
165
+ "a[href]",
166
+ "[role=button]",
167
+ "[role=link]",
168
+ "[role=textbox]",
169
+ "[role=checkbox]",
170
+ "[role=radio]",
171
+ "[role=combobox]"
172
+ ];
173
+ async function extractElements(page) {
174
+ const raw = await page.evaluate((selectors) => {
175
+ const g = globalThis;
176
+ const doc = g.document;
177
+ const seen = /* @__PURE__ */ new Set();
178
+ const out = [];
179
+ for (const sel of selectors) {
180
+ for (const el of doc.querySelectorAll(sel)) {
181
+ if (seen.has(el)) continue;
182
+ seen.add(el);
183
+ const tag = el.tagName.toLowerCase();
184
+ const testId = el.getAttribute("data-testid") || void 0;
185
+ const id = el.id || void 0;
186
+ const aLabel = el.getAttribute("aria-label") || void 0;
187
+ const text = el.textContent?.trim().slice(0, 60) || void 0;
188
+ const type = el.getAttribute("type") || void 0;
189
+ const role = el.getAttribute("role") || void 0;
190
+ out.push({ tag, role, testId, id, name: aLabel || text, type });
191
+ }
192
+ }
193
+ return out;
194
+ }, DOM_SCAN_SELECTORS);
195
+ return raw.map((r) => {
196
+ const score = scoreElement(r);
197
+ const selector = buildRuntimeSelector(r);
198
+ const el = { tag: r.tag, selector, score };
199
+ if (r.role) el.role = r.role;
200
+ if (r.name) el.name = r.name;
201
+ if (r.testId) el.testId = r.testId;
202
+ return el;
203
+ });
204
+ }
205
+ function safeName(route) {
206
+ return route.replace(/^\//, "").replace(/[^a-z0-9]/gi, "-").replace(/-+/g, "-").slice(0, 48) || "root";
207
+ }
208
+ async function renderRoute(page, url, route, outDir, timeout, screenshots) {
209
+ const consoleErrors = [];
210
+ const onConsole = (msg) => {
211
+ if (msg.type() === "error") consoleErrors.push(msg.text());
212
+ };
213
+ page.on("console", onConsole);
214
+ const t0 = Date.now();
215
+ let status = "reachable";
216
+ let title;
217
+ try {
218
+ const response = await page.goto(url, { timeout, waitUntil: "networkidle" });
219
+ const httpStatus = response?.status() ?? 200;
220
+ if (httpStatus === 401 || httpStatus === 403) status = "auth-required";
221
+ title = await page.title().catch(() => void 0);
222
+ } catch (err) {
223
+ status = String(err).toLowerCase().includes("timeout") ? "timeout" : "error";
224
+ }
225
+ const loadTimeMs = Date.now() - t0;
226
+ let domSnapshotPath;
227
+ let accessibilityTreePath;
228
+ let screenshotPath;
229
+ let interactiveElements = [];
230
+ if (status === "reachable") {
231
+ const base = safeName(route);
232
+ const domDir = path2.join(outDir, "dom-snapshots");
233
+ await mkdir(domDir, { recursive: true });
234
+ const domFile = path2.join(domDir, `${base}.html`);
235
+ await writeFile(domFile, await page.content(), "utf-8");
236
+ domSnapshotPath = domFile;
237
+ const a11ySnapshot = await page.locator("body").ariaSnapshot().catch(() => null);
238
+ if (a11ySnapshot) {
239
+ const treeDir = path2.join(outDir, "accessibility-trees");
240
+ await mkdir(treeDir, { recursive: true });
241
+ const treeFile = path2.join(treeDir, `${base}.aria.txt`);
242
+ await writeFile(treeFile, a11ySnapshot, "utf-8");
243
+ accessibilityTreePath = treeFile;
244
+ }
245
+ if (screenshots) {
246
+ const ssDir = path2.join(outDir, "screenshots");
247
+ await mkdir(ssDir, { recursive: true });
248
+ const ssFile = path2.join(ssDir, `${base}.png`);
249
+ await page.screenshot({ path: ssFile, fullPage: true }).catch(() => {
250
+ });
251
+ screenshotPath = ssFile;
252
+ }
253
+ interactiveElements = await extractElements(page).catch(() => []);
254
+ }
255
+ page.off("console", onConsole);
256
+ return {
257
+ url,
258
+ route,
259
+ status,
260
+ title,
261
+ domSnapshotPath,
262
+ accessibilityTreePath,
263
+ screenshotPath,
264
+ interactiveElements,
265
+ consoleErrors,
266
+ loadTimeMs
267
+ };
268
+ }
269
+ async function runRuntimeDiscovery(opts) {
270
+ const {
271
+ baseURL,
272
+ routes,
273
+ outDir,
274
+ timeout = 3e4,
275
+ headless = true,
276
+ storageState,
277
+ screenshots = false
278
+ } = opts;
279
+ const browser = await chromium2.launch({ headless });
280
+ const results = [];
281
+ try {
282
+ for (const route of routes) {
283
+ const url = new URL(route, baseURL).toString();
284
+ const ctx = await browser.newContext(storageState ? { storageState } : {});
285
+ const pg = await ctx.newPage();
286
+ try {
287
+ const evidence = await renderRoute(pg, url, route, outDir, timeout, screenshots);
288
+ results.push(evidence);
289
+ } catch (err) {
290
+ results.push({
291
+ url,
292
+ route,
293
+ status: "error",
294
+ interactiveElements: [],
295
+ consoleErrors: [String(err)],
296
+ loadTimeMs: 0
297
+ });
298
+ } finally {
299
+ await ctx.close();
300
+ }
301
+ }
302
+ } finally {
303
+ await browser.close();
304
+ }
305
+ return {
306
+ routes: results,
307
+ scannedRoutes: routes.length,
308
+ reachable: results.filter((r) => r.status === "reachable").length,
309
+ errored: results.filter((r) => r.status !== "reachable").length
310
+ };
311
+ }
312
+
313
+ // src/index.ts
314
+ var execFileAsync = promisify(execFile);
315
+ async function run(options) {
316
+ const args = [
317
+ "playwright",
318
+ "test",
319
+ "--reporter=json",
320
+ "--trace=on-first-retry",
321
+ `--config=${options.playwrightConfig}`,
322
+ ...options.testFiles ?? []
323
+ ];
324
+ const env = {
325
+ ...process.env,
326
+ ...options.baseURL ? { PLAYWRIGHT_BASE_URL: options.baseURL } : {}
327
+ };
328
+ const results = [];
329
+ try {
330
+ const { stdout } = await execFileAsync("npx", args, {
331
+ env,
332
+ cwd: path3.dirname(options.playwrightConfig)
333
+ });
334
+ const report = JSON.parse(stdout);
335
+ for (const suite of report.suites) {
336
+ for (const spec of suite.specs) {
337
+ const lastResult = spec.tests[0]?.results.at(-1);
338
+ results.push({
339
+ filePath: spec.file,
340
+ passed: spec.ok,
341
+ error: lastResult?.error?.message,
342
+ durationMs: lastResult?.duration ?? 0
343
+ });
344
+ }
345
+ }
346
+ } catch (err) {
347
+ results.push({
348
+ filePath: "<unknown>",
349
+ passed: false,
350
+ error: String(err),
351
+ durationMs: 0
352
+ });
353
+ }
354
+ return results;
355
+ }
356
+ var index_default = run;
357
+ export {
358
+ buildRuntimeSelector,
359
+ index_default as default,
360
+ impactToSeverity,
361
+ levelFromTags,
362
+ run,
363
+ runDynamicScan,
364
+ runRuntimeDiscovery,
365
+ scoreElement,
366
+ wcagRefsFromTags
367
+ };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@selfcure/runner",
3
+ "version": "0.1.0",
4
+ "description": "Executes Playwright tests programmatically and captures traces on failure",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "ricardofrancocustodio",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ricardofrancocustodio/selfcure.git",
11
+ "directory": "packages/runner"
12
+ },
13
+ "homepage": "https://github.com/ricardofrancocustodio/selfcure#readme",
14
+ "main": "dist/index.js",
15
+ "types": "dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": ["dist"],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "build": "tsup src/index.ts --format esm --dts",
28
+ "dev": "tsup src/index.ts --format esm --watch",
29
+ "test": "vitest"
30
+ },
31
+ "dependencies": {
32
+ "@playwright/test": "1.60.0"
33
+ }
34
+ }