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
2 changes: 1 addition & 1 deletion skills/webcmd-adapter-author/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ Check these off step by step:
- **The `browser:` field determines the `func` signature:** `browser:false -> (args)`, `browser:true -> (page, args)`. If this is reversed, `args` may actually be a debug flag and all external parameters can silently fall back to defaults.
- Throw the correct typed error for known failures according to [`references/typed-errors.md`](./references/typed-errors.md). **Do not** silently `return []`, **do not** silently `return [{sentinel}]`, and **do not** silently clamp external parameters with `Math.max/min`.
- **Persistent sessions keep stale DOM between commands.** `siteSession: 'persistent'` shares one tab per site; leftover modals/drawers from the previous command leak into the next one. State-sensitive write commands (checkout flows) should add `freshPage: true` (new tab, same lease — cookies/login/location survive). Verify session-scoped context (login, selected city/date) *before* side effects, and embed such context in URLs/IDs your command emits for sibling commands. See `references/adapter-template.md` and "Persistent Sessions and State Hygiene" in `docs/authoring.mdx`.
- For private iteration, write `~/.webcmd/clis/<site>/<name>.js` to avoid a build. When the user says to promote a CLI, create a main-repo plugin with `webcmd plugin create <site> --dir plugins/<site>`, copy the real command files into it, delete scaffold sample commands, register it in root `webcmd-plugin.json`, remove the local `~/.webcmd/clis/<site>` shadow, install the plugin, then run `webcmd validate <site>` and smoke commands. See `references/adapter-template.md` for details.
- For private iteration, write `~/.webcmd/clis/<site>/<name>.js` to avoid a build. When the user says to promote a CLI, create a main-repo plugin with `webcmd plugin create <site> --dir plugins/<site> --author-name "Agent" --author-handle "agent"`, copy the real command files into it, delete scaffold sample commands, register it in root `webcmd-plugin.json`, remove the local `~/.webcmd/clis/<site>` shadow, install the plugin, then run `webcmd validate <site>` and smoke commands. See `references/adapter-template.md` for details.
- Write site memory every round: no memory -> use skill -> produce memory -> next time becomes a five-minute task.
- **After a site's first command passes verify, stop and ask the user for their use cases before recommending next set of commands.** See Runbook Step 13.
- **Raw dumps, packet captures, and HTML samples from debugging may only be written to `~/.webcmd/sites/<site>/fixtures/` or `/tmp/`. Never leave `.dbg-*.html`, `raw-*.json`, `sample.*`, or similar temporary files in the repo root, `clis/<site>/`, or the current working directory.**
Expand Down
4 changes: 3 additions & 1 deletion src/browser/runtime/local-cloak/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { CloakSessionManager } from './session-manager.js';
import type { BrowserContext, Frame, Page as PlaywrightPage } from 'playwright-core';
import { runBrowserProgram } from '../../run/runner.js';
import { BROWSER_RUN_MAX_SOURCE_BYTES } from '../../run/types.js';
import { toPlaywrightWaitUntil } from '../../utils.js';

const snapshotBaselines = new WeakMap<CloakSessionManager, SnapshotBaselineStore>();

Expand Down Expand Up @@ -185,7 +186,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command:
// 'none' maps to Playwright's 'commit': sites that stream analytics forever
// never fire the load event, so adapters gating readiness on their own
// selector waits must be able to skip it.
await lease.page.goto(command.url, { waitUntil: command.waitUntil === 'none' ? 'commit' : 'load' });
await lease.page.goto(command.url, { waitUntil: toPlaywrightWaitUntil(command.waitUntil) });
return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url(), timedOut: false }, page: lease.pageId };
}
case 'exec': {
Expand Down Expand Up @@ -336,6 +337,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command:
siteSession: command.siteSession,
idleTimeout: command.idleTimeout,
url: command.url,
waitUntil: command.waitUntil,
windowMode: command.windowMode,
});
return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url() }, page: lease.pageId };
Expand Down
5 changes: 3 additions & 2 deletions src/browser/runtime/local-cloak/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContex
import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js';
import { CloakNetworkCapture } from './network.js';
import { findPackageRoot } from '../../../package-paths.js';
import { toPlaywrightWaitUntil } from '../../utils.js';

const UNRESOLVED = Symbol('unresolved');
let cachedCloakBrowserVersion: string | undefined | typeof UNRESOLVED = UNRESOLVED;
Expand Down Expand Up @@ -270,7 +271,7 @@ export class CloakSessionManager {
})));
}

async newPage(input: SessionKeyInput & { url?: string }): Promise<CloakPageLease> {
async newPage(input: SessionKeyInput & { url?: string; waitUntil?: 'load' | 'none' }): Promise<CloakPageLease> {
const profileId = normalizeProfileId(input.profileId);
const session = requireSession(input.session);
const surface = normalizeSurface(input.surface);
Expand All @@ -282,7 +283,7 @@ export class CloakSessionManager {
);
if (input.url) {
try {
await acquired.page.goto(input.url, { waitUntil: 'load' });
await acquired.page.goto(input.url, { waitUntil: toPlaywrightWaitUntil(input.waitUntil) });
} catch (error) {
if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {});
throw error;
Expand Down
4 changes: 4 additions & 0 deletions src/browser/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,7 @@ export function buildEvaluateExpression(input: string | EvaluateFunction, args:
}
return wrapForEval(input);
}

export function toPlaywrightWaitUntil(waitUntil?: 'load' | 'none'): 'commit' | 'load' {
return waitUntil === 'none' ? 'commit' : 'load';
}
22 changes: 22 additions & 0 deletions src/browser/verify-fixture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,28 @@ describe('validateRowShape', () => {
},
]);
});

it('allows wide rows when maxTopLevelKeys is overridden', () => {
const wideRow = Object.fromEntries(Array.from({ length: 15 }, (_, i) => [`col${i}`, i]));
const failures = validateRowShape([wideRow], { maxTopLevelKeys: 20 });
expect(failures).toEqual([]);
});

it('falls back sensibly if maxTopLevelKeys override is invalid or negative', () => {
const wideRow = Object.fromEntries(Array.from({ length: 15 }, (_, i) => [`col${i}`, i]));
// Should fall back to 12 and fail
const failuresNeg = validateRowShape([wideRow], { maxTopLevelKeys: -5 });
expect(failuresNeg).toContainEqual(expect.objectContaining({ rule: 'shapeKeyCount' }));

const failuresNan = validateRowShape([wideRow], { maxTopLevelKeys: NaN });
expect(failuresNan).toContainEqual(expect.objectContaining({ rule: 'shapeKeyCount' }));
});

it('allows deeper nesting when maxNestedDepth is overridden', () => {
const deepRow = { nested: { deeply: { value: 1 } } }; // Depth is 3
const failures = validateRowShape([deepRow], { maxNestedDepth: 5 });
expect(failures.filter(f => f.rule === 'shapeDepth')).toEqual([]);
});
});

describe('deriveFixture', () => {
Expand Down
17 changes: 15 additions & 2 deletions src/browser/verify-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ export type FixtureExpect = {
* the value coerces to `false` in JS.
*/
mustBeTruthy?: string[];
/**
* Override the default maximum number of top-level columns allowed in the row.
* Useful for spreadsheet-export adapters that legitimately return wide rows.
*/
maxColumns?: number;
/**
* Override the default maximum nesting depth allowed in the row values.
*/
maxNestedDepth?: number;
};

export type FixtureArgs = Record<string, unknown> | unknown[];
Expand Down Expand Up @@ -262,8 +271,12 @@ export function validateRows(rows: Row[], fixture: Fixture): ValidationFailure[]

export function validateRowShape(rows: Row[], opts: RowShapeOptions = {}): ValidationFailure[] {
const failures: ValidationFailure[] = [];
const maxTopLevelKeys = opts.maxTopLevelKeys ?? DEFAULT_MAX_TOP_LEVEL_KEYS;
const maxNestedDepth = opts.maxNestedDepth ?? DEFAULT_MAX_NESTED_DEPTH;
const maxTopLevelKeys = (typeof opts.maxTopLevelKeys === 'number' && opts.maxTopLevelKeys >= 0)
? opts.maxTopLevelKeys
: DEFAULT_MAX_TOP_LEVEL_KEYS;
const maxNestedDepth = (typeof opts.maxNestedDepth === 'number' && opts.maxNestedDepth >= 0)
? opts.maxNestedDepth
: DEFAULT_MAX_NESTED_DEPTH;

rows.forEach((row, i) => {
const keys = Object.keys(row);
Expand Down
9 changes: 6 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,7 +935,10 @@ cli({
console.log(renderVerifyPreview(rows));
console.log(`\n → ${rows.length} row${rows.length === 1 ? '' : 's'}`);

const shapeFailures = validateRowShape(rows);
const shapeFailures = validateRowShape(rows, {
maxTopLevelKeys: fixture?.expect?.maxColumns,
maxNestedDepth: fixture?.expect?.maxNestedDepth,
});
if (shapeFailures.length > 0) {
console.log(`\n ✗ Adapter output violates row shape conventions:`);
for (const f of shapeFailures.slice(0, 20)) {
Expand Down Expand Up @@ -1374,8 +1377,8 @@ cli({
.argument('<name>', 'Plugin name (lowercase, hyphens allowed)')
.option('-d, --dir <path>', 'Output directory (default: ./<name>)')
.option('--description <text>', 'Plugin description')
.option('--author-name <name>', 'Author display name')
.option('--author-handle <handle>', 'Author GitHub handle')
.option('--author-name <name>', 'Author display name (required in non-interactive mode)')
.option('--author-handle <handle>', 'Author GitHub handle (required in non-interactive mode)')
.action(async (name: string, opts: {
dir?: string;
description?: string;
Expand Down