mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
feat(execution): support site session env default (#2221)
Co-authored-by: jackwener <jakevingoo@gmail.com>
This commit is contained in:
@@ -171,6 +171,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil
|
||||
|----------|---------|-------------|
|
||||
| `OPENCLI_PROFILE` | — | Browser Bridge profile alias/contextId to use when multiple Chrome profiles are connected |
|
||||
| `OPENCLI_WINDOW` | command default | Set to `foreground` or `background` to override Browser Bridge window placement. Browser-backed commands also accept `--window <foreground\|background>`. |
|
||||
| `OPENCLI_SITE_SESSION` | adapter default | Set to `ephemeral` or `persistent` to override `siteSession` metadata for browser-backed adapter commands. `ephemeral` closes the one-shot automation window when the command finishes; `persistent` reuses the site's session. Per-command `--site-session` takes precedence. |
|
||||
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `45` | Seconds to wait for browser connection |
|
||||
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a single browser command |
|
||||
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol endpoint for remote browser or Electron apps |
|
||||
|
||||
@@ -156,6 +156,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `OPENCLI_WINDOW` | 命令默认值 | 设为 `foreground` 或 `background` 来覆盖 Browser Bridge 窗口位置。浏览器型命令也支持 `--window <foreground\|background>` |
|
||||
| `OPENCLI_SITE_SESSION` | adapter 默认值 | 设为 `ephemeral` 或 `persistent`,覆盖浏览器型 adapter 命令的 `siteSession` 元数据。`ephemeral` 会在命令结束时关闭一次性自动化窗口;`persistent` 会复用该站点的 session。命令级 `--site-session` 优先。 |
|
||||
| `OPENCLI_BROWSER_CONNECT_TIMEOUT` | `45` | 浏览器连接超时(秒) |
|
||||
| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | 单个浏览器命令超时(秒) |
|
||||
| `OPENCLI_CDP_ENDPOINT` | — | Chrome DevTools Protocol 端点,用于远程浏览器或 Electron 应用 |
|
||||
|
||||
+75
-22
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
@@ -11,6 +11,13 @@ import * as runtime from './runtime.js';
|
||||
import * as capRouting from './capabilityRouting.js';
|
||||
import * as daemonClient from './browser/daemon-client.js';
|
||||
import { BrowserCommandError } from './browser/daemon-client.js';
|
||||
import { clearAllHooks, onBeforeExecute } from './hooks.js';
|
||||
|
||||
afterEach(() => {
|
||||
clearAllHooks();
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('coerceAndValidateArgs', () => {
|
||||
it('rejects fractional values for integer arguments', () => {
|
||||
@@ -251,10 +258,16 @@ describe('executeCommand — non-browser timeout', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('lets user --site-session ephemeral override adapter persistent metadata', async () => {
|
||||
it.each([
|
||||
{ label: 'lets ephemeral env override persistent metadata', env: 'ephemeral', metadata: 'persistent', explicit: undefined, expected: 'ephemeral' },
|
||||
{ label: 'lets persistent env override ephemeral metadata', env: 'persistent', metadata: 'ephemeral', explicit: undefined, expected: 'persistent' },
|
||||
{ label: 'lets ephemeral flag override persistent env', env: 'persistent', metadata: 'persistent', explicit: 'ephemeral', expected: 'ephemeral' },
|
||||
{ label: 'lets persistent flag override ephemeral env', env: 'ephemeral', metadata: 'ephemeral', explicit: 'persistent', expected: 'persistent' },
|
||||
{ label: 'does not parse a shadowed invalid env', env: '', metadata: 'ephemeral', explicit: 'persistent', expected: 'persistent' },
|
||||
] as const)('$label', async ({ env, metadata, explicit, expected }) => {
|
||||
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
||||
const mockPage = { closeWindow } as any;
|
||||
const sessionOpts: Array<{ session?: string; idleTimeout?: number }> = [];
|
||||
const sessionOpts: Array<{ session?: string; siteSession?: string }> = [];
|
||||
|
||||
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
||||
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => {
|
||||
@@ -262,28 +275,68 @@ describe('executeCommand — non-browser timeout', () => {
|
||||
return fn(mockPage);
|
||||
});
|
||||
|
||||
try {
|
||||
const cmd = cli({
|
||||
site: 'test-execution',
|
||||
name: 'site-session-override-ephemeral', access: 'read',
|
||||
description: 'test user site-session override',
|
||||
browser: true,
|
||||
strategy: Strategy.PUBLIC,
|
||||
siteSession: 'persistent',
|
||||
func: async () => [{ ok: true }],
|
||||
});
|
||||
vi.stubEnv('OPENCLI_SITE_SESSION', env);
|
||||
const cmd = cli({
|
||||
site: 'test-execution',
|
||||
name: 'site-session-precedence', access: 'read',
|
||||
description: 'test site-session precedence',
|
||||
browser: true,
|
||||
strategy: Strategy.PUBLIC,
|
||||
siteSession: metadata,
|
||||
func: async () => [{ ok: true }],
|
||||
});
|
||||
|
||||
await executeCommand(cmd, {}, false, { siteSession: 'ephemeral' });
|
||||
await executeCommand(cmd, {}, false, explicit === undefined ? {} : { siteSession: explicit });
|
||||
|
||||
expect(sessionOpts).toHaveLength(1);
|
||||
expect(sessionOpts).toHaveLength(1);
|
||||
expect(sessionOpts[0]?.siteSession).toBe(expected);
|
||||
if (expected === 'persistent') {
|
||||
expect(sessionOpts[0]?.session).toBe('site:test-execution');
|
||||
expect(closeWindow).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(sessionOpts[0]?.session).toMatch(/^site:test-execution:/);
|
||||
expect(sessionOpts[0]?.idleTimeout).toBeUndefined();
|
||||
expect(closeWindow).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['', ' ', 'Persistent'])('rejects invalid OPENCLI_SITE_SESSION=%j before hooks or browser setup', async (env) => {
|
||||
const beforeHook = vi.fn();
|
||||
const adapter = vi.fn(async () => [{ ok: true }]);
|
||||
onBeforeExecute(beforeHook);
|
||||
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
||||
const browserSessionSpy = vi.spyOn(runtime, 'browserSession');
|
||||
|
||||
vi.stubEnv('OPENCLI_SITE_SESSION', env);
|
||||
const cmd = cli({
|
||||
site: 'test-execution',
|
||||
name: 'site-session-invalid-env', access: 'read',
|
||||
description: 'test invalid site-session env fails before side effects',
|
||||
browser: true,
|
||||
strategy: Strategy.PUBLIC,
|
||||
navigateBefore: 'https://example.com/',
|
||||
func: adapter,
|
||||
});
|
||||
|
||||
await expect(executeCommand(cmd, {})).rejects.toMatchObject({ code: 'ARGUMENT' });
|
||||
expect(beforeHook).not.toHaveBeenCalled();
|
||||
expect(browserSessionSpy).not.toHaveBeenCalled();
|
||||
expect(adapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not apply OPENCLI_SITE_SESSION to non-browser commands', async () => {
|
||||
vi.stubEnv('OPENCLI_SITE_SESSION', 'invalid');
|
||||
const cmd = cli({
|
||||
site: 'test-execution',
|
||||
name: 'site-session-node-only', access: 'read',
|
||||
description: 'test browser env does not poison node-only commands',
|
||||
browser: false,
|
||||
strategy: Strategy.PUBLIC,
|
||||
func: async () => [{ ok: true }],
|
||||
});
|
||||
|
||||
await expect(executeCommand(cmd, {})).resolves.toEqual([{ ok: true }]);
|
||||
});
|
||||
|
||||
it('skips repeated domain pre-navigation for persistent site sessions', async () => {
|
||||
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
||||
const goto = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -431,7 +484,7 @@ describe('executeCommand — non-browser timeout', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('calls closeWindow on browser command failure', async () => {
|
||||
it('lets env ephemeral override persistent metadata and closes the window on failure', async () => {
|
||||
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
||||
const mockPage = { closeWindow } as any;
|
||||
|
||||
@@ -446,16 +499,16 @@ describe('executeCommand — non-browser timeout', () => {
|
||||
const cmd = cli({
|
||||
site: 'test-execution',
|
||||
name: 'browser-close-on-error', access: 'read',
|
||||
description: 'test closeWindow on failure',
|
||||
description: 'test env ephemeral closeWindow on failure',
|
||||
browser: true,
|
||||
strategy: Strategy.PUBLIC,
|
||||
siteSession: 'persistent',
|
||||
func: async () => { throw new Error('adapter failure'); },
|
||||
});
|
||||
|
||||
vi.stubEnv('OPENCLI_SITE_SESSION', 'ephemeral');
|
||||
await expect(executeCommand(cmd, {})).rejects.toThrow('adapter failure');
|
||||
expect(closeWindow).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('skips closeWindow when --keep-tab=true (success path)', async () => {
|
||||
|
||||
+15
-6
@@ -211,6 +211,13 @@ export async function executeCommand(
|
||||
onTraceExport?: (trace: ObservationExportResult) => void;
|
||||
} = {},
|
||||
): Promise<unknown> {
|
||||
// Resolve browser-only configuration before argument hooks or any browser
|
||||
// lifecycle setup. Non-browser commands must not be affected by browser
|
||||
// environment defaults, even when those defaults are invalid.
|
||||
const siteSession = shouldUseBrowserSession(cmd)
|
||||
? resolveSiteSession(cmd, opts.siteSession)
|
||||
: null;
|
||||
|
||||
let kwargs: CommandArgs;
|
||||
try {
|
||||
kwargs = opts.prepared ? rawKwargs : prepareCommandArgs(cmd, rawKwargs);
|
||||
@@ -236,7 +243,7 @@ export async function executeCommand(
|
||||
|
||||
let result: unknown;
|
||||
try {
|
||||
if (shouldUseBrowserSession(cmd)) {
|
||||
if (siteSession !== null) {
|
||||
const electron = isElectronApp(cmd.site);
|
||||
let cdpEndpoint: string | undefined;
|
||||
|
||||
@@ -264,7 +271,6 @@ export async function executeCommand(
|
||||
const profileRouting = profileRouteParams(profileSelection);
|
||||
const contextId = profileSelection?.contextId;
|
||||
const internal = cmd as InternalCliCommand;
|
||||
const siteSession = resolveSiteSession(cmd, opts.siteSession);
|
||||
const session = resolveAdapterBrowserSession(cmd, siteSession);
|
||||
const keepTab = resolveKeepTab(siteSession, opts.keepTab);
|
||||
const windowMode = resolveBrowserWindowMode(cmd.defaultWindowMode ?? 'background', opts.windowMode);
|
||||
@@ -576,14 +582,17 @@ export function prepareCommandArgs(
|
||||
*/
|
||||
const RUNTIME_TIMEOUT_PADDING_SECONDS = 30;
|
||||
|
||||
function normalizeSiteSession(raw: unknown): SiteSessionMode | null {
|
||||
if (raw === undefined || raw === null || raw === '') return null;
|
||||
function normalizeSiteSession(name: string, raw: unknown): SiteSessionMode | null {
|
||||
if (raw === undefined) return null;
|
||||
if (raw === 'ephemeral' || raw === 'persistent') return raw;
|
||||
throw new ArgumentError(`--site-session must be one of: ephemeral, persistent. Received: "${String(raw)}"`);
|
||||
throw new ArgumentError(`${name} must be one of: ephemeral, persistent. Received: "${String(raw)}"`);
|
||||
}
|
||||
|
||||
function resolveSiteSession(cmd: CliCommand, rawOption?: unknown): SiteSessionMode {
|
||||
return normalizeSiteSession(rawOption) ?? cmd.siteSession ?? 'ephemeral';
|
||||
return normalizeSiteSession('--site-session', rawOption)
|
||||
?? normalizeSiteSession('OPENCLI_SITE_SESSION', process.env.OPENCLI_SITE_SESSION)
|
||||
?? cmd.siteSession
|
||||
?? 'ephemeral';
|
||||
}
|
||||
|
||||
function resolveAdapterBrowserSession(cmd: CliCommand, siteSession: SiteSessionMode): string {
|
||||
|
||||
Reference in New Issue
Block a user