Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .github/workflows/e2e-headed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
# Pin macOS instead of following macos-latest while hosted macOS 26
# browser/runtime behavior remains less predictable.
os: [ubuntu-latest, macos-15]
steps:
- uses: actions/checkout@v6

Expand Down
20 changes: 14 additions & 6 deletions clis/twitter/delete.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,36 @@
import { cli, Strategy } from '@agentrhq/webcmd/registry';
import { CommandExecutionError } from '@agentrhq/webcmd/errors';
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
import { parseTweetUrl, buildTwitterArticleScopeSource, unwrapBrowserResult } from './shared.js';

function buildDeleteScript(tweetId) {
return `(async () => {
try {
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
${buildTwitterArticleScopeSource(tweetId)}
const targetArticle = findTargetArticle();
let targetArticle = findTargetArticle();
for (let i = 0; i < 20 && !targetArticle; i++) {
await new Promise(r => setTimeout(r, 250));
targetArticle = findTargetArticle();
}

if (!targetArticle) {
return { ok: false, message: 'Could not find the tweet card matching the requested URL.' };
}

const buttons = Array.from(targetArticle.querySelectorAll('button,[role="button"]'));
const moreMenu = buttons.find((el) => visible(el) && (el.getAttribute('aria-label') || '').trim() === 'More');
const belongsToTargetArticle = (el) => el.closest('article') === targetArticle;
const buttons = Array.from(targetArticle.querySelectorAll('button,[role="button"]')).filter(belongsToTargetArticle);
const moreMenu = Array.from(targetArticle.querySelectorAll('[data-testid="caret"]')).filter(belongsToTargetArticle).find(visible)
|| buttons.find((el) => visible(el) && /^More/.test((el.getAttribute('aria-label') || '').trim()));
if (!moreMenu) {
return { ok: false, message: 'Could not find the "More" context menu on the matched tweet. Are you sure you are logged in and looking at a valid tweet?' };
}

const beforeMenuItems = new Set(document.querySelectorAll('[role="menuitem"]'));
moreMenu.click();
await new Promise(r => setTimeout(r, 1000));

const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
const items = Array.from(document.querySelectorAll('[role="menuitem"]'))
.filter((item) => visible(item) && !beforeMenuItems.has(item));
const deleteBtn = items.find((item) => {
const text = (item.textContent || '').trim();
return text.includes('Delete') && !text.includes('List');
Expand Down Expand Up @@ -69,7 +77,7 @@ cli({
const target = parseTweetUrl(kwargs.url);
await page.goto(target.url);
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
const result = await page.evaluate(buildDeleteScript(target.id));
const result = unwrapBrowserResult(await page.evaluate(buildDeleteScript(target.id)));
if (result.ok) {
// Wait for the deletion request to be processed
await page.wait(2);
Expand Down
75 changes: 74 additions & 1 deletion clis/twitter/delete.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
import { getRegistry } from '@agentrhq/webcmd/registry';
import './delete.js';
import { __test__ } from './delete.js';
describe('twitter delete command', () => {
it('targets the matched tweet article instead of the first More button on the page', async () => {
const cmd = getRegistry().get('twitter/delete');
Expand All @@ -27,6 +28,12 @@ describe('twitter delete command', () => {
expect(script).toContain('__twGetStatusIdFromHref');
expect(script).toContain("document.querySelectorAll('article')");
expect(script).toContain("targetArticle.querySelectorAll('button,[role=\"button\"]')");
expect(script).toContain("closest('article') === targetArticle");
expect(script).toContain(".filter(belongsToTargetArticle)");
expect(script).toContain('[data-testid="caret"]');
expect(script).toContain('/^More/');
expect(script).toContain('i < 20');
expect(script).toContain('beforeMenuItems');
// Substring match must NOT appear — exact-id match only.
expect(script).not.toContain("'/status/' + tweetId");
expect(result).toEqual([
Expand Down Expand Up @@ -58,6 +65,72 @@ describe('twitter delete command', () => {
]);
expect(page.wait).toHaveBeenCalledTimes(1);
});
it('unwraps browser runtime evaluate envelopes before checking delete success', async () => {
const cmd = getRegistry().get('twitter/delete');
expect(cmd?.func).toBeTypeOf('function');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue({
session: 'twitter',
data: { ok: true, message: 'Tweet successfully deleted.' },
}),
};
const result = await cmd.func(page, {
url: 'https://x.com/alice/status/2040254679301718161',
});
expect(result).toEqual([
{
status: 'success',
message: 'Tweet successfully deleted.',
},
]);
expect(page.wait).toHaveBeenNthCalledWith(2, 2);
});
it('ignores stale non-target delete menu items that existed before opening the matched tweet menu', async () => {
const dom = new JSDOM(`
<body>
<div role="menuitem" data-stale-delete>Delete</div>
<article>
<a href="https://x.com/bob/status/999">wrong tweet</a>
<button data-testid="caret" aria-label="More"></button>
</article>
<article>
<a href="https://x.com/alice/status/2040254679301718161">target tweet</a>
<button data-testid="caret" aria-label="More" data-target-caret></button>
</article>
</body>
`, { runScripts: 'outside-only', url: 'https://x.com/alice/status/2040254679301718161' });
dom.window.setTimeout = (handler) => {
if (typeof handler === 'function') handler();
return 0;
};
Object.defineProperty(dom.window.HTMLElement.prototype, 'getClientRects', {
configurable: true,
value() {
return [{ bottom: 1, height: 1, left: 0, right: 1, top: 0, width: 1 }];
},
});
let staleDeleteClicked = false;
let targetCaretClicked = false;
dom.window.document.querySelector('[data-stale-delete]')?.addEventListener('click', () => {
staleDeleteClicked = true;
});
dom.window.document.querySelector('[data-target-caret]')?.addEventListener('click', () => {
targetCaretClicked = true;
const item = dom.window.document.createElement('div');
item.setAttribute('role', 'menuitem');
item.textContent = 'Pin to your profile';
dom.window.document.body.appendChild(item);
});
const result = await dom.window.eval(__test__.buildDeleteScript('2040254679301718161'));
expect(targetCaretClicked).toBe(true);
expect(staleDeleteClicked).toBe(false);
expect(result).toEqual({
ok: false,
message: 'The matched tweet menu did not contain Delete. This tweet may not belong to you.',
});
});
it('rejects malformed or off-domain URLs with ArgumentError before navigation', async () => {
const cmd = getRegistry().get('twitter/delete');
expect(cmd?.func).toBeTypeOf('function');
Expand Down
12 changes: 7 additions & 5 deletions src/browser/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ChildProcess } from 'node:child_process';
import type { IPage } from '../types.js';
import type { IBrowserFactory } from '../runtime.js';
import { Page } from './page.js';
import { resolveProfileContextId } from './profile.js';
import { profileRouteParams, resolveProfileSelection } from './profile.js';
import { ensureBrowserBridgeReady } from './daemon-lifecycle.js';

const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
Expand All @@ -25,7 +25,7 @@ export class BrowserBridge implements IBrowserFactory {
return this._state;
}

async connect(opts: { timeout?: number; session?: string; idleTimeout?: number; contextId?: string; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent' } = {}): Promise<IPage> {
async connect(opts: { timeout?: number; session?: string; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent' } = {}): Promise<IPage> {
if (this._state === 'connected' && this._page) return this._page;
if (this._state === 'connecting') throw new Error('Already connecting');
if (this._state === 'closing') throw new Error('Session is closing');
Expand All @@ -34,10 +34,12 @@ export class BrowserBridge implements IBrowserFactory {
this._state = 'connecting';

try {
const contextId = opts.contextId ?? resolveProfileContextId();
await this._ensureDaemon(opts.timeout, contextId);
const routing = opts.contextId || opts.preferredContextId
? { contextId: opts.contextId, preferredContextId: opts.preferredContextId }
: profileRouteParams(resolveProfileSelection());
await this._ensureDaemon(opts.timeout, routing.contextId);
if (!opts.session?.trim()) throw new Error('Browser session is required');
this._page = new Page(opts.session.trim(), opts.idleTimeout, contextId, opts.windowMode, opts.surface, opts.siteSession);
this._page = new Page(opts.session.trim(), opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId);
this._state = 'connected';
return this._page;
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion src/browser/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class CDPBridge implements IBrowserFactory {
private _pending = new Map<number, { resolve: (val: unknown) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> }>();
private _eventListeners = new Map<string, Set<(params: unknown) => void>>();

async connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; idleTimeout?: number; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent' }): Promise<IPage> {
async connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent' }): Promise<IPage> {
if (this._ws) throw new Error('CDPBridge is already connected. Call close() before reconnecting.');

const endpoint = opts?.cdpEndpoint ?? process.env.WEBCMD_CDP_ENDPOINT;
Expand Down
55 changes: 30 additions & 25 deletions src/browser/daemon-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,29 +159,6 @@ describe('daemon-client', () => {
expect(vi.mocked(fetch).mock.calls[0][0]).toMatch(/\/status\?contextId=work$/);
});

it('rejects WEBCMD_DAEMON_PORT so CLI and extension cannot split bridge ports', async () => {
vi.resetModules();
vi.stubEnv('WEBCMD_DAEMON_PORT', '19999');
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({
ok: true,
pid: 1,
uptime: 0,
runtimeConnected: true,
runtimeName: 'fake',
pending: 0,
memoryMB: 1,
port: 9777,
}),
} as Response);

const freshClient = await import('./daemon-client.js');
await expect(freshClient.fetchDaemonStatus()).rejects.toThrow('WEBCMD_DAEMON_PORT is no longer supported');

expect(vi.mocked(fetch)).not.toHaveBeenCalled();
});

it('sendCommand includes the current pid in generated command ids', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
vi.mocked(fetch).mockResolvedValue({
Expand All @@ -203,7 +180,7 @@ describe('daemon-client', () => {
expect(ids[0]).not.toBe(ids[1]);
});

it('sendCommand forwards WEBCMD_PROFILE as command contextId', async () => {
it('sendCommand forwards WEBCMD_PROFILE as a hard contextId requirement', async () => {
vi.stubEnv('WEBCMD_PROFILE', 'work');
vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
vi.mocked(fetch).mockResolvedValue({
Expand All @@ -213,8 +190,36 @@ describe('daemon-client', () => {

await sendCommand('exec', { code: '1 + 1' });

const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)) as { contextId?: string };
const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)) as { contextId?: string; preferredContextId?: string };
expect(body.contextId).toBe('work');
expect(body.preferredContextId).toBeUndefined();
});

it('sendCommand forwards the config default as preferredContextId', async () => {
const fs = await import('node:fs');
const os = await import('node:os');
const path = await import('node:path');
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-dc-profile-'));
fs.writeFileSync(
path.join(configDir, 'browser-profiles.json'),
JSON.stringify({ version: 1, aliases: {}, defaultContextId: 'profile-default' }),
);
vi.stubEnv('WEBCMD_CONFIG_DIR', configDir);
vi.stubEnv('WEBCMD_PROFILE', '');
try {
vi.mocked(fetch).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }),
} as Response);

await sendCommand('exec', { code: '1 + 1' });

const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)) as { contextId?: string; preferredContextId?: string };
expect(body.contextId).toBeUndefined();
expect(body.preferredContextId).toBe('profile-default');
} finally {
fs.rmSync(configDir, { recursive: true, force: true });
}
});

it('sendCommand uses explicit windowMode before WEBCMD_WINDOW env fallback', async () => {
Expand Down
11 changes: 8 additions & 3 deletions src/browser/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { sleep } from '../utils.js';
import { BrowserConnectError } from '../errors.js';
import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT } from '../daemon-utils.js';
import { classifyBrowserError } from './errors.js';
import { resolveProfileContextId } from './profile.js';
import { profileRouteParams, resolveProfileSelection } from './profile.js';
import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './config.js';
import { ensureBrowserBridgeReady } from './daemon-lifecycle.js';
import { isPreDispatchError } from './bridge-readiness.js';
Expand Down Expand Up @@ -117,7 +117,11 @@ async function sendCommandRaw(
const envWindowMode = rawWindowMode === 'foreground' || rawWindowMode === 'background'
? rawWindowMode
: undefined;
const contextId = params.contextId ?? resolveProfileContextId();
const routing = params.contextId || params.preferredContextId
? { contextId: params.contextId, preferredContextId: params.preferredContextId }
: profileRouteParams(resolveProfileSelection());
const contextId = routing.contextId;
const preferredContextId = routing.preferredContextId;
const windowMode = params.windowMode ?? envWindowMode;

let id = generateId();
Expand Down Expand Up @@ -150,6 +154,7 @@ async function sendCommandRaw(
timeout: timeoutSeconds,
deadlineAt,
...(contextId && { contextId }),
...(preferredContextId && { preferredContextId }),
...(windowMode && { windowMode }),
};
try {
Expand Down Expand Up @@ -245,6 +250,6 @@ export async function sendCommandFull(
return { data: result.data, page: result.page };
}

export async function bindTab(session: string, opts: { contextId?: string; page?: string; index?: number } = {}): Promise<unknown> {
export async function bindTab(session: string, opts: { contextId?: string; preferredContextId?: string; page?: string; index?: number } = {}): Promise<unknown> {
return sendCommand('bind', { session, surface: 'browser', ...opts });
}
21 changes: 3 additions & 18 deletions src/browser/daemon-transport.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@
import { DAEMON_HEADER_NAME, DEFAULT_DAEMON_PORT, unsupportedDaemonPortEnvMessage } from '../constants.js';
import { DAEMON_HEADER_NAME, DEFAULT_DAEMON_PORT } from '../constants.js';

const DAEMON_PORT = DEFAULT_DAEMON_PORT;
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
const WEBCMD_HEADERS = { [DAEMON_HEADER_NAME]: '1' };

class UnsupportedDaemonPortEnvError extends Error {
constructor(value: string) {
super(unsupportedDaemonPortEnvMessage(value));
this.name = 'UnsupportedDaemonPortEnvError';
}
}

function assertSupportedDaemonPortEnv(): void {
const value = process.env.WEBCMD_DAEMON_PORT;
if (value !== undefined && value !== '') throw new UnsupportedDaemonPortEnvError(value);
}

export interface DaemonStatus {
ok: boolean;
pid: number;
Expand Down Expand Up @@ -50,7 +38,6 @@ export type DaemonHealth =
| { state: 'ready'; status: DaemonStatus };

export async function requestDaemon(pathname: string, init?: RequestInit & { timeout?: number }): Promise<Response> {
assertSupportedDaemonPortEnv();
const { timeout = 2000, headers, ...rest } = init ?? {};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
Expand All @@ -71,8 +58,7 @@ export async function fetchDaemonStatus(opts?: { timeout?: number; contextId?: s
const res = await requestDaemon(`/status${params}`, { timeout: opts?.timeout ?? 2000 });
if (!res.ok) return null;
return await res.json() as DaemonStatus;
} catch (err) {
if (err instanceof UnsupportedDaemonPortEnvError) throw err;
} catch {
return null;
}
}
Expand All @@ -90,8 +76,7 @@ export async function requestDaemonShutdown(opts?: { timeout?: number }): Promis
try {
const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 });
return res.ok;
} catch (err) {
if (err instanceof UnsupportedDaemonPortEnvError) throw err;
} catch {
return false;
}
}
5 changes: 4 additions & 1 deletion src/browser/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class Page extends BasePage {
private readonly windowMode?: 'foreground' | 'background',
private readonly surface: 'browser' | 'adapter' = 'browser',
private readonly siteSession?: 'ephemeral' | 'persistent',
public readonly preferredContextId?: string,
) {
super();
this._idleTimeout = idleTimeout;
Expand All @@ -60,11 +61,12 @@ export class Page extends BasePage {
private _networkCaptureWarned = false;

/** Helper: spread session into command params */
private _sessionOpts(): { session: string; surface: 'browser' | 'adapter'; idleTimeout?: number; contextId?: string; windowMode?: 'foreground' | 'background'; siteSession?: 'ephemeral' | 'persistent' } {
private _sessionOpts(): { session: string; surface: 'browser' | 'adapter'; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; siteSession?: 'ephemeral' | 'persistent' } {
return {
session: this.session,
surface: this.surface,
...(this.contextId && { contextId: this.contextId }),
...(this.preferredContextId && { preferredContextId: this.preferredContextId }),
...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
...(this.windowMode && { windowMode: this.windowMode }),
...(this.siteSession && { siteSession: this.siteSession }),
Expand All @@ -77,6 +79,7 @@ export class Page extends BasePage {
session: this.session,
surface: this.surface,
...(this.contextId && { contextId: this.contextId }),
...(this.preferredContextId && { preferredContextId: this.preferredContextId }),
...(this._page !== undefined && { page: this._page }),
...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
...(this.windowMode && { windowMode: this.windowMode }),
Expand Down
Loading
Loading