diff --git a/src/app/options/profile-summary.test.ts b/src/app/options/profile-summary.test.ts index e61617a..2c59311 100644 --- a/src/app/options/profile-summary.test.ts +++ b/src/app/options/profile-summary.test.ts @@ -9,7 +9,6 @@ describe("profile summary formatting", () => { it("formats record summary", () => { const out = formatRecordingProfileSummary({ browser: "chromium", - selectorPolicy: "reliable", device: "iPhone 13", testIdAttribute: "data-qa", loadStorage: ".auth/in.json", @@ -17,7 +16,6 @@ describe("profile summary formatting", () => { }); expect(out).toContain("browser=chromium"); - expect(out).toContain("selectorPolicy=reliable"); expect(out).toContain("loadStorage=.auth/in.json"); }); diff --git a/src/app/options/profile-summary.ts b/src/app/options/profile-summary.ts index 66c0409..f4868bd 100644 --- a/src/app/options/profile-summary.ts +++ b/src/app/options/profile-summary.ts @@ -1,15 +1,13 @@ import type { RecordBrowser } from "../../core/recorder.js"; -import type { SelectorPolicy } from "./record-profile.js"; export function formatRecordingProfileSummary(profile: { browser: RecordBrowser; - selectorPolicy: SelectorPolicy; device?: string; testIdAttribute?: string; loadStorage?: string; saveStorage?: string; }): string { - return `Recording profile: browser=${profile.browser}, selectorPolicy=${profile.selectorPolicy}, device=${profile.device ?? "(none)"}, testIdAttr=${profile.testIdAttribute ?? "(default)"}, loadStorage=${profile.loadStorage ?? "(none)"}, saveStorage=${profile.saveStorage ?? "(none)"}`; + return `Recording profile: browser=${profile.browser}, device=${profile.device ?? "(none)"}, testIdAttr=${profile.testIdAttribute ?? "(default)"}, loadStorage=${profile.loadStorage ?? "(none)"}, saveStorage=${profile.saveStorage ?? "(none)"}`; } export function formatImproveProfileSummary(profile: { diff --git a/src/app/options/record-profile.test.ts b/src/app/options/record-profile.test.ts index 8f2d4a2..dcea8a3 100644 --- a/src/app/options/record-profile.test.ts +++ b/src/app/options/record-profile.test.ts @@ -3,14 +3,12 @@ import { UserError } from "../../utils/errors.js"; import { normalizeRecordUrl, parseRecordBrowser, - parseSelectorPolicy, resolveRecordProfile, } from "./record-profile.js"; describe("resolveRecordProfile", () => { it("applies CLI values and normalizes optionals", () => { const out = resolveRecordProfile({ - selectorPolicy: "raw", browser: "firefox", device: " iPhone 13 ", testIdAttribute: " data-qa ", @@ -19,7 +17,6 @@ describe("resolveRecordProfile", () => { }); expect(out).toEqual({ - selectorPolicy: "raw", browser: "firefox", device: "iPhone 13", testIdAttribute: "data-qa", @@ -31,7 +28,6 @@ describe("resolveRecordProfile", () => { it("uses defaults when CLI values are unset", () => { const out = resolveRecordProfile({}); - expect(out.selectorPolicy).toBe("reliable"); expect(out.browser).toBe("chromium"); expect(out.outputDir).toBe("e2e"); }); @@ -39,12 +35,10 @@ describe("resolveRecordProfile", () => { describe("record-profile parsing", () => { it("parses valid enums", () => { - expect(parseSelectorPolicy("RAW")).toBe("raw"); expect(parseRecordBrowser("Webkit")).toBe("webkit"); }); it("rejects invalid enums", () => { - expect(() => parseSelectorPolicy("fast")).toThrow(UserError); expect(() => parseRecordBrowser("safari")).toThrow(UserError); }); diff --git a/src/app/options/record-profile.ts b/src/app/options/record-profile.ts index 70cb592..5e96280 100644 --- a/src/app/options/record-profile.ts +++ b/src/app/options/record-profile.ts @@ -2,10 +2,7 @@ import type { RecordBrowser } from "../../core/recorder.js"; import { PLAY_DEFAULT_TEST_DIR } from "../../core/play/play-defaults.js"; import { UserError } from "../../utils/errors.js"; -export type SelectorPolicy = "reliable" | "raw"; - export interface RecordProfileInput { - selectorPolicy?: string; browser?: string; device?: string; testIdAttribute?: string; @@ -15,7 +12,6 @@ export interface RecordProfileInput { } export interface ResolvedRecordProfile { - selectorPolicy: SelectorPolicy; browser: RecordBrowser; device?: string; testIdAttribute?: string; @@ -28,7 +24,6 @@ export function resolveRecordProfile( input: RecordProfileInput ): ResolvedRecordProfile { const profile: ResolvedRecordProfile = { - selectorPolicy: parseSelectorPolicy(input.selectorPolicy) ?? "reliable", browser: parseRecordBrowser(input.browser) ?? "chromium", outputDir: input.outputDir ?? PLAY_DEFAULT_TEST_DIR, }; @@ -52,18 +47,6 @@ function cleanOptional(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -export function parseSelectorPolicy(value: string | undefined): SelectorPolicy | undefined { - if (!value) return undefined; - const normalized = value.trim().toLowerCase(); - if (normalized === "reliable" || normalized === "raw") { - return normalized; - } - throw new UserError( - `Invalid selector policy: ${value}`, - "Use --selector-policy reliable or --selector-policy raw" - ); -} - export function parseRecordBrowser(value: string | undefined): RecordBrowser | undefined { if (!value) return undefined; const normalized = value.trim().toLowerCase(); diff --git a/src/app/services/record-service.test.ts b/src/app/services/record-service.test.ts index 47dde7f..8941ef2 100644 --- a/src/app/services/record-service.test.ts +++ b/src/app/services/record-service.test.ts @@ -1,13 +1,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { UserError } from "../../utils/errors.js"; +vi.mock("node:fs/promises", () => ({ + default: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + }, +})); + vi.mock("../../utils/chromium-runtime.js", () => ({ ensureChromiumAvailable: vi.fn(), })); -vi.mock("../../core/recorder.js", () => ({ - record: vi.fn(), -})); +vi.mock("../../core/recorder.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + record: vi.fn(), + }; +}); vi.mock("../../core/improve/improve.js", () => ({ improveTestFile: vi.fn(), @@ -25,6 +37,7 @@ vi.mock("../../utils/ui.js", () => ({ }, })); +import fs from "node:fs/promises"; import { ensureChromiumAvailable } from "../../utils/chromium-runtime.js"; import { record } from "../../core/recorder.js"; import { improveTestFile } from "../../core/improve/improve.js"; @@ -34,14 +47,8 @@ import { runRecord } from "./record-service.js"; function mockRecordDefaults() { vi.mocked(record).mockResolvedValue({ outputPath: "e2e/sample.yaml", - stats: { - selectorSteps: 1, - stableSelectors: 1, - fallbackSelectors: 0, - frameAwareSelectors: 0, - }, - recordingMode: "jsonl", - degraded: false, + stepCount: 2, + recordingMode: "codegen", }); vi.mocked(improveTestFile).mockResolvedValue({ report: { @@ -283,3 +290,105 @@ describe("runRecord auto-improve", () => { ); }); }); + +describe("runRecordFromFile", () => { + const validRecording = JSON.stringify({ + title: "Login Flow", + steps: [ + { type: "navigate", url: "https://example.com/login" }, + { type: "click", selectors: [["aria/Submit[role=\"button\"]"]] }, + ], + }); + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(fs.readFile).mockResolvedValue(validRecording); + vi.mocked(fs.writeFile).mockResolvedValue(); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(improveTestFile).mockResolvedValue({ + report: { + testFile: "e2e/login-flow.yaml", + generatedAt: new Date().toISOString(), + providerUsed: "playwright", + summary: { + unchanged: 1, + improved: 0, + fallback: 0, + warnings: 0, + assertionCandidates: 0, + appliedAssertions: 0, + skippedAssertions: 0, + }, + stepFindings: [], + assertionCandidates: [], + diagnostics: [], + }, + reportPath: "e2e/login-flow.improve-report.json", + }); + }); + + it("imports a valid DevTools recording JSON file", async () => { + await runRecord({ fromFile: "/tmp/recording.json" }); + + expect(fs.readFile).toHaveBeenCalledWith("/tmp/recording.json", "utf-8"); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining("login-flow.yaml"), + expect.stringContaining("name: Login Flow"), + "utf-8" + ); + expect(ui.success).toHaveBeenCalledWith( + expect.stringContaining("Test saved to") + ); + }); + + it("throws UserError when file does not exist", async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT")); + + await expect( + runRecord({ fromFile: "/tmp/missing.json" }) + ).rejects.toBeInstanceOf(UserError); + await expect( + runRecord({ fromFile: "/tmp/missing.json" }) + ).rejects.toThrow("Could not read file"); + }); + + it("throws UserError when file contains no supported steps", async () => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ title: "Empty", steps: [{ type: "scroll" }] }) + ); + + await expect( + runRecord({ fromFile: "/tmp/empty.json" }) + ).rejects.toBeInstanceOf(UserError); + await expect( + runRecord({ fromFile: "/tmp/empty.json" }) + ).rejects.toThrow("No supported interactions"); + }); + + it("uses recording title as test name, falling back to filename", async () => { + await runRecord({ fromFile: "/tmp/recording.json" }); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining("login-flow.yaml"), + expect.stringContaining("name: Login Flow"), + "utf-8" + ); + + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + steps: [{ type: "navigate", url: "https://example.com" }], + }) + ); + await runRecord({ fromFile: "/tmp/my-test.json" }); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining("my-test.yaml"), + expect.stringContaining("name: my-test"), + "utf-8" + ); + }); + + it("skips auto-improve when improve is false", async () => { + await runRecord({ fromFile: "/tmp/recording.json", improve: false }); + + expect(improveTestFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/services/record-service.ts b/src/app/services/record-service.ts index 8e48309..e3cd2f4 100644 --- a/src/app/services/record-service.ts +++ b/src/app/services/record-service.ts @@ -1,5 +1,7 @@ +import fs from "node:fs/promises"; +import path from "node:path"; import { input } from "@inquirer/prompts"; -import { record as runRecording, type RecordOptions } from "../../core/recorder.js"; +import { record as runRecording, normalizeFirstNavigate, slugify, type RecordOptions } from "../../core/recorder.js"; import { improveTestFile } from "../../core/improve/improve.js"; import { PLAY_DEFAULT_BASE_URL, PLAY_DEFAULT_TEST_DIR } from "../../core/play/play-defaults.js"; import { resolveRecordProfile, hasUrlProtocol, normalizeRecordUrl } from "../options/record-profile.js"; @@ -8,22 +10,29 @@ import { ensureChromiumAvailable } from "../../utils/chromium-runtime.js"; import { UserError } from "../../utils/errors.js"; import { ui } from "../../utils/ui.js"; import { defaultRunInteractiveCommand } from "../../infra/process/process-runner-adapter.js"; +import { devtoolsRecordingToSteps } from "../../core/transform/devtools-recording-adapter.js"; +import { stepsToYaml } from "../../core/transform/yaml-io.js"; export interface RecordCliOptions { name?: string; url?: string; description?: string; outputDir?: string; - selectorPolicy?: string; browser?: string; device?: string; testIdAttribute?: string; loadStorage?: string; saveStorage?: string; + fromFile?: string; improve?: boolean; } export async function runRecord(opts: RecordCliOptions): Promise { + if (opts.fromFile) { + await runRecordFromFile(opts); + return; + } + const name = opts.name ?? (await input({ @@ -76,14 +85,12 @@ export async function runRecord(opts: RecordCliOptions): Promise { const summaryOptions: { browser: typeof profile.browser; - selectorPolicy: typeof profile.selectorPolicy; device?: string; testIdAttribute?: string; loadStorage?: string; saveStorage?: string; } = { browser: profile.browser, - selectorPolicy: profile.selectorPolicy, }; if (profile.device !== undefined) summaryOptions.device = profile.device; if (profile.testIdAttribute !== undefined) { @@ -97,7 +104,6 @@ export async function runRecord(opts: RecordCliOptions): Promise { name, url, outputDir: profile.outputDir, - selectorPolicy: profile.selectorPolicy, browser: profile.browser, }; const cleanedDescription = description || undefined; @@ -120,12 +126,7 @@ export async function runRecord(opts: RecordCliOptions): Promise { console.log(); ui.success(`Test saved to ${result.outputPath}`); - ui.info( - `Recording mode: ${result.recordingMode}${result.degraded ? " (degraded fidelity)" : ""}` - ); - ui.info( - `Selector quality: stable=${result.stats.stableSelectors}, fallback=${result.stats.fallbackSelectors}, frame-aware=${result.stats.frameAwareSelectors}` - ); + ui.info(`Recording mode: ${result.recordingMode} (${result.stepCount} steps)`); ui.info("Run it with: ui-test play " + result.outputPath); if (opts.improve !== false) { @@ -177,3 +178,90 @@ export async function runRecord(opts: RecordCliOptions): Promise { } } } + +async function runRecordFromFile(opts: RecordCliOptions): Promise { + const filePath = opts.fromFile!; + + let json: string; + try { + json = await fs.readFile(filePath, "utf-8"); + } catch { + throw new UserError( + `Could not read file: ${filePath}`, + "Make sure the file exists and is a Chrome DevTools Recorder JSON export." + ); + } + + const result = devtoolsRecordingToSteps(json); + if (result.steps.length === 0) { + throw new UserError( + "No supported interactions found in the DevTools recording.", + "Make sure the file is a valid Chrome DevTools Recorder JSON export with click, type, or navigation steps." + ); + } + + const name = + opts.name ?? + result.title ?? + path.basename(filePath, path.extname(filePath)); + + const outputDir = opts.outputDir ?? PLAY_DEFAULT_TEST_DIR; + + const slug = slugify(name) || `test-${Date.now()}`; + const outputPath = path.join(outputDir, `${slug}.yaml`); + + const firstStep = result.steps[0]; + const firstNavigateUrl = + firstStep?.action === "navigate" ? firstStep.url : undefined; + const steps = firstNavigateUrl + ? normalizeFirstNavigate(result.steps, firstNavigateUrl) + : result.steps; + + const yamlOptions: { description?: string; baseUrl?: string } = {}; + if (opts.description) yamlOptions.description = opts.description; + if (firstNavigateUrl) { + try { + const parsed = new URL(firstNavigateUrl); + yamlOptions.baseUrl = `${parsed.protocol}//${parsed.host}`; + } catch { + // ignore invalid URLs + } + } + + const yamlContent = stepsToYaml(name, steps, yamlOptions); + await fs.mkdir(outputDir, { recursive: true }); + await fs.writeFile(outputPath, yamlContent, "utf-8"); + + console.log(); + ui.success(`Test saved to ${outputPath}`); + ui.info(`Imported ${steps.length} steps from DevTools recording (${result.skipped} skipped)`); + ui.info("Run it with: ui-test play " + outputPath); + + if (opts.improve !== false) { + try { + console.log(); + ui.info("Running auto-improve..."); + const improveResult = await improveTestFile({ + testFile: outputPath, + applySelectors: true, + applyAssertions: true, + assertions: "candidates", + }); + + const summary = improveResult.report.summary; + const parts: string[] = []; + if (summary.improved > 0) parts.push(summary.improved + " selectors improved"); + if (summary.appliedAssertions > 0) parts.push(summary.appliedAssertions + " assertions applied"); + + if (parts.length > 0) { + ui.success("Auto-improve: " + parts.join(", ")); + } else { + ui.info("Auto-improve: no changes needed"); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + ui.warn("Auto-improve failed: " + message); + ui.warn("You can run it manually: ui-test improve " + outputPath + " --apply"); + } + } +} diff --git a/src/commands/record.test.ts b/src/commands/record.test.ts index 4ad789e..465da31 100644 --- a/src/commands/record.test.ts +++ b/src/commands/record.test.ts @@ -10,8 +10,6 @@ describe("record command options", () => { expect(command).toBeDefined(); command?.parseOptions([ - "--selector-policy", - "raw", "--browser", "firefox", "--device", @@ -25,7 +23,6 @@ describe("record command options", () => { ]); const opts = command?.opts() as Record; - expect(opts.selectorPolicy).toBe("raw"); expect(opts.browser).toBe("firefox"); expect(opts.device).toBe("iPhone 13"); expect(opts.testIdAttribute).toBe("data-qa"); diff --git a/src/commands/record.ts b/src/commands/record.ts index 1246e82..f2d8da4 100644 --- a/src/commands/record.ts +++ b/src/commands/record.ts @@ -10,13 +10,13 @@ export function registerRecord(program: Command) { .option("-n, --name ", "Test name") .option("-u, --url ", "Starting URL") .option("-d, --description ", "Test description") - .option("--selector-policy ", "Selector policy: reliable or raw") .option("--browser ", "Browser: chromium, firefox, or webkit") .option("--device ", "Playwright device name") .option("--test-id-attribute ", "Custom test-id attribute") .option("-o, --output-dir ", "Output directory for recorded test") .option("--load-storage ", "Path to storage state to preload") .option("--save-storage ", "Path to write resulting storage state") + .option("--from-file ", "Import a Chrome DevTools Recorder JSON export instead of recording") .option("--no-improve", "Skip automatic improvement after recording") .action(async (opts: unknown) => { try { @@ -35,24 +35,24 @@ function parseRecordCliOptions(value: unknown): RecordCliOptions { const url = asOptionalString(value.url); const description = asOptionalString(value.description); const outputDir = asOptionalString(value.outputDir); - const selectorPolicy = asOptionalString(value.selectorPolicy); const browser = asOptionalString(value.browser); const device = asOptionalString(value.device); const testIdAttribute = asOptionalString(value.testIdAttribute); const loadStorage = asOptionalString(value.loadStorage); const saveStorage = asOptionalString(value.saveStorage); + const fromFile = asOptionalString(value.fromFile); const improve = asOptionalBoolean(value.improve); if (name !== undefined) out.name = name; if (url !== undefined) out.url = url; if (description !== undefined) out.description = description; if (outputDir !== undefined) out.outputDir = outputDir; - if (selectorPolicy !== undefined) out.selectorPolicy = selectorPolicy; if (browser !== undefined) out.browser = browser; if (device !== undefined) out.device = device; if (testIdAttribute !== undefined) out.testIdAttribute = testIdAttribute; if (loadStorage !== undefined) out.loadStorage = loadStorage; if (saveStorage !== undefined) out.saveStorage = saveStorage; + if (fromFile !== undefined) out.fromFile = fromFile; if (improve !== undefined) out.improve = improve; return out; @@ -63,12 +63,12 @@ interface RawRecordCliOptions { url?: unknown; description?: unknown; outputDir?: unknown; - selectorPolicy?: unknown; browser?: unknown; device?: unknown; testIdAttribute?: unknown; loadStorage?: unknown; saveStorage?: unknown; + fromFile?: unknown; improve?: unknown; } diff --git a/src/core/recorder-codegen.ts b/src/core/recorder-codegen.ts index 7bf3deb..16a766e 100644 --- a/src/core/recorder-codegen.ts +++ b/src/core/recorder-codegen.ts @@ -1,5 +1,3 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { spawn, type SpawnOptions } from "node:child_process"; import { fileURLToPath } from "node:url"; import type { @@ -8,12 +6,10 @@ import type { } from "./contracts/process-runner.js"; export type CodegenBrowser = "chromium" | "firefox" | "webkit"; -export type JsonlCapability = "supported" | "unsupported" | "unknown"; export interface CodegenRunOptions { url: string; outputFile: string; - target: "jsonl" | "playwright-test"; browser: CodegenBrowser; device?: string; testIdAttribute?: string; @@ -21,44 +17,15 @@ export interface CodegenRunOptions { saveStorage?: string; } -export async function detectJsonlCapability( - playwrightBin: string -): Promise { - if (playwrightBin === "npx") return "unknown"; - - const resolvedCliPath = resolvePlaywrightCliPath(playwrightBin); - if (!resolvedCliPath.includes("node_modules")) return "unknown"; - - const jsonlGeneratorPath = path.resolve( - path.dirname(resolvedCliPath), - "../playwright-core/lib/server/codegen/jsonl.js" - ); - - try { - await fs.access(jsonlGeneratorPath); - return "supported"; - } catch { - return "unsupported"; - } -} - -export function runCodegen( +export async function runCodegen( playwrightBin: string, options: CodegenRunOptions, runInteractiveCommand: RunInteractiveCommand = defaultRunInteractiveCommand -): Promise { - return runCodegenInternal(playwrightBin, options, runInteractiveCommand); -} - -async function runCodegenInternal( - playwrightBin: string, - options: CodegenRunOptions, - runInteractiveCommand: RunInteractiveCommand ): Promise { const argsCore = [ "codegen", "--target", - options.target, + "playwright-test", "--output", options.outputFile, "--browser", diff --git a/src/core/recorder.test.ts b/src/core/recorder.test.ts index ba56ea7..7ce5eef 100644 --- a/src/core/recorder.test.ts +++ b/src/core/recorder.test.ts @@ -13,7 +13,6 @@ vi.mock("node:child_process", () => ({ import { spawn } from "node:child_process"; import { - detectJsonlCapability, normalizeFirstNavigate, record, resolvePlaywrightCliPath, @@ -35,8 +34,7 @@ describe("runCodegen", () => { const run = runCodegen("npx", { url: "http://127.0.0.1:5173", - outputFile: "/tmp/out.jsonl", - target: "jsonl", + outputFile: "/tmp/out.spec.ts", browser: "chromium", }); child.emit("close", 0, null); @@ -50,8 +48,7 @@ describe("runCodegen", () => { const run = runCodegen("npx", { url: "http://127.0.0.1:5173", - outputFile: "/tmp/out.jsonl", - target: "jsonl", + outputFile: "/tmp/out.spec.ts", browser: "chromium", }); child.emit("close", 1, null); @@ -74,21 +71,30 @@ describe("resolvePlaywrightCliPath", () => { describe("record", () => { beforeEach(() => { vi.resetAllMocks(); - delete process.env.UI_TEST_DISABLE_JSONL; }); - it("recovers and saves JSONL steps even when jsonl codegen exits via signal", async () => { + it("records and saves steps from playwright-test codegen output", async () => { vi.spyOn(Date, "now").mockReturnValue(424242); const child = createMockChildProcess(); vi.mocked(spawn).mockReturnValue(child); - const tmpJsonlPath = path.join(os.tmpdir(), "ui-test-recording-424242.jsonl"); - await fs.writeFile(tmpJsonlPath, '{"type":"click","selector":"button"}\n', "utf-8"); + const tmpCodePath = path.join(os.tmpdir(), "ui-test-recording-424242.spec.ts"); + await fs.writeFile( + tmpCodePath, + [ + "import { test } from '@playwright/test';", + "test('x', async ({ page }) => {", + " await page.goto('https://example.com');", + " await page.getByRole('button', { name: 'Save' }).click();", + "});", + ].join("\n"), + "utf-8" + ); const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "ui-test-recorder-test-")); const run = record({ - name: "Recovered Recording", + name: "Codegen Recording", url: "http://127.0.0.1:5173", outputDir, }); @@ -96,33 +102,28 @@ describe("record", () => { await vi.waitFor(() => { expect(spawn).toHaveBeenCalledTimes(1); }); - child.emit("close", null, "SIGTERM"); + child.emit("close", 0, null); const result = await run; const saved = await fs.readFile(result.outputPath, "utf-8"); - expect(result.recordingMode).toBe("jsonl"); - expect(saved).toContain("name: Recovered Recording"); - expect(saved).toContain("target:"); + expect(result.recordingMode).toBe("codegen"); + expect(result.stepCount).toBeGreaterThan(0); + expect(saved).toContain("name: Codegen Recording"); + expect(saved).toContain("action: click"); await fs.rm(outputDir, { recursive: true, force: true }); }); - it("falls back to playwright-test parsing when JSONL is unavailable", async () => { + it("recovers steps even when codegen exits via signal", async () => { vi.spyOn(Date, "now").mockReturnValue(515151); - const jsonlChild = createMockChildProcess(); - const fallbackChild = createMockChildProcess(); - vi.mocked(spawn) - .mockReturnValueOnce(jsonlChild) - .mockReturnValueOnce(fallbackChild); + const child = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(child); - const fallbackCodePath = path.join( - os.tmpdir(), - "ui-test-recording-fallback-515151.spec.ts" - ); + const tmpCodePath = path.join(os.tmpdir(), "ui-test-recording-515151.spec.ts"); await fs.writeFile( - fallbackCodePath, + tmpCodePath, [ "import { test } from '@playwright/test';", "test('x', async ({ page }) => {", @@ -135,7 +136,7 @@ describe("record", () => { const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "ui-test-recorder-test-")); const run = record({ - name: "Fallback Recording", + name: "Recovered Recording", url: "http://127.0.0.1:5173", outputDir, }); @@ -143,35 +144,24 @@ describe("record", () => { await vi.waitFor(() => { expect(spawn).toHaveBeenCalledTimes(1); }); - jsonlChild.emit("close", 1, null); - - await vi.waitFor(() => { - expect(spawn).toHaveBeenCalledTimes(2); - }); - fallbackChild.emit("close", 0, null); + child.emit("close", null, "SIGTERM"); const result = await run; - const saved = await fs.readFile(result.outputPath, "utf-8"); - - expect(result.recordingMode).toBe("fallback"); - expect(result.degraded).toBe(true); - expect(saved).toContain("action: click"); + expect(result.recordingMode).toBe("codegen"); + expect(result.stepCount).toBeGreaterThan(0); await fs.rm(outputDir, { recursive: true, force: true }); }); - it("fails immediately when JSONL succeeds but yields no actionable steps", async () => { + it("throws UserError when codegen produces no steps", async () => { vi.spyOn(Date, "now").mockReturnValue(535353); - const jsonlChild = createMockChildProcess(); - vi.mocked(spawn).mockReturnValueOnce(jsonlChild); - - const tmpJsonlPath = path.join(os.tmpdir(), "ui-test-recording-535353.jsonl"); - await fs.writeFile(tmpJsonlPath, '{"type":"openPage","url":"about:blank"}\n', "utf-8"); + const child = createMockChildProcess(); + vi.mocked(spawn).mockReturnValueOnce(child); const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "ui-test-recorder-test-")); const run = record({ - name: "No Interaction Recording", + name: "Empty Recording", url: "http://127.0.0.1:5173", outputDir, }); @@ -179,7 +169,7 @@ describe("record", () => { await vi.waitFor(() => { expect(spawn).toHaveBeenCalledTimes(1); }); - jsonlChild.emit("close", 0, null); + child.emit("close", 0, null); await expect(run).rejects.toBeInstanceOf(UserError); await expect(run).rejects.toThrow("No interactions were recorded"); @@ -188,18 +178,15 @@ describe("record", () => { await fs.rm(outputDir, { recursive: true, force: true }); }); - it("throws clear error when both JSONL and fallback produce no steps", async () => { + it("throws UserError with codegen failure reason when codegen fails and no steps", async () => { vi.spyOn(Date, "now").mockReturnValue(616161); - const jsonlChild = createMockChildProcess(); - const fallbackChild = createMockChildProcess(); - vi.mocked(spawn) - .mockReturnValueOnce(jsonlChild) - .mockReturnValueOnce(fallbackChild); + const child = createMockChildProcess(); + vi.mocked(spawn).mockReturnValueOnce(child); const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "ui-test-recorder-test-")); const run = record({ - name: "Broken Recording", + name: "Failed Recording", url: "http://127.0.0.1:5173", outputDir, }); @@ -207,59 +194,13 @@ describe("record", () => { await vi.waitFor(() => { expect(spawn).toHaveBeenCalledTimes(1); }); - jsonlChild.emit("close", 1, null); - - await vi.waitFor(() => { - expect(spawn).toHaveBeenCalledTimes(2); - }); - fallbackChild.emit("close", 1, null); + child.emit("close", 1, null); await expect(run).rejects.toBeInstanceOf(UserError); await expect(run).rejects.toThrow("No interactions were recorded"); await fs.rm(outputDir, { recursive: true, force: true }); }); - - it("uses fallback directly when JSONL is disabled by environment", async () => { - vi.spyOn(Date, "now").mockReturnValue(717171); - process.env.UI_TEST_DISABLE_JSONL = "1"; - - const fallbackChild = createMockChildProcess(); - vi.mocked(spawn).mockReturnValueOnce(fallbackChild); - - const fallbackCodePath = path.join( - os.tmpdir(), - "ui-test-recording-fallback-717171.spec.ts" - ); - await fs.writeFile( - fallbackCodePath, - [ - "import { test } from '@playwright/test';", - "test('x', async ({ page }) => {", - " await page.getByRole('button', { name: 'Save' }).click();", - "});", - ].join("\n"), - "utf-8" - ); - - const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "ui-test-recorder-test-")); - const run = record({ - name: "JSONL Disabled Recording", - url: "http://127.0.0.1:5173", - outputDir, - }); - - await vi.waitFor(() => { - expect(spawn).toHaveBeenCalledTimes(1); - }); - fallbackChild.emit("close", 0, null); - - const result = await run; - expect(result.recordingMode).toBe("fallback"); - expect(spawn).toHaveBeenCalledTimes(1); - - await fs.rm(outputDir, { recursive: true, force: true }); - }); }); describe("normalizeFirstNavigate", () => { @@ -267,7 +208,7 @@ describe("normalizeFirstNavigate", () => { const steps = normalizeFirstNavigate( [ { action: "navigate", url: "https://consent.example.com/auth?key=abc" }, - { action: "click", target: { value: "getByRole('button', { name: 'OK' })", kind: "locatorExpression" as const, source: "codegen-jsonl" as const } }, + { action: "click", target: { value: "getByRole('button', { name: 'OK' })", kind: "locatorExpression" as const, source: "codegen" as const } }, ], "https://example.com" ); @@ -296,7 +237,7 @@ describe("normalizeFirstNavigate", () => { it("injects navigate when first step is not a navigate", () => { const steps = normalizeFirstNavigate( - [{ action: "click", target: { value: "#btn", kind: "css" as const, source: "codegen-jsonl" as const } }], + [{ action: "click", target: { value: "#btn", kind: "css" as const, source: "codegen" as const } }], "https://example.com/page" ); @@ -325,42 +266,3 @@ describe("normalizeFirstNavigate", () => { expect(steps[0]).toEqual({ action: "navigate", url: "/page#section" }); }); }); - -describe("detectJsonlCapability", () => { - it("returns unknown for npx entrypoint", async () => { - await expect(detectJsonlCapability("npx")).resolves.toBe("unknown"); - }); - - it("detects supported when playwright-core jsonl generator exists", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "ui-test-recorder-capability-")); - const cliPath = path.join(root, "node_modules", "playwright", "cli.js"); - const jsonlPath = path.join( - root, - "node_modules", - "playwright-core", - "lib", - "server", - "codegen", - "jsonl.js" - ); - await fs.mkdir(path.dirname(cliPath), { recursive: true }); - await fs.mkdir(path.dirname(jsonlPath), { recursive: true }); - await fs.writeFile(cliPath, "", "utf-8"); - await fs.writeFile(jsonlPath, "", "utf-8"); - - await expect(detectJsonlCapability(cliPath)).resolves.toBe("supported"); - - await fs.rm(root, { recursive: true, force: true }); - }); - - it("detects unsupported when jsonl generator is missing", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "ui-test-recorder-capability-")); - const cliPath = path.join(root, "node_modules", "playwright", "cli.js"); - await fs.mkdir(path.dirname(cliPath), { recursive: true }); - await fs.writeFile(cliPath, "", "utf-8"); - - await expect(detectJsonlCapability(cliPath)).resolves.toBe("unsupported"); - - await fs.rm(root, { recursive: true, force: true }); - }); -}); diff --git a/src/core/recorder.ts b/src/core/recorder.ts index e4543c1..135c84e 100644 --- a/src/core/recorder.ts +++ b/src/core/recorder.ts @@ -1,11 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; -import { - jsonlToRecordingSteps, - type RecordSelectorPolicy, - type RecordingTransformStats, -} from "./transform/jsonl-transform.js"; import { playwrightCodeToSteps } from "./transform/playwright-ast-transform.js"; import { stepsToYaml } from "./transform/yaml-io.js"; import type { Step } from "./yaml-schema.js"; @@ -16,7 +11,6 @@ import type { } from "./contracts/process-runner.js"; import { defaultRunInteractiveCommand, - detectJsonlCapability, resolvePlaywrightCliPath, runCodegen, type CodegenBrowser, @@ -30,7 +24,6 @@ export interface RecordOptions { url: string; description?: string; outputDir: string; - selectorPolicy?: RecordSelectorPolicy; browser?: RecordBrowser; device?: string; testIdAttribute?: string; @@ -40,9 +33,8 @@ export interface RecordOptions { export interface RecordResult { outputPath: string; - stats: RecordingTransformStats; - recordingMode: "jsonl" | "fallback"; - degraded: boolean; + stepCount: number; + recordingMode: "codegen"; } export interface RecorderDependencies { @@ -56,146 +48,35 @@ export async function record( const playwrightBin = await findPlaywrightCli(); const runInteractiveCommand = dependencies.runInteractiveCommand ?? defaultRunInteractiveCommand; - const selectorPolicy = options.selectorPolicy ?? "reliable"; const browser = options.browser ?? "chromium"; - const jsonlCapability = await detectJsonlCapability(playwrightBin); - const jsonlDisabledByEnv = process.env["UI_TEST_DISABLE_JSONL"] === "1"; - if (jsonlDisabledByEnv) { - ui.warn("JSONL recording disabled by UI_TEST_DISABLE_JSONL=1; using playwright-test fallback mode."); - } else if (jsonlCapability === "unsupported") { - ui.warn("JSONL target internals were not detected in this Playwright install; using playwright-test fallback mode."); - } else { - ui.warn("Recorder uses Playwright's hidden JSONL target first; this may break across Playwright versions."); - } ui.info("Opening browser for recording..."); ui.dim("Interact with the page. Close the browser when done."); - const jsonlTmpFile = path.join(os.tmpdir(), `ui-test-recording-${Date.now()}.jsonl`); - - let codegenJsonlError: Error | undefined; - let jsonlContent = ""; - - if (jsonlDisabledByEnv) { - codegenJsonlError = new Error("JSONL recording disabled by UI_TEST_DISABLE_JSONL=1."); - } else if (jsonlCapability === "unsupported") { - codegenJsonlError = new Error("JSONL target internals not detected in installed Playwright package."); - } else { - try { - const jsonlCodegenOptions: CodegenRunOptions = { - url: options.url, - outputFile: jsonlTmpFile, - target: "jsonl", - browser, - }; - if (options.device !== undefined) { - jsonlCodegenOptions.device = options.device; - } - if (options.testIdAttribute !== undefined) { - jsonlCodegenOptions.testIdAttribute = options.testIdAttribute; - } - if (options.loadStorage !== undefined) { - jsonlCodegenOptions.loadStorage = options.loadStorage; - } - if (options.saveStorage !== undefined) { - jsonlCodegenOptions.saveStorage = options.saveStorage; - } - await runCodegen( - playwrightBin, - jsonlCodegenOptions, - runInteractiveCommand - ); - } catch (err) { - codegenJsonlError = err instanceof Error ? err : new Error(String(err)); - } - } + const tmpFile = path.join(os.tmpdir(), `ui-test-recording-${Date.now()}.spec.ts`); + let codegenError: Error | undefined; try { - jsonlContent = await fs.readFile(jsonlTmpFile, "utf-8"); - } catch { - jsonlContent = ""; - } finally { - await fs.unlink(jsonlTmpFile).catch(() => {}); - } - - const transformed = jsonlToRecordingSteps(jsonlContent, { selectorPolicy }); - if (transformed.steps.length > 0) { - if (codegenJsonlError) { - ui.warn(`Recorder exited unexpectedly (${codegenJsonlError.message}), but captured JSONL steps were recovered.`); - } - - const normalizedSteps = normalizeFirstNavigate(transformed.steps, options.url); - const outputPath = await saveRecordingYaml(options, normalizedSteps); - return { - outputPath, - stats: transformed.stats, - recordingMode: "jsonl", - degraded: transformed.stats.fallbackSelectors > 0, - }; - } - - if (!codegenJsonlError) { - throw new UserError( - "No interactions were recorded.", - "Try again and make sure to click, type, or interact with elements on the page." - ); - } - - ui.warn("JSONL recording yielded no usable steps. Falling back to playwright-test codegen parsing."); - ui.warn(`JSONL failure reason: ${codegenJsonlError.message}`); - - const fallback = await recordWithPlaywrightTestFallback( - playwrightBin, - options, - browser, - runInteractiveCommand - ); - const normalizedSteps = normalizeFirstNavigate(fallback.steps, options.url); - const outputPath = await saveRecordingYaml(options, normalizedSteps); - - return { - outputPath, - stats: fallback.stats, - recordingMode: "fallback", - degraded: true, - }; -} - -async function recordWithPlaywrightTestFallback( - playwrightBin: string, - options: RecordOptions, - browser: RecordBrowser, - runInteractiveCommand: RunInteractiveCommand -): Promise<{ steps: Step[]; stats: RecordingTransformStats }> { - const tmpFile = path.join(os.tmpdir(), `ui-test-recording-fallback-${Date.now()}.spec.ts`); - - let fallbackError: Error | undefined; - try { - const fallbackCodegenOptions: CodegenRunOptions = { + const codegenOptions: CodegenRunOptions = { url: options.url, outputFile: tmpFile, - target: "playwright-test", browser, }; if (options.device !== undefined) { - fallbackCodegenOptions.device = options.device; + codegenOptions.device = options.device; } if (options.testIdAttribute !== undefined) { - fallbackCodegenOptions.testIdAttribute = options.testIdAttribute; + codegenOptions.testIdAttribute = options.testIdAttribute; } if (options.loadStorage !== undefined) { - fallbackCodegenOptions.loadStorage = options.loadStorage; + codegenOptions.loadStorage = options.loadStorage; } if (options.saveStorage !== undefined) { - fallbackCodegenOptions.saveStorage = options.saveStorage; + codegenOptions.saveStorage = options.saveStorage; } - await runCodegen( - playwrightBin, - fallbackCodegenOptions, - runInteractiveCommand - ); + await runCodegen(playwrightBin, codegenOptions, runInteractiveCommand); } catch (err) { - fallbackError = err instanceof Error ? err : new Error(String(err)); + codegenError = err instanceof Error ? err : new Error(String(err)); } let code = ""; @@ -209,22 +90,26 @@ async function recordWithPlaywrightTestFallback( const steps = playwrightCodeToSteps(code); if (steps.length === 0) { - const fallbackMessage = fallbackError ? `Fallback codegen failed: ${fallbackError.message}` : "Fallback parser found no supported interactions."; + const reason = codegenError + ? `Codegen failed: ${codegenError.message}` + : "Parser found no supported interactions."; throw new UserError( "No interactions were recorded.", - `${fallbackMessage} Try again and make sure to click, type, or interact with elements on the page.` + `${reason} Try again and make sure to click, type, or interact with elements on the page.` ); } - const selectorSteps = steps.filter((step) => step.action !== "navigate").length; + if (codegenError) { + ui.warn(`Recorder exited unexpectedly (${codegenError.message}), but captured steps were recovered.`); + } + + const normalizedSteps = normalizeFirstNavigate(steps, options.url); + const outputPath = await saveRecordingYaml(options, normalizedSteps); + return { - steps, - stats: { - selectorSteps, - stableSelectors: 0, - fallbackSelectors: selectorSteps, - frameAwareSelectors: 0, - }, + outputPath, + stepCount: normalizedSteps.length, + recordingMode: "codegen", }; } @@ -270,7 +155,7 @@ async function findPlaywrightCli(): Promise { return "npx"; } -function slugify(text: string): string { +export function slugify(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") @@ -297,4 +182,4 @@ export function normalizeFirstNavigate(steps: Step[], startingUrl: string): Step return [{ action: "navigate" as const, url: startPath }, ...steps]; } -export { runCodegen, resolvePlaywrightCliPath, detectJsonlCapability }; +export { runCodegen, resolvePlaywrightCliPath }; diff --git a/src/core/runtime/step-executor.ts b/src/core/runtime/step-executor.ts index b0dc50d..0bdce28 100644 --- a/src/core/runtime/step-executor.ts +++ b/src/core/runtime/step-executor.ts @@ -28,6 +28,10 @@ export async function executeRuntimeStep( await resolveLocator(page, step).click({ timeout }); return; + case "dblclick": + await resolveLocator(page, step).dblclick({ timeout }); + return; + case "fill": await resolveLocator(page, step).fill(step.text, { timeout }); return; diff --git a/src/core/transform/devtools-recording-adapter.test.ts b/src/core/transform/devtools-recording-adapter.test.ts new file mode 100644 index 0000000..78c1b84 --- /dev/null +++ b/src/core/transform/devtools-recording-adapter.test.ts @@ -0,0 +1,349 @@ +import { describe, it, expect } from "vitest"; +import { devtoolsRecordingToSteps } from "./devtools-recording-adapter.js"; + +describe("devtoolsRecordingToSteps", () => { + it("converts navigate step", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + title: "Test", + steps: [{ type: "navigate", url: "https://example.com" }], + })); + + expect(result.steps).toEqual([ + { action: "navigate", url: "https://example.com" }, + ]); + expect(result.title).toBe("Test"); + }); + + it("converts click step with ARIA selector", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ + type: "click", + selectors: [["aria/Submit[role=\"button\"]"]], + }], + })); + + expect(result.steps).toEqual([{ + action: "click", + target: { + value: "getByRole('button', { name: 'Submit' })", + kind: "locatorExpression", + source: "devtools-import", + confidence: expect.any(Number), + }, + }]); + }); + + it("converts change step to fill", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ + type: "change", + selectors: [["#email"]], + value: "user@example.com", + }], + })); + + expect(result.steps).toEqual([{ + action: "fill", + target: { + value: "#email", + kind: "css", + source: "devtools-import", + confidence: 0.5, + }, + text: "user@example.com", + }]); + }); + + it("merges keyDown + keyUp into press", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [ + { type: "keyDown", key: "Enter", selectors: [["#input"]] }, + { type: "keyUp", key: "Enter" }, + ], + })); + + expect(result.steps).toEqual([{ + action: "press", + target: { + value: "#input", + kind: "css", + source: "devtools-import", + confidence: 0.5, + }, + key: "Enter", + }]); + }); + + it("skips keyDown without matching keyUp", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [ + { type: "keyDown", key: "Enter", selectors: [["#input"]] }, + ], + })); + + expect(result.steps).toEqual([]); + expect(result.skipped).toBe(1); + }); + + it("converts waitForElement to assertVisible", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ + type: "waitForElement", + selectors: [["aria/Success message"]], + }], + })); + + expect(result.steps).toEqual([{ + action: "assertVisible", + target: { + value: "getByLabel('Success message')", + kind: "locatorExpression", + source: "devtools-import", + confidence: expect.any(Number), + }, + }]); + }); + + it("converts doubleClick step to dblclick", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ + type: "doubleClick", + selectors: [["aria/Edit[role=\"button\"]"]], + }], + })); + + expect(result.steps).toEqual([{ + action: "dblclick", + target: { + value: "getByRole('button', { name: 'Edit' })", + kind: "locatorExpression", + source: "devtools-import", + confidence: expect.any(Number), + }, + }]); + }); + + it("converts hover step", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ + type: "hover", + selectors: [["aria/Menu[role=\"button\"]"]], + }], + })); + + expect(result.steps).toEqual([{ + action: "hover", + target: { + value: "getByRole('button', { name: 'Menu' })", + kind: "locatorExpression", + source: "devtools-import", + confidence: expect.any(Number), + }, + }]); + }); + + it("skips scroll, setViewport, and close steps", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [ + { type: "scroll", x: 0, y: 100 }, + { type: "setViewport", width: 1920, height: 1080 }, + { type: "close" }, + ], + })); + + expect(result.steps).toEqual([]); + expect(result.skipped).toBe(3); + }); + + it("prefers ARIA selector over CSS", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ + type: "click", + selectors: [ + ["#btn-submit"], + ["aria/Save[role=\"button\"]"], + ], + }], + })); + + expect(result.steps[0]).toMatchObject({ + target: { + value: "getByRole('button', { name: 'Save' })", + kind: "locatorExpression", + source: "devtools-import", + }, + }); + }); + + it("prefers data-testid selector when no ARIA available", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ + type: "click", + selectors: [ + ["[data-testid=\"save-btn\"]"], + ["div > button.primary"], + ], + }], + })); + + expect(result.steps[0]).toMatchObject({ + target: { + value: "getByTestId('save-btn')", + kind: "locatorExpression", + source: "devtools-import", + }, + }); + }); + + it("falls back to CSS selector", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ + type: "click", + selectors: [["div > button.primary"]], + }], + })); + + expect(result.steps[0]).toMatchObject({ + target: { + kind: "css", + source: "devtools-import", + confidence: 0.5, + }, + }); + }); + + it("falls back to XPath selector as last resort", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ + type: "click", + selectors: [["xpath//html/body/button"]], + }], + })); + + expect(result.steps[0]).toMatchObject({ + target: { + value: "/html/body/button", + kind: "xpath", + source: "devtools-import", + confidence: 0.3, + }, + }); + }); + + it("returns empty steps for malformed JSON", () => { + const result = devtoolsRecordingToSteps("not valid json"); + expect(result.steps).toEqual([]); + }); + + it("returns empty steps for missing steps array", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ title: "Empty" })); + expect(result.steps).toEqual([]); + }); + + it("omits title when recording title is absent", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ type: "navigate", url: "https://example.com" }], + })); + + expect("title" in result).toBe(false); + }); + + it("handles complete recording flow", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + title: "Login Flow", + steps: [ + { type: "navigate", url: "https://example.com/login" }, + { type: "click", selectors: [["aria/Email[role=\"textbox\"]"]] }, + { type: "change", selectors: [["#email"]], value: "user@example.com" }, + { type: "change", selectors: [["#password"]], value: "secret" }, + { type: "click", selectors: [["aria/Login[role=\"button\"]"]] }, + { type: "waitForElement", selectors: [["aria/Dashboard"]] }, + ], + })); + + expect(result.title).toBe("Login Flow"); + expect(result.steps).toHaveLength(6); + expect(result.steps.map((s) => s.action)).toEqual([ + "navigate", "click", "fill", "fill", "click", "assertVisible", + ]); + }); + + it("skips click steps without selectors", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ type: "click" }], + })); + + expect(result.steps).toEqual([]); + expect(result.skipped).toBe(1); + }); + + it("escapes backslashes in ARIA selector names", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ + type: "click", + selectors: [["aria/it\\s a \\path[role=\"button\"]"]], + }], + })); + + expect(result.steps[0]).toMatchObject({ + target: { + value: "getByRole('button', { name: 'it\\\\s a \\\\path' })", + kind: "locatorExpression", + source: "devtools-import", + }, + }); + }); + + it("handles multi-segment shadow DOM ARIA selectors", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ + type: "click", + selectors: [["aria/Submit[role=\"button\"]", "aria/Confirm[role=\"button\"]"]], + }], + })); + + expect(result.steps[0]).toMatchObject({ + target: { + value: "getByRole('button', { name: 'Confirm' })", + kind: "locatorExpression", + source: "devtools-import", + }, + }); + // Multi-segment gets a reduced confidence + expect((result.steps[0] as any).target.confidence).toBeLessThan(0.9); + }); + + it("handles ARIA selector with extra attributes beyond role", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ + type: "click", + selectors: [["aria/Accept[role=\"button\"][checked=\"true\"]"]], + }], + })); + + expect(result.steps[0]).toMatchObject({ + target: { + value: "getByRole('button', { name: 'Accept' })", + kind: "locatorExpression", + source: "devtools-import", + }, + }); + }); + + it("handles ARIA selector without role attribute", () => { + const result = devtoolsRecordingToSteps(JSON.stringify({ + steps: [{ + type: "click", + selectors: [["aria/Email address"]], + }], + })); + + expect(result.steps[0]).toMatchObject({ + target: { + value: "getByLabel('Email address')", + kind: "locatorExpression", + source: "devtools-import", + }, + }); + }); +}); diff --git a/src/core/transform/devtools-recording-adapter.ts b/src/core/transform/devtools-recording-adapter.ts new file mode 100644 index 0000000..f28ff21 --- /dev/null +++ b/src/core/transform/devtools-recording-adapter.ts @@ -0,0 +1,273 @@ +/** + * Converts Chrome DevTools Recorder JSON exports into ui-test Step[]. + * + * Chrome DevTools Recorder exports a framework-agnostic JSON format + * with multiple selector alternatives per action (CSS, ARIA, XPath). + * This adapter converts that format into the ui-test YAML step format. + */ + +import type { Step, Target } from "../yaml-schema.js"; +import { classifySelector } from "../selector-classifier.js"; +import { scoreLocatorConfidence } from "./locator-confidence.js"; + +export interface DevToolsRecording { + title?: string; + steps: DevToolsStep[]; +} + +export interface DevToolsStep { + type: string; + url?: string; + selectors?: DevToolsSelector[][]; + offsetX?: number; + offsetY?: number; + value?: string; + key?: string; + assertedEvents?: unknown[]; + [key: string]: unknown; +} + +type DevToolsSelector = string; + +export interface DevToolsConvertResult { + steps: Step[]; + title?: string; + skipped: number; +} + +export function devtoolsRecordingToSteps(json: string): DevToolsConvertResult { + let recording: DevToolsRecording; + try { + recording = JSON.parse(json) as DevToolsRecording; + } catch { + return { steps: [], skipped: 0 }; + } + + if (!recording || !Array.isArray(recording.steps)) { + const title = recording?.title; + return title === undefined + ? { steps: [], skipped: 0 } + : { steps: [], title, skipped: 0 }; + } + + const steps: Step[] = []; + let skipped = 0; + const devSteps = recording.steps; + + for (let i = 0; i < devSteps.length; i++) { + const devStep = devSteps[i]; + if (!devStep) continue; + const result = convertStep(devStep, devSteps, i); + if (result === "skip") { + skipped++; + continue; + } + if (result === "consumed") { + continue; + } + steps.push(result); + } + + const title = recording.title; + return title === undefined ? { steps, skipped } : { steps, title, skipped }; +} + +function convertStep( + step: DevToolsStep, + allSteps: DevToolsStep[], + index: number +): Step | "skip" | "consumed" { + switch (step.type) { + case "navigate": + return { action: "navigate", url: step.url ?? "/" }; + + case "click": + return convertClickStep(step); + + case "doubleClick": + return convertClickStep(step, "dblclick"); + + case "hover": + return convertClickStep(step, "hover"); + + case "change": + return convertChangeStep(step); + + case "keyDown": + return convertKeyStep(step, allSteps, index); + + case "keyUp": + return "consumed"; + + case "waitForElement": + return convertWaitStep(step); + + case "scroll": + case "setViewport": + case "emulateNetworkConditions": + case "close": + return "skip"; + + default: + return "skip"; + } +} + +function convertClickStep(step: DevToolsStep, action: "click" | "dblclick" | "hover" = "click"): Step | "skip" { + const target = selectBestTarget(step.selectors); + if (!target) return "skip"; + return { action, target } as Step; +} + +function convertChangeStep(step: DevToolsStep): Step | "skip" { + const target = selectBestTarget(step.selectors); + if (!target) return "skip"; + return { action: "fill", target, text: step.value ?? "" }; +} + +function convertKeyStep( + step: DevToolsStep, + allSteps: DevToolsStep[], + index: number +): Step | "skip" { + const key = step.key; + if (!key) return "skip"; + + const nextStep = allSteps[index + 1]; + if (nextStep?.type === "keyUp" && nextStep.key === key) { + const target = selectBestTarget(step.selectors); + if (!target) return "skip"; + return { action: "press", target, key }; + } + + return "skip"; +} + +function convertWaitStep(step: DevToolsStep): Step | "skip" { + const target = selectBestTarget(step.selectors); + if (!target) return "skip"; + return { action: "assertVisible", target }; +} + +function selectBestTarget( + selectorGroups: DevToolsSelector[][] | undefined +): Target | null { + if (!selectorGroups || selectorGroups.length === 0) return null; + + const flatSelectors = selectorGroups + .map((group) => group.join(" >> ")) + .filter((s) => s.length > 0); + + if (flatSelectors.length === 0) return null; + + const ariaSelector = flatSelectors.find((s) => s.startsWith("aria/")); + if (ariaSelector) { + if (ariaSelector.includes(" >> ")) { + // Multi-segment shadow DOM selector — convert last ARIA segment + const segments = ariaSelector.split(" >> "); + const lastAria = [...segments].reverse().find((s) => s.startsWith("aria/")); + if (lastAria) { + const locatorExpr = ariaToLocatorExpression(lastAria); + if (locatorExpr) { + return { + value: locatorExpr, + kind: "locatorExpression", + source: "devtools-import", + confidence: Math.max(scoreLocatorConfidence(locatorExpr) - 0.1, 0), + }; + } + } + } else { + const locatorExpr = ariaToLocatorExpression(ariaSelector); + if (locatorExpr) { + return { + value: locatorExpr, + kind: "locatorExpression", + source: "devtools-import", + confidence: scoreLocatorConfidence(locatorExpr), + }; + } + } + } + + const testIdSelector = flatSelectors.find((s) => + s.includes("[data-testid=") || s.includes("[data-test-id=") + ); + if (testIdSelector) { + const testId = extractTestId(testIdSelector); + if (testId) { + const locatorExpr = `getByTestId('${escapeSingleQuotes(testId)}')`; + return { + value: locatorExpr, + kind: "locatorExpression", + source: "devtools-import", + confidence: scoreLocatorConfidence(locatorExpr), + }; + } + } + + const cssSelector = flatSelectors.find( + (s) => !s.startsWith("xpath/") && !s.startsWith("aria/") && !s.startsWith("pierce/") + ); + if (cssSelector) { + const kind = classifySelector(cssSelector).kind; + return { + value: cssSelector.includes(" >> ") ? `locator('${escapeSingleQuotes(cssSelector)}')` : cssSelector, + kind: cssSelector.includes(" >> ") ? "locatorExpression" : kind, + source: "devtools-import", + confidence: 0.5, + }; + } + + const xpathSelector = flatSelectors.find((s) => s.startsWith("xpath/")); + if (xpathSelector) { + const xpath = xpathSelector.slice("xpath/".length); + return { + value: xpath, + kind: "xpath", + source: "devtools-import", + confidence: 0.3, + }; + } + + return null; +} + +function ariaToLocatorExpression(ariaSelector: string): string | null { + const body = ariaSelector.slice("aria/".length).trim(); + if (!body) return null; + + const roleMatch = body.match(/\[role="([^"]+)"\]/); + if (roleMatch) { + const roleIdx = body.indexOf(roleMatch[0]); + const name = body.slice(0, roleIdx).trim(); + const role = roleMatch[1]; + if (!role) return `getByLabel('${escapeSingleQuotes(body)}')`; + if (name) { + return `getByRole('${escapeSingleQuotes(role)}', { name: '${escapeSingleQuotes(name)}' })`; + } + return `getByRole('${escapeSingleQuotes(role)}')`; + } + + return `getByLabel('${escapeSingleQuotes(body)}')`; +} + +function extractTestId(selector: string): string | null { + const patterns = [ + /\[data-testid="([^"]+)"\]/, + /\[data-test-id="([^"]+)"\]/, + /\[data-testid='([^']+)'\]/, + /\[data-test-id='([^']+)'\]/, + /\[data-testid=([^\]\s]+)\]/, + /\[data-test-id=([^\]\s]+)\]/, + ]; + for (const pattern of patterns) { + const match = selector.match(pattern); + if (match?.[1]) return match[1]; + } + return null; +} + +function escapeSingleQuotes(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); +} diff --git a/src/core/transform/jsonl-transform.ts b/src/core/transform/jsonl-transform.ts deleted file mode 100644 index 509446c..0000000 --- a/src/core/transform/jsonl-transform.ts +++ /dev/null @@ -1,259 +0,0 @@ -import type { Step, Target } from "../yaml-schema.js"; -import { classifySelector } from "../selector-classifier.js"; -import { locatorNodeToExpression, type JsonlLocatorNode } from "./selector-normalize.js"; - -export type RecordSelectorPolicy = "reliable" | "raw"; - -export interface RecordingTransformStats { - selectorSteps: number; - stableSelectors: number; - fallbackSelectors: number; - frameAwareSelectors: number; -} - -export interface JsonlTransformOptions { - selectorPolicy?: RecordSelectorPolicy; -} - -interface CodegenAction { - type?: string; - name?: string; - url?: string; - selector?: string; - text?: string; - key?: string; - value?: string; - options?: string[]; - locator?: JsonlLocatorNode; - framePath?: string[]; - pageAlias?: string; - [key: string]: unknown; -} - -interface SelectorResolution { - target: Target; - stable: boolean; - fallback: boolean; - frameAware: boolean; -} - -type SelectorStepBuilder = ( - selectorResolution: SelectorResolution, - action: CodegenAction -) => Step; - -const selectorStepBuilders = { - click: (selectorResolution) => ({ action: "click", target: selectorResolution.target }), - check: (selectorResolution) => ({ action: "check", target: selectorResolution.target }), - uncheck: (selectorResolution) => ({ action: "uncheck", target: selectorResolution.target }), - hover: (selectorResolution) => ({ action: "hover", target: selectorResolution.target }), - assertVisible: (selectorResolution) => ({ - action: "assertVisible", - target: selectorResolution.target, - }), - fill: (selectorResolution, action) => ({ - action: "fill", - target: selectorResolution.target, - text: action.text ?? action.value ?? "", - }), - press: (selectorResolution, action) => ({ - action: "press", - target: selectorResolution.target, - key: action.key ?? "", - }), - select: (selectorResolution, action) => ({ - action: "select", - target: selectorResolution.target, - value: action.value ?? action.options?.[0] ?? "", - }), - assertText: (selectorResolution, action) => ({ - action: "assertText", - target: selectorResolution.target, - text: action.text ?? "", - }), - assertValue: (selectorResolution, action) => ({ - action: "assertValue", - target: selectorResolution.target, - value: action.value ?? "", - }), - assertChecked: (selectorResolution) => ({ - action: "assertChecked", - target: selectorResolution.target, - checked: true, - }), -} satisfies Record; - -type SelectorActionName = keyof typeof selectorStepBuilders; - -export function jsonlToSteps( - jsonlContent: string, - options: JsonlTransformOptions = {} -): Step[] { - return jsonlToRecordingSteps(jsonlContent, options).steps; -} - -export function jsonlToRecordingSteps( - jsonlContent: string, - options: JsonlTransformOptions = {} -): { steps: Step[]; stats: RecordingTransformStats } { - const lines = jsonlContent - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - - const policy = options.selectorPolicy ?? "reliable"; - const steps: Step[] = []; - const stats: RecordingTransformStats = { - selectorSteps: 0, - stableSelectors: 0, - fallbackSelectors: 0, - frameAwareSelectors: 0, - }; - - for (const line of lines) { - let action: CodegenAction; - try { - action = JSON.parse(line) as CodegenAction; - } catch { - continue; - } - - const transformed = actionToStep(action, policy); - if (!transformed) continue; - steps.push(transformed.step); - - if (transformed.selectorResolution) { - stats.selectorSteps += 1; - if (transformed.selectorResolution.stable) stats.stableSelectors += 1; - if (transformed.selectorResolution.fallback) stats.fallbackSelectors += 1; - if (transformed.selectorResolution.frameAware) stats.frameAwareSelectors += 1; - } - } - - return { steps, stats }; -} - -function actionToStep( - action: CodegenAction, - policy: RecordSelectorPolicy -): { step: Step; selectorResolution?: SelectorResolution } | null { - const actionName = action.type ?? action.name ?? ""; - - if (actionName === "openPage") { - if (!action.url || action.url === "about:blank" || action.url === "chrome://newtab/") { - return null; - } - return { step: { action: "navigate", url: action.url } }; - } - - if (actionName === "navigate") { - return { step: { action: "navigate", url: action.url ?? "/" } }; - } - - if (!isSelectorActionName(actionName)) { - return null; - } - - const selectorResolution = resolveSelector(action, policy); - if (!selectorResolution) return null; - - const step = buildSelectorStep(actionName, selectorResolution, action); - return { step, selectorResolution }; -} - -function buildSelectorStep( - actionName: SelectorActionName, - selectorResolution: SelectorResolution, - action: CodegenAction -): Step { - const builder = selectorStepBuilders[actionName]; - return builder(selectorResolution, action); -} - -function isSelectorActionName(actionName: string): actionName is SelectorActionName { - return Object.hasOwn(selectorStepBuilders, actionName); -} - -function resolveSelector( - action: CodegenAction, - policy: RecordSelectorPolicy -): SelectorResolution | null { - const rawSelector = typeof action.selector === "string" ? action.selector.trim() : ""; - const framePath = Array.isArray(action.framePath) - ? action.framePath.filter( - (value): value is string => typeof value === "string" && value.length > 0 - ) - : []; - const normalized = locatorNodeToExpression(action.locator, 0, { - dropDynamicExact: policy === "reliable", - }); - - if (policy === "raw") { - if (rawSelector) { - const kind = classifySelector(rawSelector).kind; - return { - target: { - value: rawSelector, - kind, - source: "codegen-jsonl", - ...(framePath.length > 0 ? { framePath } : {}), - }, - stable: true, - fallback: false, - frameAware: framePath.length > 0, - }; - } - - if (normalized) { - return { - target: { - value: normalized, - kind: "locatorExpression", - source: "codegen-jsonl", - ...(framePath.length > 0 ? { framePath } : {}), - confidence: 0.8, - warning: "Raw selector was unavailable, using normalized locator expression.", - }, - stable: true, - fallback: true, - frameAware: framePath.length > 0, - }; - } - - return null; - } - - if (normalized) { - return { - target: { - value: normalized, - kind: "locatorExpression", - source: "codegen-jsonl", - ...(framePath.length > 0 ? { framePath } : {}), - }, - stable: true, - fallback: false, - frameAware: framePath.length > 0, - }; - } - - if (rawSelector) { - const kind = classifySelector(rawSelector).kind; - return { - target: { - value: rawSelector, - kind, - source: "codegen-jsonl", - ...(framePath.length > 0 ? { framePath } : {}), - raw: rawSelector, - confidence: 0.4, - warning: "Could not normalize selector from codegen locator chain; preserving raw selector.", - }, - stable: false, - fallback: true, - frameAware: framePath.length > 0, - }; - } - - return null; -} diff --git a/src/core/transform/locator-confidence.test.ts b/src/core/transform/locator-confidence.test.ts new file mode 100644 index 0000000..48ba671 --- /dev/null +++ b/src/core/transform/locator-confidence.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { scoreLocatorConfidence } from "./locator-confidence.js"; + +describe("scoreLocatorConfidence", () => { + it("scores getByRole as 0.9", () => { + expect(scoreLocatorConfidence("getByRole('button', { name: 'Save' })")).toBe(0.9); + }); + + it("scores getByTestId as 0.9", () => { + expect(scoreLocatorConfidence("getByTestId('submit-btn')")).toBe(0.9); + }); + + it("scores getByLabel as 0.8", () => { + expect(scoreLocatorConfidence("getByLabel('Email')")).toBe(0.8); + }); + + it("scores getByPlaceholder as 0.8", () => { + expect(scoreLocatorConfidence("getByPlaceholder('Enter email')")).toBe(0.8); + }); + + it("scores getByText as 0.7", () => { + expect(scoreLocatorConfidence("getByText('Welcome')")).toBe(0.7); + }); + + it("scores getByAltText as 0.7", () => { + expect(scoreLocatorConfidence("getByAltText('Logo')")).toBe(0.7); + }); + + it("scores getByTitle as 0.7", () => { + expect(scoreLocatorConfidence("getByTitle('Settings')")).toBe(0.7); + }); + + it("scores locator() with CSS as 0.5", () => { + expect(scoreLocatorConfidence("locator('#submit')")).toBe(0.5); + }); + + it("scores locator() with complex CSS as 0.5", () => { + expect(scoreLocatorConfidence("locator('div.container > button')")).toBe(0.5); + }); + + it("penalizes nth() chain by -0.15", () => { + expect(scoreLocatorConfidence("getByRole('button').nth(0)")).toBe(0.75); + }); + + it("penalizes first() chain by -0.15", () => { + expect(scoreLocatorConfidence("getByRole('listitem').first()")).toBe(0.75); + }); + + it("penalizes last() chain by -0.15", () => { + expect(scoreLocatorConfidence("getByRole('listitem').last()")).toBe(0.75); + }); + + it("penalizes filter() chain by -0.05", () => { + expect(scoreLocatorConfidence("getByRole('row').filter({ hasText: 'Active' })")).toBe(0.85); + }); + + it("applies multiple penalties cumulatively", () => { + expect(scoreLocatorConfidence("getByRole('row').filter({ hasText: 'Active' }).first()")).toBe(0.7); + }); + + it("clamps to minimum 0", () => { + expect(scoreLocatorConfidence("locator('div').nth(0).first().last()")).toBe(0.05); + }); + + it("getByRole scores higher than locator()", () => { + const roleScore = scoreLocatorConfidence("getByRole('button', { name: 'Save' })"); + const locatorScore = scoreLocatorConfidence("locator('#submit')"); + expect(roleScore).toBeGreaterThan(locatorScore); + }); + + it("does not penalize text content that looks like chain methods", () => { + expect(scoreLocatorConfidence("getByText('.first(item)')")).toBe(0.7); + expect(scoreLocatorConfidence("getByText('.nth(2)')")).toBe(0.7); + expect(scoreLocatorConfidence("getByText('.last()')")).toBe(0.7); + expect(scoreLocatorConfidence("getByText('.filter(x)')")).toBe(0.7); + }); + + it("getByTestId scores higher than getByText", () => { + const testIdScore = scoreLocatorConfidence("getByTestId('login-btn')"); + const textScore = scoreLocatorConfidence("getByText('Login')"); + expect(testIdScore).toBeGreaterThan(textScore); + }); +}); diff --git a/src/core/transform/locator-confidence.ts b/src/core/transform/locator-confidence.ts new file mode 100644 index 0000000..3ae954f --- /dev/null +++ b/src/core/transform/locator-confidence.ts @@ -0,0 +1,66 @@ +/** + * Confidence scoring for Playwright locator expressions. + * + * Assigns a confidence score (0–1) based on how stable/reliable + * the locator strategy is expected to be across page changes. + */ + +const METHOD_SCORES: Record = { + getByRole: 0.9, + getByTestId: 0.9, + getByLabel: 0.8, + getByPlaceholder: 0.8, + getByText: 0.7, + getByAltText: 0.7, + getByTitle: 0.7, +}; + +const POSITIONAL_PENALTY = -0.15; +const FILTER_PENALTY = -0.05; + +const POSITIONAL_METHODS = new Set(["nth", "first", "last"]); + +export function scoreLocatorConfidence(locatorExpression: string): number { + const baseScore = getBaseScore(locatorExpression); + const penalty = getPenalty(locatorExpression); + return Math.round(clamp(baseScore + penalty, 0, 1) * 100) / 100; +} + +function getBaseScore(expression: string): number { + for (const [method, score] of Object.entries(METHOD_SCORES)) { + if (expression.startsWith(`${method}(`)) { + return score; + } + } + + if (expression.startsWith("locator(")) { + return 0.5; + } + + return 0.5; +} + +function getPenalty(expression: string): number { + const stripped = stripQuotedStrings(expression); + let penalty = 0; + + for (const method of POSITIONAL_METHODS) { + if (stripped.includes(`.${method}(`)) { + penalty += POSITIONAL_PENALTY; + } + } + + if (stripped.includes(".filter(")) { + penalty += FILTER_PENALTY; + } + + return penalty; +} + +function stripQuotedStrings(expr: string): string { + return expr.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""'); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/src/core/transform/playwright-ast-transform.ts b/src/core/transform/playwright-ast-transform.ts index d4181f5..133244f 100644 --- a/src/core/transform/playwright-ast-transform.ts +++ b/src/core/transform/playwright-ast-transform.ts @@ -1,6 +1,7 @@ import { parse } from "acorn"; import type { Step, Target } from "../yaml-schema.js"; import { classifySelector } from "../selector-classifier.js"; +import { scoreLocatorConfidence } from "./locator-confidence.js"; import { isArrowFunctionExpression, isAstNode, @@ -203,7 +204,8 @@ function expressionToTarget(expression: unknown, source: string): Target | null return { value: selector, kind: classifySelector(selector).kind, - source: "codegen-fallback", + source: "codegen", + confidence: scoreLocatorConfidence(selector), }; } diff --git a/src/core/transform/selector-normalize.ts b/src/core/transform/selector-normalize.ts deleted file mode 100644 index 0cf524e..0000000 --- a/src/core/transform/selector-normalize.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { detectDynamicSignals } from "../improve/dynamic-signal-detection.js"; - -export interface JsonlLocatorNode { - kind: string; - body?: unknown; - options?: Record; - next?: JsonlLocatorNode; -} - -export interface LocatorNormalizeOptions { - dropDynamicExact?: boolean; -} - -export function locatorNodeToExpression( - node: unknown, - depth = 0, - normalizeOptions: LocatorNormalizeOptions = {} -): string | undefined { - if (!isLocatorNode(node) || depth > 64) return undefined; - - const { kind, body, options = {}, next } = node; - let current: string; - - switch (kind) { - case "default": { - const hasText = options["hasText"]; - const hasNotText = options["hasNotText"]; - if (hasText !== undefined) { - current = `locator(${toLiteral(body)}, { hasText: ${toLiteral(hasText)} })`; - } else if (hasNotText !== undefined) { - current = `locator(${toLiteral(body)}, { hasNotText: ${toLiteral(hasNotText)} })`; - } else { - current = `locator(${toLiteral(body)})`; - } - break; - } - - case "frame-locator": - current = `frameLocator(${toLiteral(body)})`; - break; - - case "frame": - current = "contentFrame()"; - break; - - case "nth": { - const nthIndex = typeof body === "number" ? body : Number(body); - if (!Number.isFinite(nthIndex)) return undefined; - current = `nth(${nthIndex})`; - break; - } - - case "first": - current = "first()"; - break; - - case "last": - current = "last()"; - break; - - case "visible": - current = `filter({ visible: ${body === true || body === "true" ? "true" : "false"} })`; - break; - - case "role": { - const roleOptions: string[] = []; - let roleName = ""; - if (options["name"] !== undefined) { - roleOptions.push(`name: ${toLiteral(options["name"])}`); - roleName = typeof options["name"] === "string" ? options["name"] : ""; - } - const dropExact = shouldDropExactForDynamicText( - roleName, - normalizeOptions.dropDynamicExact === true - ); - if (options["exact"] === true && !dropExact) roleOptions.push("exact: true"); - const attrs = Array.isArray(options["attrs"]) - ? options["attrs"].filter( - (value): value is { name: unknown; value: unknown } => isPlainObject(value) - ) - : []; - for (const attr of attrs) { - if (typeof attr.name !== "string") continue; - roleOptions.push(`${safeObjectKey(attr.name)}: ${toLiteral(attr.value)}`); - } - current = - roleOptions.length > 0 - ? `getByRole(${toLiteral(body)}, { ${roleOptions.join(", ")} })` - : `getByRole(${toLiteral(body)})`; - break; - } - - case "has-text": - current = `filter({ hasText: ${toLiteral(body)} })`; - break; - - case "has-not-text": - current = `filter({ hasNotText: ${toLiteral(body)} })`; - break; - - case "has": { - const nested = locatorNodeToExpression(body, depth + 1, normalizeOptions); - if (!nested) return undefined; - current = `filter({ has: ${nested} })`; - break; - } - - case "hasNot": { - const nested = locatorNodeToExpression(body, depth + 1, normalizeOptions); - if (!nested) return undefined; - current = `filter({ hasNot: ${nested} })`; - break; - } - - case "and": { - const nested = locatorNodeToExpression(body, depth + 1, normalizeOptions); - if (!nested) return undefined; - current = `and(${nested})`; - break; - } - - case "or": { - const nested = locatorNodeToExpression(body, depth + 1, normalizeOptions); - if (!nested) return undefined; - current = `or(${nested})`; - break; - } - - case "chain": { - const nested = locatorNodeToExpression(body, depth + 1, normalizeOptions); - if (!nested) return undefined; - current = `locator(${nested})`; - break; - } - - case "test-id": - current = `getByTestId(${toLiteral(body)})`; - break; - - case "text": - current = toGetByTextMethod("getByText", body, options, normalizeOptions); - break; - - case "alt": - current = toGetByTextMethod("getByAltText", body, options, normalizeOptions); - break; - - case "placeholder": - current = toGetByTextMethod("getByPlaceholder", body, options, normalizeOptions); - break; - - case "label": - current = toGetByTextMethod("getByLabel", body, options, normalizeOptions); - break; - - case "title": - current = toGetByTextMethod("getByTitle", body, options, normalizeOptions); - break; - - default: - return undefined; - } - - if (!next) return current; - const nextExpression = locatorNodeToExpression(next, depth + 1, normalizeOptions); - if (!nextExpression) return current; - return `${current}.${nextExpression}`; -} - -function toGetByTextMethod( - methodName: "getByText" | "getByAltText" | "getByPlaceholder" | "getByLabel" | "getByTitle", - body: unknown, - options: Record, - normalizeOptions: LocatorNormalizeOptions -): string { - const bodyText = typeof body === "string" ? body : ""; - const dropExact = shouldDropExactForDynamicText( - bodyText, - normalizeOptions.dropDynamicExact === true - ); - if (options["exact"] === true && !dropExact) { - return `${methodName}(${toLiteral(body)}, { exact: true })`; - } - return `${methodName}(${toLiteral(body)})`; -} - -function toLiteral(value: unknown): string { - if (value === null) return "null"; - if (value === undefined) return "undefined"; - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { - return String(value); - } - if (typeof value === "string") return quote(value); - if (Array.isArray(value)) return `[${value.map((entry) => toLiteral(entry)).join(", ")}]`; - if (isRegexLike(value)) return `/${escapeRegexBody(value.source)}/${value.flags}`; - if (isPlainObject(value)) { - const entries = Object.entries(value).map( - ([key, entry]) => `${safeObjectKey(key)}: ${toLiteral(entry)}` - ); - return `{ ${entries.join(", ")} }`; - } - return quote(formatFallbackLiteral(value)); -} - -function quote(value: string): string { - return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`; -} - -function safeObjectKey(key: string): string { - if (/^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(key)) return key; - return quote(key); -} - -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isLocatorNode(value: unknown): value is JsonlLocatorNode { - return isPlainObject(value) && typeof value["kind"] === "string"; -} - -function isRegexLike(value: unknown): value is { source: string; flags: string } { - return ( - isPlainObject(value) && - typeof value["source"] === "string" && - typeof value["flags"] === "string" - ); -} - -function escapeRegexBody(value: string): string { - return value.replace(/\//g, "\\/"); -} - -function formatFallbackLiteral(value: unknown): string { - if (typeof value === "symbol") { - return value.description ? `Symbol(${value.description})` : "Symbol()"; - } - return Object.prototype.toString.call(value); -} - -function shouldDropExactForDynamicText(text: string, enabled: boolean): boolean { - if (!enabled) return false; - - const normalized = text.trim().toLowerCase(); - if (!normalized) return false; - - if (normalized.length <= 24) { - return false; - } - const dynamicSignals = detectDynamicSignals(text); - const hasWeatherOrNewsSignal = dynamicSignals.includes( - "contains_weather_or_news_fragment" - ); - const hasDateOrTimeSignal = dynamicSignals.includes( - "contains_date_or_time_fragment" - ); - const hasNumericSignal = dynamicSignals.includes("contains_numeric_fragment"); - const hasHeadlineSignal = dynamicSignals.includes("contains_headline_like_text"); - - const hasDynamicNumericSignal = hasNumericSignal && hasHeadlineSignal; - const strongSignalCount = [ - hasDateOrTimeSignal, - hasWeatherOrNewsSignal, - hasDynamicNumericSignal, - ].filter(Boolean).length; - return strongSignalCount >= 2; -} diff --git a/src/core/transformer.test.ts b/src/core/transformer.test.ts index 78ae323..6049299 100644 --- a/src/core/transformer.test.ts +++ b/src/core/transformer.test.ts @@ -1,278 +1,7 @@ import { describe, it, expect } from "vitest"; -import { jsonlToSteps, jsonlToRecordingSteps } from "./transform/jsonl-transform.js"; import { playwrightCodeToSteps } from "./transform/playwright-ast-transform.js"; import { stepsToYaml, yamlToTest } from "./transform/yaml-io.js"; -describe("jsonlToSteps", () => { - it("parses navigate", () => { - const steps = jsonlToSteps('{"type":"navigate","url":"https://example.com"}'); - expect(steps).toEqual([{ action: "navigate", url: "https://example.com" }]); - }); - - it("parses selector actions into V2 target", () => { - const steps = jsonlToSteps('{"type":"click","selector":"button"}'); - expect(steps).toEqual([ - { - action: "click", - target: { - value: "button", - kind: "css", - source: "codegen-jsonl", - raw: "button", - confidence: 0.4, - warning: "Could not normalize selector from codegen locator chain; preserving raw selector.", - }, - }, - ]); - }); - - it("uses locator chain normalization in reliable mode", () => { - const steps = jsonlToSteps( - '{"type":"click","selector":"button","locator":{"kind":"role","body":"button","options":{"name":"Save"}}}' - ); - expect(steps).toEqual([ - { - action: "click", - target: { - value: "getByRole('button', { name: 'Save' })", - kind: "locatorExpression", - source: "codegen-jsonl", - }, - }, - ]); - }); - - it("drops exact for dynamic locator text in reliable mode", () => { - const steps = jsonlToSteps( - '{"type":"click","selector":"a.story-link","locator":{"kind":"role","body":"link","options":{"name":"Live update Schiphol 12:30: verkeer rond luchthaven vast","exact":true}}}' - ); - - expect(steps).toEqual([ - { - action: "click", - target: { - value: - "getByRole('link', { name: 'Live update Schiphol 12:30: verkeer rond luchthaven vast' })", - kind: "locatorExpression", - source: "codegen-jsonl", - }, - }, - ]); - }); - - it("drops exact for breaking headline text with date/time in reliable mode", () => { - const steps = jsonlToSteps( - '{"type":"click","selector":"a.breaking-link","locator":{"kind":"role","body":"link","options":{"name":"Breaking: markten reageren 2026-02-21 12:30","exact":true}}}' - ); - - expect(steps).toEqual([ - { - action: "click", - target: { - value: "getByRole('link', { name: 'Breaking: markten reageren 2026-02-21 12:30' })", - kind: "locatorExpression", - source: "codegen-jsonl", - }, - }, - ]); - }); - - it("keeps exact for long stable separator text in reliable mode", () => { - const steps = jsonlToSteps( - '{"type":"click","selector":"a.doc-link","locator":{"kind":"role","body":"link","options":{"name":"Download annual financial report: document section","exact":true}}}' - ); - - expect(steps).toEqual([ - { - action: "click", - target: { - value: - "getByRole('link', { name: 'Download annual financial report: document section', exact: true })", - kind: "locatorExpression", - source: "codegen-jsonl", - }, - }, - ]); - }); - - it("keeps exact when only a single dynamic keyword is present", () => { - const steps = jsonlToSteps( - '{"type":"click","selector":"a.help-link","locator":{"kind":"role","body":"link","options":{"name":"Live support handbook for enterprise onboarding teams","exact":true}}}' - ); - - expect(steps).toEqual([ - { - action: "click", - target: { - value: - "getByRole('link', { name: 'Live support handbook for enterprise onboarding teams', exact: true })", - kind: "locatorExpression", - source: "codegen-jsonl", - }, - }, - ]); - }); - - it("falls back to raw selector when locator normalization is invalid", () => { - const steps = jsonlToSteps( - '{"type":"click","selector":"#submit","locator":{"kind":"nth","body":"not-a-number"}}' - ); - - expect(steps).toEqual([ - { - action: "click", - target: { - value: "#submit", - kind: "css", - source: "codegen-jsonl", - raw: "#submit", - confidence: 0.4, - warning: "Could not normalize selector from codegen locator chain; preserving raw selector.", - }, - }, - ]); - }); - - it("preserves raw selectors in raw policy", () => { - const steps = jsonlToSteps('{"type":"click","selector":"text=Save"}', { - selectorPolicy: "raw", - }); - - expect(steps).toEqual([ - { - action: "click", - target: { - value: "text=Save", - kind: "playwrightSelector", - source: "codegen-jsonl", - }, - }, - ]); - }); - - it("keeps exact when raw policy falls back to normalized locator expressions", () => { - const steps = jsonlToSteps( - '{"type":"click","locator":{"kind":"role","body":"link","options":{"name":"Nederlaag voor Trump: hooggerechtshof VS oordeelt dat heffingen onwettig zijn","exact":true}}}', - { selectorPolicy: "raw" } - ); - - expect(steps).toEqual([ - { - action: "click", - target: { - value: - "getByRole('link', { name: 'Nederlaag voor Trump: hooggerechtshof VS oordeelt dat heffingen onwettig zijn', exact: true })", - kind: "locatorExpression", - source: "codegen-jsonl", - confidence: 0.8, - warning: "Raw selector was unavailable, using normalized locator expression.", - }, - }, - ]); - }); - - it("includes framePath metadata when present", () => { - const steps = jsonlToSteps( - '{"type":"click","selector":"button","framePath":["iframe[name=\\"checkout\\"]"]}' - ); - - expect(steps).toEqual([ - { - action: "click", - target: { - value: "button", - kind: "css", - source: "codegen-jsonl", - framePath: ['iframe[name="checkout"]'], - raw: "button", - confidence: 0.4, - warning: "Could not normalize selector from codegen locator chain; preserving raw selector.", - }, - }, - ]); - }); - - it("parses openPage into navigate and skips about:blank", () => { - const steps = jsonlToSteps( - '{"name":"openPage","url":"about:blank"}\n{"name":"openPage","url":"https://example.com"}' - ); - expect(steps).toEqual([{ action: "navigate", url: "https://example.com" }]); - }); - - it("skips malformed and unsupported lines", () => { - const steps = jsonlToSteps( - ['{"type":"unknown","selector":"button"}', 'not json', '{"type":"click","selector":"#x"}'].join("\n") - ); - expect(steps).toHaveLength(1); - expect(steps[0]).toMatchObject({ action: "click" }); - }); - - it("builds defined steps for every supported selector action", () => { - const lines = [ - '{"type":"click","selector":"#a"}', - '{"type":"fill","selector":"#a","text":"hello"}', - '{"type":"press","selector":"#a","key":"Enter"}', - '{"type":"check","selector":"#a"}', - '{"type":"uncheck","selector":"#a"}', - '{"type":"hover","selector":"#a"}', - '{"type":"select","selector":"#a","value":"v"}', - '{"type":"assertVisible","selector":"#a"}', - '{"type":"assertText","selector":"#a","text":"ok"}', - '{"type":"assertValue","selector":"#a","value":"ok"}', - '{"type":"assertChecked","selector":"#a"}', - ]; - - const steps = jsonlToSteps(lines.join("\n")); - expect(steps).toHaveLength(lines.length); - expect(steps.map((step) => step.action)).toEqual([ - "click", - "fill", - "press", - "check", - "uncheck", - "hover", - "select", - "assertVisible", - "assertText", - "assertValue", - "assertChecked", - ]); - }); -}); - -describe("jsonlToRecordingSteps", () => { - it("returns selector quality stats", () => { - const out = jsonlToRecordingSteps( - [ - '{"type":"navigate","url":"/"}', - '{"type":"click","selector":"#a"}', - '{"type":"click","selector":"#b","locator":{"kind":"role","body":"button"}}', - ].join("\n") - ); - - expect(out.steps).toHaveLength(3); - expect(out.stats.selectorSteps).toBe(2); - expect(out.stats.stableSelectors).toBe(1); - expect(out.stats.fallbackSelectors).toBe(1); - }); - - it("ignores unsupported actions without affecting selector stats", () => { - const out = jsonlToRecordingSteps( - [ - '{"type":"noop","selector":"#ignored","locator":{"kind":"nth","body":"not-a-number"}}', - '{"name":"noop","selector":"#also-ignored"}', - '{"type":"navigate","url":"/"}', - ].join("\n") - ); - - expect(out.steps).toEqual([{ action: "navigate", url: "/" }]); - expect(out.stats.selectorSteps).toBe(0); - expect(out.stats.stableSelectors).toBe(0); - expect(out.stats.fallbackSelectors).toBe(0); - expect(out.stats.frameAwareSelectors).toBe(0); - }); -}); - describe("playwrightCodeToSteps", () => { it("parses playwright-test code into V2 target steps", () => { const code = ` @@ -292,7 +21,8 @@ describe("playwrightCodeToSteps", () => { target: { value: "getByRole('button', { name: 'Save' })", kind: "locatorExpression", - source: "codegen-fallback", + source: "codegen", + confidence: 0.9, }, }, { @@ -300,7 +30,8 @@ describe("playwrightCodeToSteps", () => { target: { value: "locator('#status')", kind: "locatorExpression", - source: "codegen-fallback", + source: "codegen", + confidence: 0.5, }, text: "Done", }, @@ -325,6 +56,42 @@ describe("playwrightCodeToSteps", () => { const steps = playwrightCodeToSteps(code); expect(steps).toEqual([{ action: "navigate", url: "https://example.com" }]); }); + + it("parses fill, press, check, uncheck, hover, selectOption actions", () => { + const code = ` + import { test } from '@playwright/test'; + test('x', async ({ page }) => { + await page.getByLabel('Email').fill('user@example.com'); + await page.getByLabel('Email').press('Enter'); + await page.getByRole('checkbox').check(); + await page.getByRole('checkbox').uncheck(); + await page.getByText('Menu').hover(); + await page.getByRole('combobox').selectOption('us'); + }); + `; + + const steps = playwrightCodeToSteps(code); + expect(steps.map((s) => s.action)).toEqual([ + "fill", "press", "check", "uncheck", "hover", "select", + ]); + }); + + it("parses expect assertions", () => { + const code = ` + import { test, expect } from '@playwright/test'; + test('x', async ({ page }) => { + await expect(page.getByRole('heading')).toBeVisible(); + await expect(page.getByRole('heading')).toHaveText('Welcome'); + await expect(page.locator('#input')).toHaveValue('test'); + await expect(page.getByRole('checkbox')).toBeChecked(); + }); + `; + + const steps = playwrightCodeToSteps(code); + expect(steps.map((s) => s.action)).toEqual([ + "assertVisible", "assertText", "assertValue", "assertChecked", + ]); + }); }); describe("yaml conversion", () => { @@ -356,4 +123,43 @@ steps: `); expect(parsed).toHaveProperty("name", "T"); }); + + it("parses YAML with legacy codegen-jsonl source", () => { + const parsed = yamlToTest(` +name: Legacy Test +steps: + - action: click + target: + value: "getByRole('button')" + kind: locatorExpression + source: codegen-jsonl +`); + expect(parsed).toHaveProperty("name", "Legacy Test"); + }); + + it("parses YAML with legacy codegen-fallback source", () => { + const parsed = yamlToTest(` +name: Legacy Test +steps: + - action: click + target: + value: "getByRole('button')" + kind: locatorExpression + source: codegen-fallback +`); + expect(parsed).toHaveProperty("name", "Legacy Test"); + }); + + it("parses YAML with new codegen source", () => { + const parsed = yamlToTest(` +name: New Test +steps: + - action: click + target: + value: "getByRole('button')" + kind: locatorExpression + source: codegen +`); + expect(parsed).toHaveProperty("name", "New Test"); + }); }); diff --git a/src/core/yaml-schema.ts b/src/core/yaml-schema.ts index 24cc302..aefcc45 100644 --- a/src/core/yaml-schema.ts +++ b/src/core/yaml-schema.ts @@ -13,7 +13,7 @@ const fallbackTargetSchema = z.object({ "internal", "unknown", ]), - source: z.enum(["manual", "codegen-jsonl", "codegen-fallback"]), + source: z.enum(["manual", "codegen", "codegen-jsonl", "codegen-fallback", "devtools-import"]), }); export const targetSchema = z.object({ @@ -26,7 +26,7 @@ export const targetSchema = z.object({ "internal", "unknown", ]), - source: z.enum(["manual", "codegen-jsonl", "codegen-fallback"]), + source: z.enum(["manual", "codegen", "codegen-jsonl", "codegen-fallback", "devtools-import"]), framePath: z.array(z.string()).optional(), raw: z.string().optional(), confidence: z.number().min(0).max(1).optional(), @@ -49,6 +49,7 @@ const targetStep = baseStep.extend({ }); const clickStep = targetStep.extend({ action: z.literal("click") }); +const dblclickStep = targetStep.extend({ action: z.literal("dblclick") }); const hoverStep = targetStep.extend({ action: z.literal("hover") }); const checkStep = targetStep.extend({ action: z.literal("check") }); const uncheckStep = targetStep.extend({ action: z.literal("uncheck") }); @@ -116,6 +117,7 @@ const stepOptionalDeprecationGuard = z.unknown().superRefine((value, ctx) => { const stepSchemaByAction = z.discriminatedUnion("action", [ navigateStep, clickStep, + dblclickStep, fillStep, pressStep, checkStep, diff --git a/src/index.test.ts b/src/index.test.ts index fb22f1d..8d3d0f7 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -35,7 +35,7 @@ describe("CLI command registration", () => { // play options expect(help).toContain("--headed"); // record options - expect(help).toContain("--selector-policy"); + expect(help).toContain("--from-file"); // improve options expect(help).toContain("--assertions"); // setup options