assistant/tools/learning/test-observation-cache.mjs

174 lines
14 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import { chromium } from 'playwright';
import { describePage } from './browser-worker.mjs';
// Compare against an explicitly saved pre-change evaluate function, never a
// production/RPC switch. All fixtures are synthetic and network is blocked.
const baselinePath = process.argv[2];
assert.ok(baselinePath, 'Pass the saved baseline module path');
const { describeBaseline } = await import(pathToFileURL(path.resolve(baselinePath)).href);
const runDirectory = path.resolve('.tmp', `observation-cache-${Date.now()}`);
await fs.mkdir(runDirectory, { recursive: true });
const report = { browser: process.platform === 'win32' ? 'msedge' : 'chromium', real_platform_requests: 0,
baseline_sha256: createHash('sha256').update(await fs.readFile(path.resolve(baselinePath))).digest('hex'), comparisons: [], performance: [] };
const browser = await chromium.launch({ headless: true, ...(process.platform === 'win32' ? { channel: 'msedge' } : {}) });
try {
const page = await browser.newPage({ viewport: { width: 1120, height: 760 } });
await page.route('**/*', route => route.abort());
const cards = Array.from({ length: 230 }, (_, index) => `<article id="comment-${index + 1}" class="card"><div class="author"><a class="name" href="#author-${index}">Author ${index}</a></div><div class="content"><span>Question ${index}</span><p>Ordinary renovation sample ${index}</p><div class="nested"><span>Nested ${index}</span></div></div><time datetime="2026-10-25">Today</time></article>`).join('');
await page.setContent(`<!doctype html><title>Observation equivalence</title><style>
body{margin:0;font:16px/1.5 sans-serif}*{box-sizing:border-box}header{height:90px;padding:12px}
#controls{height:65px;padding:8px}#scroller{height:380px;width:700px;overflow:auto;border:2px solid #aaa}
.card{height:165px;padding:8px;border-bottom:1px solid #ddd}.content{padding-left:20px}.nested{padding-left:12px}
#clipped{height:55px;width:180px;overflow:hidden}#clipped p{height:100px}#offscreen{margin-top:1200px}
#overlay{position:fixed;z-index:99;left:0;top:155px;width:700px;height:210px;background:#eee}
.hidden{display:none}#opacity{opacity:0}#concealed{visibility:hidden}
</style><header><h1>Fixture</h1><div id="controls"><form id="search-form" action="/search" method="get"><label for="search-field">Search</label><input id="search-field" type="search" name="keyword" placeholder="Search notes"><button type="submit">Search</button></form><button data-testid="refresh">Refresh</button></div></header>
<main id="main"><section id="scroller">${cards}</section><section id="clipped"><p id="clipped-text">Clipped ordinary text</p><p id="clipped-away">Clipped away content</p></section>
<section id="ordinary"><h2>Ordinary heading</h2><p id="mutable">Fresh text</p><p id="phone-leaf">Contact 13800138000</p><div id="hidden-parent" class="hidden"><span id="hidden-child">Hidden content</span></div><p id="opacity">Transparent content</p><p id="concealed">Concealed content</p></section>
<section id="sensitive-ancestor" aria-label="verification code"><span id="sensitive-child">SENSITIVE_ANCESTOR</span></section><form id="private-form"><label>Password<input id="private-field" type="password" value="fixture-private"></label><p id="private-text">SENSITIVE_FORM</p></form>
<div id="offscreen"><p>Offscreen retained loaded text</p></div><div id="overlay">Visible overlay</div>
</main>`);
await page.evaluate(() => {
const costs = () => ({ computed_style: 0, bounding_rect: 0, query_selector: 0, query_selector_all: 0, element_from_point: 0 });
window.observationCosts = costs();
window.resetObservationCosts = () => { window.observationCosts = costs(); };
const wrap = (owner, name, cost) => {
const original = owner[name];
owner[name] = function (...args) { window.observationCosts[cost] += 1; return original.apply(this, args); };
};
wrap(window, 'getComputedStyle', 'computed_style');
wrap(Element.prototype, 'getBoundingClientRect', 'bounding_rect');
for (const owner of [Document.prototype, Element.prototype]) {
wrap(owner, 'querySelector', 'query_selector');
wrap(owner, 'querySelectorAll', 'query_selector_all');
}
wrap(Document.prototype, 'elementFromPoint', 'element_from_point');
});
async function sample(read, request) {
await page.evaluate(() => window.resetObservationCosts());
const started = performance.now();
const value = await read(page, request);
const duration_ms = performance.now() - started;
return { value, duration_ms, costs: await page.evaluate(() => window.observationCosts) };
}
async function compare(name, mutate, request = null) {
if (mutate) await page.evaluate(mutate);
const baseline = await sample(describeBaseline, request), optimized = await sample(describePage, request);
assert.deepEqual(optimized.value, baseline.value, name);
report.comparisons.push({ name, exact: true, elements: optimized.value.elements.length, ...(optimized.value.masks ? { masks: optimized.value.masks.length } : {}) });
return { baseline, optimized };
}
const initial = await compare('initial full observation with overlay, clipping, sensitive subtrees and output caps');
assert.ok(!initial.optimized.value.text.includes('SENSITIVE_ANCESTOR'));
assert.ok(!initial.optimized.value.text.includes('SENSITIVE_FORM'));
assert.ok(!initial.optimized.value.text.includes('13800138000'));
assert.ok(initial.optimized.value.masks.includes('#phone-leaf'));
assert.ok(initial.optimized.value.elements.length <= 120);
assert.equal(initial.optimized.value.viewport.scroll_y, 0);
await compare('canonical metadata', null, { selector: '#search-field', requireCanonical: true });
await compare('collection metadata', null, { selector: '#scroller a.name', collection: true });
await compare('noncanonical metadata rejected', null, { selector: 'input[type=search]', requireCanonical: true });
await compare('sensitive metadata rejected', null, { selector: '#sensitive-child' });
await compare('bounded metadata rejects more than 500 nodes', () => {
const root = document.createElement('div'); root.id = 'many';
root.innerHTML = '<i class="many-node">X</i>'.repeat(501); document.body.append(root);
}, { selector: '.many-node', collection: true });
await compare('remove large fixture and overlay; text and source attributes change', () => {
document.querySelector('#many').remove(); document.querySelector('#overlay').remove();
document.querySelector('#mutable').textContent = 'Changed source after previous call';
document.querySelector('#search-field').setAttribute('placeholder', 'Find content');
document.querySelector('#search-field').setAttribute('name', 'query');
});
await compare('scroll root changes viewport and current scroll metadata', () => { document.querySelector('#scroller').scrollTop = 630; });
const scrolled = await compare('metadata refreshed after root scroll', null, { selector: '#scroller', requireCanonical: true });
assert.equal(scrolled.optimized.value.elements[0].scroll_y.scroll_top, 630);
await compare('window scroll changes clipping', () => window.scrollTo(0, 460));
await compare('style changes are not cached across calls', () => {
window.scrollTo(0, 0);
document.querySelector('#hidden-parent').classList.remove('hidden');
document.querySelector('#opacity').style.opacity = '1';
document.querySelector('#concealed').style.visibility = 'visible';
document.querySelector('#ordinary').style.visibility = 'collapse';
document.querySelector('#scroller').style.height = '240px';
});
await compare('ancestor sensitivity changes are not cached across calls', () => {
document.querySelector('#ordinary').style.visibility = 'visible';
document.querySelector('#sensitive-ancestor').setAttribute('aria-label', 'Ordinary section');
document.querySelector('#private-field').setAttribute('type', 'text');
document.querySelector('#private-form label').firstChild.textContent = 'Ordinary';
document.querySelector('#private-form').id = 'ordinary-form';
document.querySelector('#private-text').textContent = 'Now ordinary form';
});
await compare('new private field causes current form exclusion', () => {
const form = document.querySelector('#ordinary-form');
const field = document.createElement('input'); field.autocomplete = 'one-time-code'; form.append(field);
});
await compare('disabled field and changed sensitive field label', () => {
document.querySelector('#search-field').disabled = true;
document.querySelector('#search-field').setAttribute('aria-label', 'verification code');
});
await compare('delete and recreate nodes with same IDs and different geometry', () => {
document.querySelector('#scroller').remove();
const section = document.createElement('section'); section.id = 'scroller'; section.style.height = '300px';
section.innerHTML = '<article id="comment-999" class="card"><a class="name" href="#new">New author</a><div class="content">New source</div></article>';
document.querySelector('#main').prepend(section);
});
await compare('metadata refreshed after node replacement', null, { selector: '#scroller', requireCanonical: true });
const duplicate = await compare('duplicate target metadata preserves ambiguity', () => {
const other = document.querySelector('#scroller').cloneNode(true); document.querySelector('#main').append(other);
}, { selector: '#scroller', requireCanonical: true });
assert.equal(duplicate.optimized.value.elements[0].unique, false);
assert.equal(duplicate.optimized.value.elements[0].count, 2);
await page.evaluate(async () => {
document.body.innerHTML = '<main id="media-root"><h1>Media observation</h1><p>Ordinary image and video</p><div class="body-media"><img id="content-image" width="100" height="60"><video id="content-video" width="100" height="60"></video></div><div class="avatar"><img id="avatar-image" width="30" height="30"></div><div style="display:none"><img id="hidden-image" width="30" height="30"></div></main>';
const pixel = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
const images = [...document.images];
await Promise.all(images.map(image => new Promise(resolve => { image.onload = image.onerror = resolve; image.src = pixel; })));
window.scrollTo(0, 0);
});
const media = await compare('media hints preserve loaded state, avatar exclusion and hidden media exclusion');
const hints = media.optimized.value.elements.find(element => element.selector === '#media-root').read_hints;
assert.ok(hints.some(hint => hint.selector === '#content-image' && hint.media_kind === 'image' && hint.loaded === true));
assert.ok(hints.some(hint => hint.selector === '#content-video' && hint.media_kind === 'video' && hint.loaded === false));
assert.ok(!hints.some(hint => hint.selector === '#avatar-image' || hint.selector === '#hidden-image'));
const long = await compare('raw walk reuse preserves normalization and per-root text truncation', () => {
document.body.innerHTML = '<main id="long-root"><section id="long-child"><p id="long-text"></p><p id="long-tail">Tail must be truncated only at the outer root</p></section></main>';
document.querySelector('#long-text').textContent = ' \n Leading whitespace\t' + '中文 Long text '.repeat(1600) + ' 13800138000';
});
assert.equal(long.optimized.value.text.length, 16000);
assert.ok(long.optimized.value.text.startsWith('Leading whitespace 中文 Long text'));
const limited = await compare('candidate/control/read limits and order remain identical in a dense visible page', () => {
document.body.innerHTML = '<main id="dense-root" style="display:grid;grid-template-columns:repeat(15,65px);grid-auto-rows:18px;font-size:10px;line-height:12px"></main>';
const root = document.querySelector('#dense-root');
root.innerHTML = Array.from({ length: 130 }, (_, index) => `<div id="dense-${index}">Read ${index}</div>`).join('') +
Array.from({ length: 40 }, (_, index) => `<button id="button-${index}">Button ${index}</button>`).join('') +
Array.from({ length: 25 }, (_, index) => `<input id="field-${index}" type="search" aria-label="Search ${index}">`).join('');
});
assert.ok(limited.optimized.value.elements.length <= 120);
assert.equal(limited.optimized.value.elements.filter(element => !element.collection && element.kind === 'fill').length, 16);
assert.equal(limited.optimized.value.elements.filter(element => !element.collection && element.tag === 'button').length, 24);
assert.equal(limited.optimized.value.elements.filter(element => !element.collection && element.kind === 'read').length, 48);
// Measure deterministic DOM work on the large unmodified fixture as well as
// elapsed time. Elapsed time is reported, never used as a flaky pass threshold.
report.performance.push({ name: 'initial_large_fixture', baseline_ms: initial.baseline.duration_ms, optimized_ms: initial.optimized.duration_ms,
baseline_costs: initial.baseline.costs, optimized_costs: initial.optimized.costs });
for (const name of ['computed_style', 'bounding_rect', 'element_from_point']) {
assert.ok(initial.optimized.costs[name] < initial.baseline.costs[name], `${name} must reuse current-call observations`);
}
assert.equal(initial.optimized.costs.query_selector_all, initial.baseline.costs.query_selector_all, 'Candidate discovery queries are unchanged');
report.status = 'passed';
} catch (error) {
report.status = 'failed'; report.error = { name: error.name, message: error.message.slice(0, 2000) };
throw error;
} finally {
await browser.close();
await fs.writeFile(path.join(runDirectory, 'report.json'), JSON.stringify(report, null, 2) + '\n');
console.log(JSON.stringify({ report: path.join(runDirectory, 'report.json'), status: report.status, comparisons: report.comparisons.length, real_platform_requests: 0 }));
}