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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ Cutting that release is tracked in
diagnostics, flow, network emulation, reporting, and local command paths.
- Core regression coverage for bounded artifact reads, non-regular artifacts,
non-replayable fill values in flows, and the ffmpeg visual-difference path.
- Agent-runtime regression coverage for interaction commands, page state,
tours, screenshot-plan bounds and deduplication, budget fallback, unsafe
links, and stale element references.
- Progressive context pruning: `inspect --context summary|outline|text|actions|full`
with `--task` ranking, `--within @rN` scoping, and `--limit` / `--budget` /
`--depth` bounds. Every focused response reports `contextStats` and `omitted`.
Expand Down
123 changes: 123 additions & 0 deletions apps/headless/Tests/agent-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,134 @@ const fresh = agent.snapshot(false, false, {context: 'actions', limit: 5});
assert(fresh.elements.length > 0, 'actions context should return executable controls');
assert.equal(agent.click({target: fresh.elements[0].ref}).clicked, fresh.elements[0].ref);

// Exercise the command surface against controls with unique semantic names.
const controls = window.document.createElement('section');
controls.innerHTML = `
<button type="button" aria-label="Runtime action">Run</button>
<input aria-label="Runtime input">
<a href="javascript:alert(1)" aria-label="Unsafe runtime link">Unsafe</a>
<div role="button" aria-label="Read only runtime control" tabindex="0">Read only</div>
`;
window.document.body.prepend(controls);
const button = controls.querySelector('button');
const input = controls.querySelector('input');
let clicks = 0;
let inputs = 0;
let changes = 0;
const pressed = [];
button.addEventListener('click', () => { clicks += 1; });
input.addEventListener('input', () => { inputs += 1; });
input.addEventListener('change', () => { changes += 1; });
input.addEventListener('keydown', event => pressed.push(`down:${event.key}`));
input.addEventListener('keyup', event => pressed.push(`up:${event.key}`));

const clicked = agent.click({role: 'button', name: 'Runtime action'});
assert.match(clicked.clicked, /^@e\d+$/);
assert.equal(clicks, 1, 'click should dispatch exactly once');
const filled = agent.fill({role: 'textbox', name: 'Runtime input', value: 'private value'});
assert.equal(filled.valueLength, 13);
assert.equal(filled.value, undefined, 'fill responses must not echo values');
assert.equal(input.value, 'private value');
assert.equal(inputs, 1);
assert.equal(changes, 1);
assert.equal(agent.press('A').pressed, 'A');
assert.deepEqual(pressed, ['down:A', 'up:A']);
assert.throws(
() => agent.fill({role: 'button', name: 'Read only runtime control', value: 'no'}),
/NOT_EDITABLE/,
);
assert.throws(
() => agent.click({role: 'link', name: 'Unsafe runtime link'}),
/UNSAFE_NAVIGATION:javascript:/,
);

window.scrollY = 0;
const downward = agent.scroll({direction: 'down', amount: 300});
assert.equal(downward.direction, 'down');
assert.equal(downward.amount, 300);
assert.equal(window.scrollY, 300);
agent.scroll({direction: 'up', amount: 125});
assert.equal(window.scrollY, 175);
agent.scroll({direction: 'top'});
assert.equal(window.scrollY, 0);
agent.scroll({direction: 'bottom'});
assert.equal(window.scrollY, 32000);

const pageState = agent.state();
assert.equal(pageState.url, 'http://127.0.0.1:41739/large-document');
assert.equal(pageState.contentHeight, 32000);
assert.equal(pageState.runningAnimations, 0);
assert(pageState.text.includes('Run'));
assert(pageState.text.length <= 30000, 'state text must stay bounded');

const stationaryTour = await agent.tour({fullPage: false});
assert.equal(stationaryTour.start, stationaryTour.end);
assert.equal(stationaryTour.durationMs, 0);
Object.defineProperty(window.document.documentElement, 'scrollHeight', {
value: 1000,
configurable: true,
});
const fullTour = await agent.tour({fullPage: true, pace: 5000});
assert.equal(fullTour.start, 0);
assert.equal(fullTour.end, 240);
assert.equal(fullTour.durationMs, 500);
assert.equal(window.scrollY, 240);

// A very tall page must produce an explicit, end-anchored 80-point cap.
Object.defineProperty(window.document.documentElement, 'scrollHeight', {
value: 100000,
configurable: true,
});
window.scrollY = 321;
const viewportPlan = agent.screenshotPlan({mode: 'viewport'});
assert.equal(viewportPlan.initialY, 321);
assert.equal(viewportPlan.points.length, 80);
assert.equal(viewportPlan.truncated, true);
assert(viewportPlan.totalPoints > viewportPlan.points.length);
assert.equal(viewportPlan.points.at(-1).y, 100000 - window.innerHeight);

// Section plans deduplicate points within 96 px and apply the same hard cap.
const sectionRoot = window.document.createElement('main');
for (let index = 0; index < 100; index += 1) {
const heading = window.document.createElement('h2');
heading.textContent = `Runtime section ${index + 1}`;
heading.getBoundingClientRect = () => ({
x: 24,
y: index * 200,
top: index * 200,
left: 24,
right: 824,
bottom: index * 200 + 48,
width: 800,
height: 48,
});
sectionRoot.append(heading);
}
window.document.body.append(sectionRoot);
window.scrollY = 0;
const sectionPlan = agent.screenshotPlan({mode: 'section'});
assert.equal(sectionPlan.points.length, 80);
assert.equal(sectionPlan.truncated, true);
for (let index = 1; index < sectionPlan.points.length; index += 1) {
assert(
sectionPlan.points[index].y - sectionPlan.points[index - 1].y > 96,
'section capture points within 96 px should be deduplicated',
);
}

// Once result arrays are exhausted, budget pruning must fall back to chopping
// page text instead of returning an oversized response.
const budgetedText = agent.snapshot(false, true, {context: 'full', limit: 1, budget: 256});
assert(budgetedText.contextStats.encodedBytes <= 1024);
assert(budgetedText.text.length < 30000, 'text fallback should be shortened to fit the budget');
assert.equal(budgetedText.contextStats.budgetApplied, true);

console.log(JSON.stringify({
selectedRegion: targetRegion.ref,
full: full.contextStats,
summary: summary.contextStats,
outline: outline.contextStats,
scopedText: scopedText.contextStats,
scopedActions: scopedActions.contextStats,
budgetedText: budgetedText.contextStats,
}));
6 changes: 4 additions & 2 deletions docs/roadmap/improvements-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,10 @@ which currently passes for the wrong reason.
command matrix is covered, and the unexpected-field test first proves its
current-version control request decodes before adding the forbidden field.
Artifact read boundaries, non-regular-file rejection, flow replay safety, and
visual-comparison invocation are now covered as well. Recording, runtime,
transport, JavaScript-runtime, and host-specific gaps remain.
visual-comparison invocation are now covered as well. Runtime coverage now
locks click/fill/press/scroll/state/tour behavior, unsafe-link rejection,
screenshot-plan caps and 96 px deduplication, budget text fallback, and stale
element-reference errors. Recording, transport, and host-specific gaps remain.

**D3. Web CI:** ~~`next build` + eslint on PR (site can break invisibly today).~~
**Done** — the `web` job in `ci.yml` runs `pnpm --filter @headless/web lint`
Expand Down
Loading