mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-06 08:07:53 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user