diff --git a/packages/@webreel/core/src/types.ts b/packages/@webreel/core/src/types.ts index 950aca0..05108c8 100644 --- a/packages/@webreel/core/src/types.ts +++ b/packages/@webreel/core/src/types.ts @@ -48,6 +48,12 @@ export type CDPClient = { }; DOM: { enable: () => Promise; + getDocument: (params: { depth?: number }) => Promise<{ root: { nodeId: number } }>; + querySelector: (params: { + nodeId: number; + selector: string; + }) => Promise<{ nodeId: number }>; + setFileInputFiles: (params: { nodeId: number; files: string[] }) => Promise; }; }; diff --git a/packages/webreel/src/lib/__tests__/config.test.ts b/packages/webreel/src/lib/__tests__/config.test.ts index ae99a10..cd2a02c 100644 --- a/packages/webreel/src/lib/__tests__/config.test.ts +++ b/packages/webreel/src/lib/__tests__/config.test.ts @@ -194,6 +194,43 @@ describe("validateStep", () => { expect(errors).toEqual([]); }); + it("accepts valid upload step", () => { + const errors = validate({ + action: "upload", + selector: "input[type='file']", + filePath: "audio.mp3", + }); + expect(errors).toEqual([]); + }); + + it("validates upload requires non-empty selector", () => { + const errors = validate({ action: "upload", selector: "", filePath: "audio.mp3" }); + expect(errors).toContainEqual( + expect.objectContaining({ path: "videos.x.steps[0].selector" }), + ); + }); + + it("validates upload rejects missing selector", () => { + const errors = validate({ action: "upload", filePath: "audio.mp3" }); + expect(errors).toContainEqual( + expect.objectContaining({ path: "videos.x.steps[0].selector" }), + ); + }); + + it("validates upload requires non-empty filePath", () => { + const errors = validate({ action: "upload", selector: "#input", filePath: "" }); + expect(errors).toContainEqual( + expect.objectContaining({ path: "videos.x.steps[0].filePath" }), + ); + }); + + it("validates upload rejects missing filePath", () => { + const errors = validate({ action: "upload", selector: "#input" }); + expect(errors).toContainEqual( + expect.objectContaining({ path: "videos.x.steps[0].filePath" }), + ); + }); + it("accepts description field on any step", () => { const errors = validate({ action: "pause", diff --git a/packages/webreel/src/lib/__tests__/runner.test.ts b/packages/webreel/src/lib/__tests__/runner.test.ts index 1c9f10f..02f7049 100644 --- a/packages/webreel/src/lib/__tests__/runner.test.ts +++ b/packages/webreel/src/lib/__tests__/runner.test.ts @@ -84,6 +84,29 @@ describe("formatStep", () => { expect(formatStep(0, step)).toBe('[step 0] select "#country" value="US"'); }); + it("formats upload step", () => { + const step: Step = { + action: "upload", + selector: "input[type='file']", + filePath: "audio.mp3", + }; + expect(formatStep(0, step)).toBe( + `[step 0] upload selector="input[type='file']" file="audio.mp3"`, + ); + }); + + it("formats upload step with description", () => { + const step: Step = { + action: "upload", + selector: "#file-input", + filePath: "tests/fixtures/heydonna.mp3", + description: "upload audio", + }; + expect(formatStep(0, step)).toBe( + '[step 0] upload selector="#file-input" file="tests/fixtures/heydonna.mp3": upload audio', + ); + }); + it("includes description when present", () => { const step: Step = { action: "pause", ms: 100, description: "wait for animation" }; expect(formatStep(0, step)).toBe("[step 0] pause 100ms: wait for animation"); diff --git a/packages/webreel/src/lib/config.ts b/packages/webreel/src/lib/config.ts index c9fef3c..a6c31a7 100644 --- a/packages/webreel/src/lib/config.ts +++ b/packages/webreel/src/lib/config.ts @@ -290,6 +290,7 @@ const VALID_ACTIONS = new Set([ "navigate", "hover", "select", + "upload", ]); const KNOWN_TOP_LEVEL_KEYS = new Set([ @@ -398,6 +399,7 @@ const KNOWN_STEP_KEYS: Record> = { "delay", "description", ]), + upload: new Set(["action", "selector", "filePath", "label", "delay", "description"]), }; export interface ValidationError { @@ -630,6 +632,21 @@ function validateStep(step: unknown, index: number): ValidationError[] { } break; } + + case "upload": + if (typeof s.selector !== "string" || s.selector.length === 0) { + errors.push({ + path: `${prefix}.selector`, + message: "Must be a non-empty string", + }); + } + if (typeof s.filePath !== "string" || s.filePath.length === 0) { + errors.push({ + path: `${prefix}.filePath`, + message: "Must be a non-empty string", + }); + } + break; } if (s.delay !== undefined && (!Number.isFinite(s.delay) || (s.delay as number) < 0)) { diff --git a/packages/webreel/src/lib/runner.ts b/packages/webreel/src/lib/runner.ts index e97ee17..2f8a75e 100644 --- a/packages/webreel/src/lib/runner.ts +++ b/packages/webreel/src/lib/runner.ts @@ -1,5 +1,5 @@ import { resolve, dirname } from "node:path"; -import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { type CDPClient, @@ -58,6 +58,8 @@ export function formatStep(i: number, step: Step): string { return `[step ${i}] hover ${step.text ? `text="${step.text}"` : `selector="${step.selector}"`}${desc}`; case "select": return `[step ${i}] select "${step.selector}" value="${step.value}"${desc}`; + case "upload": + return `[step ${i}] upload selector="${step.selector}" file="${step.filePath}"${desc}`; default: { const _exhaustive: never = step; return `[step ${i}] ${(_exhaustive as Step).action}`; @@ -400,6 +402,24 @@ export async function runVideo( } break; } + + case "upload": { + const absolutePath = resolve(configDir, step.filePath); + if (!existsSync(absolutePath)) { + throw new Error(`File not found: ${absolutePath}`); + } + await client.DOM.enable(); + const { root } = await client.DOM.getDocument({ depth: 0 }); + const { nodeId } = await client.DOM.querySelector({ + nodeId: root.nodeId, + selector: step.selector, + }); + if (!nodeId) { + throw new Error(`File input not found: selector="${step.selector}"`); + } + await client.DOM.setFileInputFiles({ nodeId, files: [absolutePath] }); + break; + } } const stepDelay = "delay" in step ? step.delay : undefined; const postDelay = stepDelay ?? config.defaultDelay; diff --git a/packages/webreel/src/lib/types.ts b/packages/webreel/src/lib/types.ts index 2aed93c..c8d80f4 100644 --- a/packages/webreel/src/lib/types.ts +++ b/packages/webreel/src/lib/types.ts @@ -121,6 +121,15 @@ export interface StepSelect { description?: string; } +export interface StepUpload { + action: "upload"; + selector: string; + filePath: string; + label?: string; + delay?: number; + description?: string; +} + export type Step = | StepPause | StepClick @@ -133,7 +142,8 @@ export type Step = | StepScreenshot | StepNavigate | StepHover - | StepSelect; + | StepSelect + | StepUpload; export interface CursorConfig { image?: string;