const { chromium } = require('playwright');
const fs = require('node:fs');
const path = require('node:path');
const root = process.env.BENCH_ROOT || __dirname;
const lanes = process.env.BENCH_LANES ? process.env.BENCH_LANES.split(',') : ['claude', 'sente', 'opencode'];
async function verify(browser, lane) {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
page.setDefaultTimeout(3500);
const errors = [];
page.on('pageerror', e => errors.push(e.message));
page.on('dialog', d => d.accept());
const results = [];
const t = id => page.getByTestId(id);
const url = `${process.env.BENCH_URL || 'http://127.0.0.1:18765/'}${process.env.BENCH_PREFIX ?? (root.endsWith('round2') ? 'round2/' : '')}${lane}/project/`;
const check = (value, msg) => { if (!value) throw new Error(msg); };
async function test(name, action) {
const start = Date.now();
try { await action(); results.push({ name, pass: true, ms: Date.now() - start }); }
catch (e) { results.push({ name, pass: false, error: e.message.slice(0, 900), ms: Date.now() - start }); }
}
async function fresh() {
await page.goto(url);
await page.evaluate(() => localStorage.clear());
await page.reload();
}
async function add(title, notes = '', priority = 'medium') {
await t('task-title').fill(title);
await t('task-notes').fill(notes);
await t('task-priority').selectOption(priority);
await t('add-task').click();
}
async function seed() {
await fresh();
await add('Alpha release', 'Ship the moon', 'high');
await add('Beta planning', 'Design review', 'low');
await add('Gamma errands', 'Buy tea', 'medium');
}
const item = title => t('task-item').filter({ hasText: title });
await test('01 loads empty with no runtime error', async () => {
await fresh(); check(await t('task-title').isVisible(), 'creation form absent');
check(await t('task-item').count() === 0, 'unexpected initial tasks');
check(await t('empty-state').isVisible(), 'missing empty state');
check(errors.length === 0, errors.join('; '));
});
await test('02 create title/notes/priority/due', async () => {
await fresh(); await t('task-due').fill('2026-10-01');
await add('Alpha release', 'Ship the moon', 'high');
check(await t('task-item').count() === 1, 'creation failed');
const text = await t('task-item').innerText();
check(text.includes('Alpha release') && text.includes('Ship the moon'), 'missing title/notes');
check(/high/i.test(text), 'priority absent');
check(/2026|Oct|10[\/\-.]01|01[\/\-.]10/.test(text), 'due date absent');
});
await test('03 whitespace title rejected', async () => {
await fresh(); await add(' '); check(await t('task-item').count() === 0, 'whitespace task accepted');
});
await test('04 edit title persists', async () => {
await fresh(); await add('Original'); await item('Original').getByTestId('task-edit').click();
await t('edit-title').fill('Renamed task'); await t('save-edit').click(); await page.reload();
check(await item('Renamed task').count() === 1, 'edit not persisted');
});
await test('05 completion toggle reversible and persisted', async () => {
await fresh(); await add('Toggle me'); await item('Toggle me').getByTestId('task-toggle').check();
await page.reload(); check(await item('Toggle me').getByTestId('task-toggle').isChecked(), 'completion not persisted');
await item('Toggle me').getByTestId('task-toggle').uncheck(); await page.reload();
check(!await item('Toggle me').getByTestId('task-toggle').isChecked(), 'undo not persisted');
});
await test('06 deletion persists', async () => {
await fresh(); await add('Delete me'); await item('Delete me').getByTestId('task-delete').click();
await page.reload(); check(await t('task-item').count() === 0, 'deletion not persisted');
});
await test('07 case-insensitive title search', async () => {
await seed(); await t('search').fill('ALPHA');
check(await t('task-item').count() === 1 && await item('Alpha release').count() === 1, 'title search failed');
});
await test('08 case-insensitive notes search', async () => {
await seed(); await t('search').fill('MOON');
check(await t('task-item').count() === 1 && await item('Alpha release').count() === 1, 'notes search failed');
});
await test('09 status filter active/completed/all', async () => {
await seed(); await item('Alpha release').getByTestId('task-toggle').check();
await t('status-filter').selectOption('completed'); check(await t('task-item').count() === 1, 'completed filter');
await t('status-filter').selectOption('active'); check(await t('task-item').count() === 2, 'active filter');
await t('status-filter').selectOption('all'); check(await t('task-item').count() === 3, 'all filter');
});
await test('10 priority filter', async () => {
await seed(); await t('priority-filter').selectOption('low');
check(await t('task-item').count() === 1 && await item('Beta planning').count() === 1, 'priority filter failed');
});
await test('11 filters and search compose', async () => {
await seed(); await item('Alpha release').getByTestId('task-toggle').check();
await t('search').fill('moon'); await t('priority-filter').selectOption('high');
await t('status-filter').selectOption('active'); check(await t('task-item').count() === 0, 'AND active failed');
await t('status-filter').selectOption('completed'); check(await t('task-item').count() === 1, 'AND completed failed');
});
await test('12 empty search state visible', async () => {
await seed(); await t('search').fill('not-a-match-888');
check(await t('task-item').count() === 0 && await t('empty-state').isVisible(), 'no empty search state');
});
await test('13 global counts correct', async () => {
await seed(); await item('Alpha release').getByTestId('task-toggle').check();
check((await t('count-total').innerText()).trim() === '3', 'total wrong');
check((await t('count-active').innerText()).trim() === '2', 'active wrong');
check((await t('count-completed').innerText()).trim() === '1', 'completed wrong');
});
await test('14 multiple tasks survive reload', async () => {
await seed(); await page.reload(); check(await t('task-item').count() === 3, 'tasks lost on reload');
check(await item('Alpha release').count() === 1, 'title lost on reload');
});
await test('15 invalid saved JSON recoverable', async () => {
await fresh(); await add('Persisted');
const keys = await page.evaluate(() => Object.keys(localStorage).filter(k => {
try { return JSON.stringify(JSON.parse(localStorage[k])).includes('Persisted'); } catch { return false; }
}));
check(keys.length > 0, 'no task storage key');
await page.evaluate(keys => keys.forEach(k => localStorage.setItem(k, '{broken')), keys);
const before = errors.length; await page.reload();
await add('Recovered'); check(await item('Recovered').count() === 1, 'cannot create after corrupted storage');
check(errors.length === before, 'runtime exception on invalid JSON');
});
await test('16 title and notes HTML injection prevented', async () => {
await fresh(); await add('
', '