From c64a6657f42216a79abe50923306252651d73421 Mon Sep 17 00:00:00 2001 From: SparshM8 Date: Fri, 7 Aug 2026 11:20:20 +0530 Subject: [PATCH 1/3] fix(browser): allow fixture to override maxColumns and maxNestedDepth Resolves #218. Updates FixtureExpect schema and validateRowShape to respect maxColumns and maxNestedDepth options, allowing spreadsheet-export adapters to legitimately return wide rows without failing the CLI verify step. --- src/browser/verify-fixture.test.ts | 22 ++++++++++++++++++++++ src/browser/verify-fixture.ts | 17 +++++++++++++++-- src/cli.ts | 5 ++++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/browser/verify-fixture.test.ts b/src/browser/verify-fixture.test.ts index 60d96ea2..45a06419 100644 --- a/src/browser/verify-fixture.test.ts +++ b/src/browser/verify-fixture.test.ts @@ -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', () => { diff --git a/src/browser/verify-fixture.ts b/src/browser/verify-fixture.ts index 1e3b1d51..1ca3fc4d 100644 --- a/src/browser/verify-fixture.ts +++ b/src/browser/verify-fixture.ts @@ -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 | unknown[]; @@ -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); diff --git a/src/cli.ts b/src/cli.ts index e4d38a6b..4017a9eb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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)) { From 81f96bb88d338b8509bce302a7b615e3d03cec4c Mon Sep 17 00:00:00 2001 From: SparshM8 Date: Fri, 7 Aug 2026 16:06:01 +0530 Subject: [PATCH 2/3] fix(browser): allow waitUntil to be passed to newPage Resolves #210. Replaces the hardcoded waitUntil: 'load' inside newPage with a mapped Playwright load state, allowing tab new --url commands to work on never-idle sites. --- src/browser/runtime/local-cloak/actions.ts | 4 +++- src/browser/runtime/local-cloak/session-manager.ts | 5 +++-- src/browser/utils.ts | 4 ++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-cloak/actions.ts index b9e313ec..b33b86d9 100644 --- a/src/browser/runtime/local-cloak/actions.ts +++ b/src/browser/runtime/local-cloak/actions.ts @@ -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(); @@ -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': { @@ -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 }; diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 644fb679..0ce34c84 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -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; @@ -270,7 +271,7 @@ export class CloakSessionManager { }))); } - async newPage(input: SessionKeyInput & { url?: string }): Promise { + async newPage(input: SessionKeyInput & { url?: string; waitUntil?: 'load' | 'none' }): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); const surface = normalizeSurface(input.surface); @@ -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; diff --git a/src/browser/utils.ts b/src/browser/utils.ts index ccf2779a..82bb268a 100644 --- a/src/browser/utils.ts +++ b/src/browser/utils.ts @@ -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'; +} From 3b139c840a0d3d14ee37fb19e5e91edfd000383d Mon Sep 17 00:00:00 2001 From: SparshM8 Date: Fri, 7 Aug 2026 16:20:57 +0530 Subject: [PATCH 3/3] docs(cli): document required author flags for plugin creation Resolves #219. Updates the help descriptions for --author-name and --author-handle to note they are required in non-interactive modes, and adds dummy author values to the adapter-author SKILL.md example to prevent silent failures during agentic workflows. --- skills/webcmd-adapter-author/SKILL.md | 2 +- src/cli.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/webcmd-adapter-author/SKILL.md b/skills/webcmd-adapter-author/SKILL.md index bde0eab0..871969fc 100644 --- a/skills/webcmd-adapter-author/SKILL.md +++ b/skills/webcmd-adapter-author/SKILL.md @@ -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//.js` to avoid a build. When the user says to promote a CLI, create a main-repo plugin with `webcmd plugin create --dir plugins/`, copy the real command files into it, delete scaffold sample commands, register it in root `webcmd-plugin.json`, remove the local `~/.webcmd/clis/` shadow, install the plugin, then run `webcmd validate ` and smoke commands. See `references/adapter-template.md` for details. +- For private iteration, write `~/.webcmd/clis//.js` to avoid a build. When the user says to promote a CLI, create a main-repo plugin with `webcmd plugin create --dir plugins/ --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/` shadow, install the plugin, then run `webcmd validate ` 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//fixtures/` or `/tmp/`. Never leave `.dbg-*.html`, `raw-*.json`, `sample.*`, or similar temporary files in the repo root, `clis//`, or the current working directory.** diff --git a/src/cli.ts b/src/cli.ts index 4017a9eb..afc57c88 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1377,8 +1377,8 @@ cli({ .argument('', 'Plugin name (lowercase, hyphens allowed)') .option('-d, --dir ', 'Output directory (default: ./)') .option('--description ', 'Plugin description') - .option('--author-name ', 'Author display name') - .option('--author-handle ', 'Author GitHub handle') + .option('--author-name ', 'Author display name (required in non-interactive mode)') + .option('--author-handle ', 'Author GitHub handle (required in non-interactive mode)') .action(async (name: string, opts: { dir?: string; description?: string;