diff --git a/CHANGELOG.md b/CHANGELOG.md index ca22584..3c966aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **CUA mode** (`configure({ ai: { mode: "cua" } })`): execute `runSteps` and `runUserFlow` through OpenAI's Responses API with the built-in `computer` tool. Screenshot-driven, coordinate-based actions via Playwright's `page.mouse` / `page.keyboard`. Requires `OPENAI_API_KEY` and `gateway: "none"`; Redis step caching is skipped in this mode because coordinate actions aren't portable across viewport sizes. - `cua` model slot in `ModelConfig` (default: `gpt-5.5`). For now, you cannot override the CUA model. - `getMode()` helper and `AIMode` type exported from `src/config.ts`. +- **File upload caching**: `browser_upload_file` now writes a step cache entry, so repeat runs replay the cached upload-button locator instead of re-resolving it through the model. The flow is unchanged — the file chooser is still opened by clicking the button and the files are still set with `fileChooser.setFiles`. ## [1.0.0] - 2026-03-27 diff --git a/src/__tests__/upload-file.test.ts b/src/__tests__/upload-file.test.ts index 9382bb5..d693306 100644 --- a/src/__tests__/upload-file.test.ts +++ b/src/__tests__/upload-file.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveUploadPath } from "../tools"; +import { resolveCachedUploadPaths, resolveUploadPath } from "../tools"; describe("resolveUploadPath", () => { it("returns absolute Unix paths as-is", () => { @@ -33,3 +33,35 @@ describe("resolveUploadPath", () => { expect(resolveUploadPath("uploads/test/document.pdf", "./uploads")).toBe("./uploads/uploads/test/document.pdf"); }); }); + +describe("cached upload path resolution", () => { + it("resolves a raw filename coming from step.data", () => { + expect(resolveCachedUploadPaths("report.pdf", "", "./uploads")).toEqual([ + "./uploads/report.pdf", + ]); + }); + + it("does not re-prefix the cached value when step.data has no value", () => { + expect(resolveCachedUploadPaths(undefined, "./uploads/a.pdf,/tmp/b.png", "./uploads")).toEqual([ + "./uploads/a.pdf", + "/tmp/b.png", + ]); + }); + + it("prefers step.data over the cached value", () => { + expect(resolveCachedUploadPaths("fresh.pdf", "./uploads/stale.pdf", "./uploads")).toEqual([ + "./uploads/fresh.pdf", + ]); + }); + + it("splits multiple raw filenames and skips empty entries", () => { + expect(resolveCachedUploadPaths("a.pdf, b.png,", "", "./uploads")).toEqual([ + "./uploads/a.pdf", + "./uploads/b.png", + ]); + }); + + it("returns an empty list when neither source has a value", () => { + expect(resolveCachedUploadPaths(undefined, undefined, "./uploads")).toEqual([]); + }); +}); diff --git a/src/index.ts b/src/index.ts index 75ee9d4..40b0e65 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,7 @@ async function maybeWithSpan( import { z } from "zod"; import { buildRunStepsPrompt, buildRunUserFlowPrompt } from "./prompts"; import { getRedis } from "./redis"; -import { getAItools } from "./tools"; +import { getAItools, resolveCachedUploadPaths } from "./tools"; import { RunStepsOptions, UserFlowOptions } from "./types"; import { runLocatorCode, @@ -40,7 +40,7 @@ import { replacePlaceholders, resolveEmailPlaceholders, } from "./data-cache"; -import { resolveAI } from "./config"; +import { getConfig, resolveAI } from "./config"; import { runCUALoop, buildRunStepsPromptCUA, buildRunUserFlowPromptCUA } from "./cua"; import { applyExtraction } from "./extract"; import { logger } from "./logger"; @@ -385,6 +385,22 @@ export const runSteps = async ({ case "select-option": code = `await page.${locator}.describe('${description}').selectOption("${input}", { timeout: ${CACHED_ACTION_TIMEOUT} })`; break; + case "uploadFile": { + // `input` is deliberately not used here: see resolveCachedUploadPaths. + const uploadPaths = resolveCachedUploadPaths( + step.data?.value, + value, + getConfig().uploadBasePath || "./uploads", + ); + + code = ` + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.${locator}.describe('${description}').click({ timeout: ${CACHED_ACTION_TIMEOUT} }); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(${JSON.stringify(uploadPaths)}); + `; + break; + } case "waitForText": code = `await page.getByText("${value}", { exact: true }).first().waitFor({ state: "visible" })`; break; diff --git a/src/tools.ts b/src/tools.ts index e02f8b3..e3d3016 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -22,6 +22,27 @@ export function resolveUploadPath(filePath: string, uploadBasePath: string): str : `${uploadBasePath}/${filePath}`; } +/** + * Resolves the file paths a cached "uploadFile" step should attach. The two sources hold + * different things: `rawValue` (from the step's data) is an unresolved filename, while + * `cachedValue` was already resolved against the base path when the step was recorded and + * must be used as-is — resolving it again would re-prefix it whenever `uploadBasePath` is + * relative, which is the default. + */ +export function resolveCachedUploadPaths( + rawValue: string | undefined, + cachedValue: string | undefined, + uploadBasePath: string, +): string[] { + return (rawValue ?? cachedValue ?? "") + .split(",") + .map((filePath) => filePath.trim()) + .filter(Boolean) + .map((filePath) => + rawValue === undefined ? filePath : resolveUploadPath(filePath, uploadBasePath), + ); +} + type ToolSettings = { abortController?: AbortController; currentStep?: { description: string; data?: Record }; @@ -588,8 +609,12 @@ class PlaywrightTools { const uploadBasePath = getConfig().uploadBasePath || "./uploads"; const prefixedFilePaths = filePaths.map((filePath) => resolveUploadPath(filePath, uploadBasePath)); - // File uploads are not cached for now as it needs a two step process - // We can solve this later by introducing multi-action caching if needed + let cachedLocator = ""; + + if (this.currentStep) { + cachedLocator = await this.resolveLocator(locator); + } + const fileChooserPromise = this.page.waitForEvent("filechooser"); await locator.click({ timeout: LOCATOR_ACTION_TIMEOUT }); const fileChooser = await fileChooserPromise; @@ -597,6 +622,15 @@ class PlaywrightTools { timeout: LOCATOR_ACTION_TIMEOUT, }); + // Cached as a single "uploadFile" action: the locator stored is the button that opens + // the file chooser, and the replay in index.ts performs both steps against it. + this.prepareCacheData( + cachedLocator, + "uploadFile", + elementDescription, + prefixedFilePaths.join(","), + ); + return { success: true, prefixedFilePaths,