Skip to content
Merged
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 .github/workflows/regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ on:
scenario:
description: Scenario to run
type: choice
options: [all, basic, experience, slow, gated, variants]
options: [all, basic, experience, slow, gated, staged-login, variants]
default: all
retries:
description: Retries per scenario
Expand Down
56 changes: 49 additions & 7 deletions Bunoshfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ const highlight = require('cli-highlight').highlight;
import matter from 'gray-matter';
import yaml from 'js-yaml';
import { chromium } from 'playwright';
import { assertBasicRun, assertControlRun, assertSeededRun, status } from './tests/regression/lib/assertions.ts';
import { assertBasicRun, assertControlRun, assertNavigatedRun, assertSeededRun, status } from './tests/regression/lib/assertions.ts';
import { readSessionAnalysis } from './tests/regression/lib/artifacts.ts';
import { DISCUSSION_CATEGORY, DISCUSSION_MUTATION, DISCUSSION_QUERY, prNumberFromEvent, repoOwnerAndName } from './tests/regression/lib/github.ts';
import { REPORT_MARKER, buildReport, fmtDuration } from './tests/regression/lib/report.ts';
import { startFixture } from './tests/regression/fixture/server.ts';
import { SIGN_IN_OTP, startFixture } from './tests/regression/fixture/server.ts';
import { parsePlansFromMarkdown } from './src/utils/test-plan-markdown.ts';
import { htmlCombinedSnapshot, htmlTextSnapshot, minifyHtml } from './src/utils/html.js';
import { analyzeDemoCandidates, createDemoVideo } from './.claude/skills/demo-video/demo-video.ts';
Expand All @@ -23,10 +23,11 @@ const { shell, writeToFile, task, stopOnFail, ai } = global.bunosh;
const CLI = resolve('bin/explorbot-cli.ts');
const REG_ROOT = resolve('tests/regression');
const SLOW_START_MS = 12000;
const BASIC_SCENARIO = { name: 'basic', path: '/issues', knowledge: 'login.md', fixture: {}, maxTests: 5 };
const BASIC_SCENARIO = { name: 'basic', path: '/issues', knowledge: 'login.md', fixture: {}, maxTests: 5, run(runDir) { return shell`timeout 900 bun ${CLI} explore ${this.path} --headless --max-tests ${this.maxTests} -p ${runDir}`; }, assert(runDir, stdout) { return assertBasicRun(runDir, stdout, this.minPassed); } };
const SLOW_SCENARIO = { ...BASIC_SCENARIO, name: 'slow', fixture: { slowStartMs: SLOW_START_MS }, maxTests: 1, minPassed: 1 };
const GATED_SCENARIO = { ...BASIC_SCENARIO, name: 'gated', path: '/workspace', knowledge: 'workspace.md', maxTests: 1, minPassed: 1 };
const SCENARIO_ORDER = ['basic', 'experience', 'slow', 'gated'];
const STAGED_LOGIN_SCENARIO = { name: 'staged-login', path: '/account', knowledge: 'staged-login.md', fixture: {}, run(runDir) { return shell`timeout 300 bun ${CLI} navigate ${this.path} --headless -p ${runDir}`; }, assert: (_runDir, stdout, finished) => assertNavigatedRun(stdout, finished) };
const SCENARIO_ORDER = ['basic', 'experience', 'slow', 'gated', 'staged-login'];
const HISTORY_RUNS = 5;
const SUMMARY_FILE = 'records.json';
const RUNS = join(REG_ROOT, '.runs');
Expand Down Expand Up @@ -352,6 +353,17 @@ export async function regressionGated(options = { retries: 2, variant: 'native',
await gateBasicScenario(options, GATED_SCENARIO);
}

/**
* Regression scenario E: the Navigator reaches a page behind a sign-in split over three pages: email, password, one-time code.
* @param {object} options
* @param {number} [options.retries=2] - Re-runs after a failed attempt
* @param {string} [options.variant=native] - Widget variant: native|aria|plain|random
* @param {number} [options.seed=42] - RNG seed for the random variant
*/
export async function regressionStagedLogin(options = { retries: 2, variant: 'native', seed: 42 }) {
await gateBasicScenario(options, STAGED_LOGIN_SCENARIO);
}

/**
* Regression scenario B: experience reuse. Control run must fail without seeds; seeded run must pass.
* @param {object} options
Expand All @@ -377,7 +389,7 @@ export async function regressionExperience(options = { retries: 2, variant: 'nat
* @param {number} [options.seed=42]
*/
export async function regressionAll(options = { retries: 2, variant: 'native', seed: 42 }) {
await Promise.all([regressionBasic(options), regressionExperience(options), regressionSlow(options), regressionGated(options)]);
await Promise.all([regressionBasic(options), regressionExperience(options), regressionSlow(options), regressionGated(options), regressionStagedLogin(options)]);
}

/**
Expand Down Expand Up @@ -512,6 +524,36 @@ export async function regressionSmoke() {
const workspace = await (await fetch(`${gatedFixture.url}/workspace`, { headers: { cookie: gatedCookie } })).text();
await check('signed-in /workspace renders the issue list', workspace.includes('<table') && !workspace.includes('type="password"'));
gatedFixture.stop();

const stagedFixture = startFixture({ port: 0, variant: 'native', seed: 42 });
const stagedPost = (path, fields, cookie = '') => fetch(`${stagedFixture.url}${path}`, { method: 'POST', redirect: 'manual', headers: { cookie }, body: new URLSearchParams(fields) });
const cookieOf = (response) => (response.headers.get('set-cookie') || '').split(';')[0];
const stagedAnon = await fetch(`${stagedFixture.url}/account`, { redirect: 'manual' });
await check('anonymous /account redirects to /signin', stagedAnon.status === 302 && stagedAnon.headers.get('location') === '/signin');
const skippedToPassword = await fetch(`${stagedFixture.url}/signin/password`, { redirect: 'manual' });
await check('password stage needs the email stage', skippedToPassword.headers.get('location') === '/signin');
const skippedToOtp = await fetch(`${stagedFixture.url}/signin/otp`, { redirect: 'manual' });
await check('code stage needs the password stage', skippedToOtp.headers.get('location') === '/signin');
const emailStage = await stagedPost('/signin', { email: 'demo@example.com' });
const emailCookie = cookieOf(emailStage);
await check('email stage leads to the password stage', emailStage.headers.get('location') === '/signin/password');
const badPassword = await stagedPost('/signin/password', { password: 'nope' }, emailCookie);
await check('password stage rejects a wrong password', badPassword.status === 401);
const passwordStage = await stagedPost('/signin/password', { password: 'hunter2-fixture' }, emailCookie);
const pendingCookie = cookieOf(passwordStage);
await check('password stage leads to the code stage', passwordStage.headers.get('location') === '/signin/otp');
const pendingAccount = await fetch(`${stagedFixture.url}/account`, { redirect: 'manual', headers: { cookie: pendingCookie } });
await check('password alone does not open /account', pendingAccount.headers.get('location') === '/signin');
const badCode = await stagedPost('/signin/otp', { code: '000000' }, pendingCookie);
await check('code stage rejects a wrong code', badCode.status === 401);
const codeStage = await stagedPost('/signin/otp', { code: SIGN_IN_OTP }, pendingCookie);
await check('code stage leads to /account', codeStage.headers.get('location') === '/account');
const account = await (await fetch(`${stagedFixture.url}/account`, { headers: { cookie: cookieOf(codeStage) } })).text();
await check('signed-in /account renders', account.includes('<h1>Account</h1>'));
stagedFixture.stop();
const stagedKnowledge = matter(readFileSync(join(SEEDS, 'knowledge', 'staged-login.md'), 'utf-8'));
await check('staged sign-in knowledge targets /signin/* and carries the code', stagedKnowledge.data.url === '/signin/*' && stagedKnowledge.content.includes(SIGN_IN_OTP));

const workspaceKnowledge = matter(readFileSync(join(SEEDS, 'knowledge', 'workspace.md'), 'utf-8'));
await check('workspace knowledge targets /workspace', workspaceKnowledge.data.url === '/workspace');

Expand Down Expand Up @@ -638,9 +680,9 @@ async function runBasicScenario(variant, seed, retries, scenario = BASIC_SCENARI
const runDir = prepareRunDir(dirName(scenario.name, variant, seed), attempt);
cpSync(join(SEEDS, 'knowledge', scenario.knowledge), join(runDir, 'knowledge', scenario.knowledge));
const started = Date.now();
const res = await shell`timeout 900 bun ${CLI} explore ${scenario.path} --headless --max-tests ${scenario.maxTests} -p ${runDir}`.env(runEnv(fixture.url));
const res = await scenario.run(runDir).env(runEnv(fixture.url));
fixture.stop();
const outcome = assertBasicRun(runDir, String(res.output || ''), scenario.minPassed);
const outcome = scenario.assert(runDir, String(res.output || ''), !res.hasFailed);
recordAttempt(runDir, { scenario: scenario.name, label, kind: 'gate', attempt, maxAttempts, passed: outcome.passed, durationSec: elapsed(started), details: outcome.details, analysis: readSessionAnalysis(runDir) });
reportAttempt(label, attempt, maxAttempts, outcome);
passed = outcome.passed;
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## 2026-11-05

### Changes
- [Navigator] Gets through flows spread over several pages, such as a sign-in that asks for the email, the password and a one-time code on separate pages. When a step moves the page on, Navigator now looks at the new page instead of spending its remaining attempts on the old one.

## 2026-11-05

### New CLI Options
- **`prima --version`** — Shows the installed prima version. Running `prima` with no arguments also prints it, above the help.
```bash
Expand Down
7 changes: 7 additions & 0 deletions docs/contributing/regression-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ Runs scenario A again with the fixture's slow start on: on the first visit of a

Runs scenario A's assertions against `explore /workspace`. Signed out, `/workspace` shows the sign-in form at its own URL instead of redirecting to `/login`, and signing in there keeps you on `/workspace`. The planner reports the page blocked by a sign-in form, so explore must hand the report to Captain, sign in with the credentials in `seeds/knowledge/workspace.md` and plan the page again. Like scenario C, it executes one test.

### Scenario E — sign-in split over three pages (`regression:staged-login`)

Runs `explorbot navigate /account` with the credentials in `seeds/knowledge/staged-login.md`. Signed out, `/account` redirects to `/signin`, which asks only for the email; `/signin/password` asks only for the password and `/signin/otp` only for the one-time code. The later fields do not exist until the earlier stage is submitted, so the Navigator has to read each page as it arrives. The run must exit 0 with no Captain handover, since a Captain rescue would hide a Navigator failure. It tests navigation only: no research, planning or test execution.

## The fixture app — "Trackly"

A self-contained issue tracker served in-process by `tests/regression/fixture/server.ts` (`Bun.serve`, ephemeral port, fresh in-memory store per attempt). No external network, no database.
Expand All @@ -49,6 +53,8 @@ A self-contained issue tracker served in-process by `tests/regression/fixture/se
| `/settings` | yes | Profile/Preferences tabs + an activity iframe |
| `/vault` | no | scenario B access-code gate |
| `/workspace` | no / yes | scenario D: the sign-in form when signed out, the issue list when signed in |
| `/signin`, `/signin/password`, `/signin/otp` | no | scenario E: one sign-in stage per page, each refusing to open before the previous one is done |
| `/account` | yes | scenario E target: only the code stage signs in |
| `/api/*` | cookie | REST API over the same store, `+ /api/openapi.json` |

The REST API mirrors the store and is served for a future API-testing scenario; the current scenarios do not exercise it.
Expand Down Expand Up @@ -79,6 +85,7 @@ bunx bunosh regression:experience --retries 0 # cheapest AI path
bunx bunosh regression:basic --retries 0
bunx bunosh regression:slow --retries 0
bunx bunosh regression:gated --retries 0
bunx bunosh regression:staged-login --retries 0
bunx bunosh regression:all # all scenarios, default retries
```

Expand Down
3 changes: 3 additions & 0 deletions src/ai/navigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ class Navigator implements Agent {
if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
progressBlocks.push(codeBlock);
}
if (freshHash !== prevHash) codeBlockIndex = codeBlocks.length;
} else {
resolved = attempt.ok;
if (attempt.ok) progressBlocks.push(codeBlock);
Expand Down Expand Up @@ -441,6 +442,8 @@ class Navigator implements Agent {
contextMsg += `<previous_failures>\n${lines}\n</previous_failures>\n\n`;
}

contextMsg += `<page>\n${actionResult.toAiContext()}\n</page>\n\n`;

if (includeHtml) {
contextMsg += `Full HTML context:\n\n<page_html>\n${await actionResult.combinedHtml()}\n</page_html>\n\n`;
}
Expand Down
27 changes: 27 additions & 0 deletions tests/regression/fixture/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ const PRIORITY_OPTIONS = [
{ value: 'critical', label: 'Critical' },
];

const SIGN_IN_STAGES = {
email: { label: 'Email', name: 'email', type: 'email', hint: 'Step 1 of 3. Who is signing in?', action: '/signin', submit: 'Continue' },
password: { label: 'Password', name: 'password', type: 'password', hint: 'Step 2 of 3. Enter your password.', action: '/signin/password', submit: 'Continue' },
otp: { label: 'One-time code', name: 'code', type: 'text', hint: 'Step 3 of 3. Enter the one-time code.', action: '/signin/otp', submit: 'Verify' },
};

export function loginPage(error?: string, action = '/login'): string {
const alert = error ? `<p role="alert" class="alert">${esc(error)}</p>` : '';
const body = `
Expand All @@ -30,6 +36,27 @@ export function loginPage(error?: string, action = '/login'): string {
return doc('Sign in · Trackly', body, null, 'login');
}

export function signInStagePage(stage: keyof typeof SIGN_IN_STAGES, error?: string): string {
const { label, name, type, hint, action, submit } = SIGN_IN_STAGES[stage];
let alert = '';
if (error) alert = `<p role="alert" class="alert">${esc(error)}</p>`;
const body = `
<main class="auth">
<h1>${esc(label)}</h1>
<p>${esc(hint)}</p>
${alert}
<form method="post" action="${action}">
<div class="field"><label for="${name}">${esc(label)}</label><input id="${name}" name="${name}" type="${type}" required></div>
<button type="submit">${esc(submit)}</button>
</form>
</main>`;
return doc(`${label} · Trackly`, body, null, 'login');
}

export function accountPage(user: PublicUser): string {
return doc('Account · Trackly', `<h1>Account</h1><p>Signed in as ${esc(user.name)}.</p>`, user, 'account');
}

export function issuesPage(reg: Registry, store: Store, filters: { q?: string; status?: string; label?: string }, user: PublicUser, opts: { openDrawer?: boolean; error?: string } = {}): string {
const labels = store.listLabels();
const users = store.listUsers();
Expand Down
26 changes: 25 additions & 1 deletion tests/regression/fixture/server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { handleApi } from './api.ts';
import { type Variant, type VariantMode, createRegistry } from './components.ts';
import { activityWidget, issueDetailPage, issuesPage, loginPage, settingsPage, slowStartShell, vaultPage } from './pages.ts';
import { accountPage, activityWidget, issueDetailPage, issuesPage, loginPage, settingsPage, signInStagePage, slowStartShell, vaultPage } from './pages.ts';
import { type Store, createStore } from './store.ts';

export const VAULT_CODE = 'K7-9284-XRAY-TANGO';
export const SIGN_IN_OTP = '482913';
const VAULT_DOCS = ['Quarterly report', 'Design archive', 'Payroll ledger'];
const SIGN_IN_TARGETS: Record<string, string> = { '/login': '/issues', '/workspace': '/workspace' };

Expand Down Expand Up @@ -73,6 +74,29 @@ async function handle(req: Request, store: Store, defaultVariant: VariantMode, d
if (path === '/workspace' && !user) return html(loginPage(undefined, '/workspace'));
if (path === '/workspace') return html(issuesPage(reg, store, {}, user));

if (path === '/signin' && method === 'GET') return html(signInStagePage('email'));
if (path === '/signin' && method === 'POST') {
const form = await req.formData();
return redirect('/signin/password', `trackly_signin_email=${encodeURIComponent(String(form.get('email') || ''))}; Path=/`);
}
if (path === '/signin/password') {
if (!cookies.trackly_signin_email) return redirect('/signin');
if (method === 'GET') return html(signInStagePage('password'));
const form = await req.formData();
const token = store.login(decodeURIComponent(cookies.trackly_signin_email), String(form.get('password') || ''));
if (!token) return html(signInStagePage('password', 'Invalid email or password'), 401);
return redirect('/signin/otp', `trackly_signin_pending=${token}; Path=/; HttpOnly`);
}
if (path === '/signin/otp') {
if (!cookies.trackly_signin_pending) return redirect('/signin');
if (method === 'GET') return html(signInStagePage('otp'));
const form = await req.formData();
if (String(form.get('code') || '') !== SIGN_IN_OTP) return html(signInStagePage('otp', 'Invalid code'), 401);
return redirect('/account', `trackly_session=${cookies.trackly_signin_pending}; Path=/; HttpOnly`);
}
if (path === '/account' && !user) return redirect('/signin');
if (path === '/account') return html(accountPage(user));

if (!user) return redirect('/login');

if (path === '/') return redirect('/issues');
Expand Down
6 changes: 6 additions & 0 deletions tests/regression/lib/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ export function assertBasicRun(runDir: string, stdout: string, minPassed = MIN_P
return { passed: checks.every(Boolean), details };
}

export function assertNavigatedRun(stdout: string, finished: boolean): AssertResult {
const handovers = stdout.split('Captain takes over:').length - 1;
const details = [`reached target: ${status(finished)}`, `navigator alone: ${status(handovers === 0)} (captain handovers: ${handovers})`];
return { passed: finished && handovers === 0, details };
}

export function assertSeededRun(runDir: string): AssertResult {
const summary = latestReporterSummary(runDir);
if (!summary) return { passed: false, details: ['seeded: FAIL (no reporter summary written)'] };
Expand Down
9 changes: 9 additions & 0 deletions tests/regression/seeds/knowledge/staged-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
url: /signin/*
---

To sign in use these credentials:

email: demo@example.com
password: hunter2-fixture
one-time code: 482913
Loading
Loading