Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/browse-watch-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"browse": minor
---

Add `browse watch` to poll for text, URL, visible, or checked conditions with configurable timeout and interval settings.
152 changes: 152 additions & 0 deletions packages/cli/src/commands/watch.ts
Original file line number Diff line number Diff line change
@@ -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: "<ms>",
}),
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: "<selector>",
}),
timeout: timeoutMsFlag,
};

async run(): Promise<void> {
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<ReturnType<typeof resolveTargetForCommand>>;
}): 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 };
}
59 changes: 59 additions & 0 deletions packages/cli/src/lib/driver/watch.ts
Original file line number Diff line number Diff line change
@@ -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<WatchAttempt>;
intervalMs: number;
timeoutMs: number;
}

export async function pollWatch(
options: PollWatchOptions,
): Promise<WatchResult> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
48 changes: 48 additions & 0 deletions packages/cli/tests/watch.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading