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 @@ -199,7 +199,7 @@ Check these off step by step:
[ ] `endpoints.json`: short endpoint name as key; value = `{url, method, params.{required,optional}, response, verified_at: YYYY-MM-DD, notes}`.
[ ] `field-map.json`: append only new codes. key = field code; value = `{meaning, verified_at: YYYY-MM-DD, source}`. **Do not overwrite existing keys.** If there is a conflict, align with the visible page before writing.
[ ] `notes.md`: prepend `## YYYY-MM-DD by <agent/user>` with new pitfalls or conclusions from this adapter work.
[ ] `verify/<cmd>.json`: **required.** Expected values for `webcmd browser verify`: args, rowCount, columns, types, patterns, notEmpty. Step 10 generated this; this item is the checklist gate.
[ ] `verify/<cmd>.json`: **required.** Expected values for `webcmd browser verify`: args, rowCount, columns, types, patterns, notEmpty. Step 10 generated this; this item is the checklist gate. Rows wider than 12 top-level keys by design (e.g. a spreadsheet-style export) fail shape validation by default — rerun with `webcmd browser verify <site>/<name> --max-top-level-keys <n>` instead of skipping the fixture.
[ ] `fixtures/<cmd>-<YYYYMMDDHHMM>.json`: save one complete endpoint response sample after removing cookies, tokens, and private user fields. Use it for later field comparison and offline replay.
[ ] If debugging dumped temporary files in the repo or adapter directory, such as `.dbg-*.html`, `raw-*.json`, or similar, **delete them before commit**. Those belong in `~/.webcmd/sites/<site>/fixtures/` or `/tmp/`.

Expand Down
18 changes: 18 additions & 0 deletions src/browser/verify-fixture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,24 @@ describe('validateRowShape', () => {
]);
});

it('honors a raised maxTopLevelKeys override for wide-row adapters', () => {
const row = Object.fromEntries(Array.from({ length: 52 }, (_, i) => [`k${i}`, i]));
const failures = validateRowShape([row], { maxTopLevelKeys: 52 });
expect(failures).toEqual([]);
});

it('still reports keys beyond a raised maxTopLevelKeys override', () => {
const row = Object.fromEntries(Array.from({ length: 53 }, (_, i) => [`k${i}`, i]));
const failures = validateRowShape([row], { maxTopLevelKeys: 52 });
expect(failures).toEqual([
{
rule: 'shapeKeyCount',
detail: 'row has 53 top-level keys, expected at most 52',
rowIndex: 0,
},
]);
});

it('reports nesting deeper than one level', () => {
const failures = validateRowShape([
{ title: 'A', stats: { author: { name: 'Ada' } } },
Expand Down
65 changes: 65 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1173,6 +1173,71 @@ describe('browser verify', () => {
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});

it('rejects a wide row by default but passes with a raised --max-top-level-keys', async () => {
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-verify-wide-'));
process.env.HOME = fakeHome;
process.env.USERPROFILE = fakeHome;
const wideRow = Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`col${i}`, i]));
mockExecFileSync.mockReturnValue(JSON.stringify([wideRow]));
const consoleLogSpy = vi.mocked(console.log);
consoleLogSpy.mockClear();

try {
const adapterDir = path.join(fakeHome, '.webcmd', 'clis', 'hn');
fs.mkdirSync(adapterDir, { recursive: true });
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');

const program = createProgram('', '');
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture']);
expect(process.exitCode).toBe(1);
let output = consoleLogSpy.mock.calls.map((args) => args.join(' ')).join('\n');
expect(output).toContain('row has 20 top-level keys, expected at most 12');

process.exitCode = undefined;
consoleLogSpy.mockClear();
const program2 = createProgram('', '');
await program2.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture', '--max-top-level-keys', '20']);
expect(process.exitCode).toBeUndefined();
output = consoleLogSpy.mock.calls.map((args) => args.join(' ')).join('\n');
expect(output).not.toContain('violates row shape conventions');
} finally {
consoleLogSpy.mockClear();
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});

it('rejects a non-positive --max-top-level-keys', async () => {
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-verify-badflag-'));
process.env.HOME = fakeHome;
process.env.USERPROFILE = fakeHome;

try {
const adapterDir = path.join(fakeHome, '.webcmd', 'clis', 'hn');
fs.mkdirSync(adapterDir, { recursive: true });
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');

const program = createProgram('', '');
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture', '--max-top-level-keys', '0']);

expect(process.exitCode).toBe(2);
expect(mockExecFileSync).not.toHaveBeenCalled();
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
});

describe('profile list', () => {
Expand Down
18 changes: 15 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,8 +856,9 @@ cli({
.option('--strict-memory', 'Fail (not just warn) when ~/.webcmd/sites/<site>/endpoints.json or notes.md is missing')
.option('--seed-args <value>', 'Seed args when no fixture exists; use JSON array/object for multiple args or flags')
.option('--trace <mode>', 'Trace capture for the adapter subprocess: off, on, retain-on-failure', 'off')
.option('--max-top-level-keys <n>', 'Override the row-shape top-level key cap (default: 12) for adapters whose rows are wide by design')
.description('Execute an adapter and validate output; uses fixture at ~/.webcmd/sites/<site>/verify/<cmd>.json when present')
.action(async (name: string, opts: { fixture?: boolean; writeFixture?: boolean; updateFixture?: boolean; strictMemory?: boolean; seedArgs?: string; trace?: string } = {}) => {
.action(async (name: string, opts: { fixture?: boolean; writeFixture?: boolean; updateFixture?: boolean; strictMemory?: boolean; seedArgs?: string; trace?: string; maxTopLevelKeys?: string } = {}) => {
try {
const parts = name.split('/');
if (parts.length !== 2) { console.error('Name must be site/command format'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
Expand All @@ -868,6 +869,16 @@ cli({
return;
}

let maxTopLevelKeys: number | undefined;
if (opts.maxTopLevelKeys !== undefined) {
maxTopLevelKeys = Number(opts.maxTopLevelKeys);
if (!Number.isInteger(maxTopLevelKeys) || maxTopLevelKeys <= 0) {
console.error('--max-top-level-keys must be a positive integer');
process.exitCode = EXIT_CODES.USAGE_ERROR;
return;
}
}

const { execFileSync } = await import('node:child_process');
const { loadFixture, writeFixture, deriveFixture, validateRows, validateRowShape, fixturePath, expandFixtureArgs, parseSeedArgs } = await import('./browser/verify-fixture.js');
const filePath = path.join(os.homedir(), '.webcmd', 'clis', site, `${command}.js`);
Expand Down Expand Up @@ -936,7 +947,7 @@ cli({
console.log(renderVerifyPreview(rows));
console.log(`\n → ${rows.length} row${rows.length === 1 ? '' : 's'}`);

const shapeFailures = validateRowShape(rows);
const shapeFailures = validateRowShape(rows, { maxTopLevelKeys });
if (shapeFailures.length > 0) {
console.log(`\n ✗ Adapter output violates row shape conventions:`);
for (const f of shapeFailures.slice(0, 20)) {
Expand All @@ -946,7 +957,8 @@ cli({
if (shapeFailures.length > 20) {
console.log(` ... and ${shapeFailures.length - 20} more failure(s)`);
}
console.log(`\n Keep rows agent-native: <=12 top-level keys, nesting depth <=1, and id-shaped fields at top level.`);
console.log(`\n Keep rows agent-native: <=${maxTopLevelKeys ?? 12} top-level keys, nesting depth <=1, and id-shaped fields at top level.`);
console.log(` If this adapter's rows are wide by design, rerun with --max-top-level-keys <n>.`);
process.exitCode = EXIT_CODES.GENERIC_ERROR;
return;
}
Expand Down