fix: resolve merge conflicts with origin/main (v0.5.0 + v0.4.5)

Merge brings in design review skills (plan-design-review, qa-design-review,
design-consultation), fix-first review mode (v0.4.5), and browse JS/click
fixes. Conflicts resolved in VERSION (keep 0.6.0), CHANGELOG (keep all
entries), TODOS (update design review to shipped), and test arrays (combine
all skill registrations). Regenerated all SKILL.md files so design skills
get the improved preamble (ELI16, escalation protocol, branch detection).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-16 19:59:27 -07:00
25 changed files with 3766 additions and 75 deletions
+9
View File
@@ -208,6 +208,15 @@ export class BrowserManager {
return { selector };
}
/** Get the ARIA role for a ref selector, or null for CSS selectors / unknown refs. */
getRefRole(selector: string): string | null {
if (selector.startsWith('@e') || selector.startsWith('@c')) {
const entry = this.refMap.get(selector.slice(1));
return entry?.role ?? null;
}
return null;
}
getRefCount(): number {
return this.refMap.size;
}
+21 -9
View File
@@ -17,6 +17,24 @@ function hasAwait(code: string): boolean {
return /\bawait\b/.test(stripped);
}
/** Detect whether code needs a block wrapper {…} vs expression wrapper (…) inside an async IIFE. */
function needsBlockWrapper(code: string): boolean {
const trimmed = code.trim();
if (trimmed.split('\n').length > 1) return true;
if (/\b(const|let|var|function|class|return|throw|if|for|while|switch|try)\b/.test(trimmed)) return true;
if (trimmed.includes(';')) return true;
return false;
}
/** Wrap code for page.evaluate(), using async IIFE with block or expression body as needed. */
function wrapForEvaluate(code: string): string {
if (!hasAwait(code)) return code;
const trimmed = code.trim();
return needsBlockWrapper(trimmed)
? `(async()=>{\n${code}\n})()`
: `(async()=>(${trimmed}))()`;
}
// Security: Path validation to prevent path traversal attacks
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
@@ -124,7 +142,7 @@ export async function handleReadCommand(
case 'js': {
const expr = args[0];
if (!expr) throw new Error('Usage: browse js <expression>');
const wrapped = hasAwait(expr) ? `(async()=>(${expr}))()` : expr;
const wrapped = wrapForEvaluate(expr);
const result = await page.evaluate(wrapped);
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
}
@@ -135,14 +153,8 @@ export async function handleReadCommand(
validateReadPath(filePath);
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
const code = fs.readFileSync(filePath, 'utf-8');
if (hasAwait(code)) {
const trimmed = code.trim();
const isSingleExpr = trimmed.split('\n').length === 1;
const wrapped = isSingleExpr ? `(async()=>(${trimmed}))()` : `(async()=>{\n${code}\n})()`;
const result = await page.evaluate(wrapped);
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
}
const result = await page.evaluate(code);
const wrapped = wrapForEvaluate(code);
const result = await page.evaluate(wrapped);
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
}
+41 -4
View File
@@ -44,11 +44,48 @@ export async function handleWriteCommand(
case 'click': {
const selector = args[0];
if (!selector) throw new Error('Usage: browse click <selector>');
// Auto-route: if ref points to a real <option> inside a <select>, use selectOption
const role = bm.getRefRole(selector);
if (role === 'option') {
const resolved = await bm.resolveRef(selector);
if ('locator' in resolved) {
const optionInfo = await resolved.locator.evaluate(el => {
if (el.tagName !== 'OPTION') return null; // custom [role=option], not real <option>
const option = el as HTMLOptionElement;
const select = option.closest('select');
if (!select) return null;
return { value: option.value, text: option.text };
});
if (optionInfo) {
await resolved.locator.locator('xpath=ancestor::select').selectOption(optionInfo.value, { timeout: 5000 });
return `Selected "${optionInfo.text}" (auto-routed from click on <option>) → now at ${page.url()}`;
}
// Real <option> with no parent <select> or custom [role=option] — fall through to normal click
}
}
const resolved = await bm.resolveRef(selector);
if ('locator' in resolved) {
await resolved.locator.click({ timeout: 5000 });
} else {
await page.click(resolved.selector, { timeout: 5000 });
try {
if ('locator' in resolved) {
await resolved.locator.click({ timeout: 5000 });
} else {
await page.click(resolved.selector, { timeout: 5000 });
}
} catch (err: any) {
// Enhanced error guidance: clicking <option> elements always fails (not visible / timeout)
const isOption = 'locator' in resolved
? await resolved.locator.evaluate(el => el.tagName === 'OPTION').catch(() => false)
: await page.evaluate(
(sel: string) => document.querySelector(sel)?.tagName === 'OPTION',
(resolved as { selector: string }).selector
).catch(() => false);
if (isOption) {
throw new Error(
`Cannot click <option> elements. Use 'browse select <parent-select> <value>' instead of 'click' for dropdown options.`
);
}
throw err;
}
// Wait briefly for any navigation/DOM update
await page.waitForLoadState('domcontentloaded').catch(() => {});
+51
View File
@@ -198,6 +198,27 @@ describe('Inspection', () => {
}
});
test('js handles multi-line with await', async () => {
const code = 'const x = await Promise.resolve(42);\nreturn x;';
const result = await handleReadCommand('js', [code], bm);
expect(result).toBe('42');
});
test('js handles await with semicolons', async () => {
const result = await handleReadCommand('js', ['const x = await Promise.resolve(5); return x + 1;'], bm);
expect(result).toBe('6');
});
test('js handles await with statement keywords', async () => {
const result = await handleReadCommand('js', ['const res = await Promise.resolve("ok"); return res;'], bm);
expect(result).toBe('ok');
});
test('js still works for simple expressions', async () => {
const result = await handleReadCommand('js', ['1 + 2'], bm);
expect(result).toBe('3');
});
test('css returns computed property', async () => {
const result = await handleReadCommand('css', ['h1', 'color'], bm);
// Navy color
@@ -247,6 +268,36 @@ describe('Interaction', () => {
expect(val).toBe('admin');
});
test('click on option ref auto-routes to selectOption', async () => {
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
// Reset select to default
await handleReadCommand('js', ['document.querySelector("#role").value = ""'], bm);
const snap = await handleMetaCommand('snapshot', [], bm, async () => {});
// Find an option ref (e.g., "Admin" option)
const optionLine = snap.split('\n').find((l: string) => l.includes('[option]') && l.includes('"Admin"'));
expect(optionLine).toBeDefined();
const refMatch = optionLine!.match(/@(e\d+)/);
expect(refMatch).toBeDefined();
const ref = `@${refMatch![1]}`;
const result = await handleWriteCommand('click', [ref], bm);
expect(result).toContain('auto-routed');
expect(result).toContain('Selected');
// Verify the select value actually changed
const val = await handleReadCommand('js', ['document.querySelector("#role").value'], bm);
expect(val).toBe('admin');
});
test('click CSS selector on option gives helpful error', async () => {
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
try {
await handleWriteCommand('click', ['option[value="admin"]'], bm);
expect(true).toBe(false); // Should not reach here
} catch (err: any) {
expect(err.message).toContain('select');
expect(err.message).toContain('option');
}
}, 15000);
test('hover works', async () => {
const result = await handleWriteCommand('hover', ['h1'], bm);
expect(result).toContain('Hovered');