diff --git a/.changeset/browse-watch-command.md b/.changeset/browse-watch-command.md new file mode 100644 index 000000000..d9231eac1 --- /dev/null +++ b/.changeset/browse-watch-command.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +Add `browse watch` to poll for text, URL, visible, or checked conditions with configurable timeout and interval settings. diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts new file mode 100644 index 000000000..d99316914 --- /dev/null +++ b/packages/cli/src/commands/watch.ts @@ -0,0 +1,152 @@ +import { Args, Flags } from "@oclif/core"; + +import { BrowseCommand } from "../base.js"; +import { + driverCommandFlags, + resolveTargetForCommand, + timeoutMsFlag, + type DriverFlags, +} from "../lib/driver/command-cli.js"; +import { sessionName } from "../lib/driver/flags.js"; +import { runDriverCommandWithTarget } from "../lib/driver/runtime.js"; +import { createStringMatcher, pollWatch } from "../lib/driver/watch.js"; +import { fail } from "../lib/errors.js"; +import { outputJson } from "../lib/output.js"; + +type WatchKind = "checked" | "text" | "url" | "visible"; + +export default class Watch extends BrowseCommand { + static override description = + "Poll until text, URL, or selector state matches."; + + static override examples = [ + "browse watch text 'Order confirmed'", + "browse watch text 'Order #\\d+' --regex", + "browse watch text 'Thanks' --selector main", + "browse watch url '/checkout'", + "browse watch visible '#submit'", + "browse watch checked '#terms' --timeout 60000", + ]; + + static override args = { + kind: Args.string({ + description: "Condition kind to watch.", + options: ["text", "url", "visible", "checked"], + required: true, + }), + target: Args.string({ + description: + "Text/URL query for text|url, or selector for visible|checked.", + required: true, + }), + }; + + static override flags = { + ...driverCommandFlags, + interval: Flags.integer({ + default: 500, + description: "Polling interval in milliseconds.", + helpValue: "", + }), + regex: Flags.boolean({ + default: false, + description: "Treat text/url target as a regular expression.", + }), + selector: Flags.string({ + description: "Optional selector to scope text checks (defaults to body).", + helpValue: "", + }), + timeout: timeoutMsFlag, + }; + + async run(): Promise { + const { args, flags } = await this.parse(Watch); + const kind = args.kind as WatchKind; + + if (flags.interval <= 0) { + fail("--interval must be a positive integer."); + } + + if ((kind === "visible" || kind === "checked") && flags.regex) { + fail("--regex is only valid for text and url watch kinds."); + } + + if ((kind === "visible" || kind === "checked") && flags.selector) { + fail("--selector is only valid for text watch kind."); + } + + const session = sessionName((flags as DriverFlags).session); + const target = await resolveTargetForCommand(session, flags as DriverFlags); + const query = args.target; + const matcher = createStringMatcher(query, flags.regex); + + try { + const result = await pollWatch({ + check: async () => + checkCondition({ + kind, + matcher, + query, + selector: flags.selector, + session, + target, + }), + intervalMs: flags.interval, + timeoutMs: flags.timeout, + }); + + outputJson({ + attempts: result.attempts, + elapsedMs: result.elapsedMs, + kind, + matched: true, + session, + value: result.value, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + fail(`watch ${kind} failed: ${message}`); + } + } +} + +async function checkCondition(options: { + kind: WatchKind; + matcher: (value: string) => boolean; + query: string; + selector?: string; + session: string; + target: Awaited>; +}): Promise<{ matched: boolean; value?: string }> { + if (options.kind === "visible" || options.kind === "checked") { + const key = options.kind; + const result = (await runDriverCommandWithTarget( + options.session, + options.target, + "is", + { check: key, selector: options.query }, + )) as { checked?: boolean; visible?: boolean }; + const boolValue = key === "visible" ? result.visible : result.checked; + return { matched: Boolean(boolValue), value: String(boolValue) }; + } + + if (options.kind === "url") { + const result = (await runDriverCommandWithTarget( + options.session, + options.target, + "get", + { what: "url" }, + )) as { url?: string }; + const value = result.url ?? ""; + return { matched: options.matcher(value), value }; + } + + const result = (await runDriverCommandWithTarget( + options.session, + options.target, + "get", + { selector: options.selector ?? "body", what: "text" }, + )) as { text?: string }; + const value = result.text ?? ""; + return { matched: options.matcher(value), value }; +} diff --git a/packages/cli/src/lib/driver/watch.ts b/packages/cli/src/lib/driver/watch.ts new file mode 100644 index 000000000..be9c28e09 --- /dev/null +++ b/packages/cli/src/lib/driver/watch.ts @@ -0,0 +1,59 @@ +export interface WatchAttempt { + matched: boolean; + value?: string; +} + +export interface WatchResult { + attempts: number; + elapsedMs: number; + value?: string; +} + +export interface PollWatchOptions { + check: () => Promise; + intervalMs: number; + timeoutMs: number; +} + +export async function pollWatch( + options: PollWatchOptions, +): Promise { + const start = Date.now(); + let attempts = 0; + + while (true) { + attempts += 1; + const attempt = await options.check(); + if (attempt.matched) { + return { + attempts, + elapsedMs: Date.now() - start, + value: attempt.value, + }; + } + + if (Date.now() - start >= options.timeoutMs) { + throw new Error( + `Watch condition not met within ${options.timeoutMs}ms after ${attempts} checks.`, + ); + } + + await sleep(options.intervalMs); + } +} + +export function createStringMatcher( + query: string, + regex: boolean, +): (value: string) => boolean { + if (!regex) { + return (value: string) => value.includes(query); + } + + const re = new RegExp(query); + return (value: string) => re.test(value); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/cli/tests/watch.test.ts b/packages/cli/tests/watch.test.ts new file mode 100644 index 000000000..dbbd37831 --- /dev/null +++ b/packages/cli/tests/watch.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createStringMatcher, pollWatch } from "../src/lib/driver/watch.js"; + +describe("watch helpers", () => { + it("matches substring by default", () => { + const matcher = createStringMatcher("confirmed", false); + expect(matcher("Order confirmed")).toBe(true); + expect(matcher("Pending")).toBe(false); + }); + + it("matches regex when enabled", () => { + const matcher = createStringMatcher("Order #\\d+", true); + expect(matcher("Order #123")).toBe(true); + expect(matcher("Order pending")).toBe(false); + }); + + it("polls until a condition is met", async () => { + const check = vi + .fn<() => Promise<{ matched: boolean; value?: string }>>() + .mockResolvedValueOnce({ matched: false, value: "Pending" }) + .mockResolvedValueOnce({ matched: false, value: "Pending" }) + .mockResolvedValueOnce({ matched: true, value: "Confirmed" }); + + const result = await pollWatch({ + check, + intervalMs: 1, + timeoutMs: 1_000, + }); + + expect(result.attempts).toBe(3); + expect(result.value).toBe("Confirmed"); + }); + + it("times out when condition is never met", async () => { + const check = vi + .fn<() => Promise<{ matched: boolean; value?: string }>>() + .mockResolvedValue({ matched: false, value: "Pending" }); + + await expect( + pollWatch({ + check, + intervalMs: 1, + timeoutMs: 5, + }), + ).rejects.toThrow("Watch condition not met within"); + }); +});