confluence_web/tests/integration/browser_backend.mjs
Artur Mukhamadiev d77b52ae86 integration: contract checks, error mapping, deployment entry points
- tests/integration: frontend served by backend, runtime error mapping,
  backend + real pi image with scripted model/Confluence (shared example,
  variants, failure paths, cancellation/busy gate, isolation canaries,
  HTTP download ownership, 16 MiB prompt round trip), real OpenAI-compatible
  adapter + real image over a scripted transport, real Chrome against the
  real backend with a scripted runtime peer, backend crash/restart
  reconciliation, and an opt-in live model check (marker: live).
- backend: map runtime terminal codes (model_output_limit,
  model_context_exceeded, query_timeout, connectivity_failed) to the
  contract's HTTP statuses; make the artifact 404 body identical for
  no-session, wrong-session, unknown and expired IDs.
- Makefile, scripts/run-backend.sh, deploy/confluence-web.env.example,
  root README for the integrated application; integration pytest marker.
2026-09-14 22:20:34 +03:00

217 lines
13 KiB
JavaScript

/**
* Browser <-> backend pair check.
*
* Drives the real frontend served by the real backend (scripted runtime peer)
* in a real Chrome via CDP. Usage:
* node tests/integration/browser_backend.mjs --app http://127.0.0.1:8765 --control /path/to/control --chrome 9444
*/
import assert from 'node:assert/strict';
import { writeFileSync } from 'node:fs';
const args = Object.fromEntries(process.argv.slice(2).reduce((acc, a, i, arr) => {
if (a.startsWith('--')) acc.push([a.slice(2), arr[i + 1]]);
return acc;
}, []));
const APP = (args.app || 'http://127.0.0.1:8765').replace(/\/$/, '');
const CONTROL = args.control;
const CHROME_PORT = Number(args.chrome || 9444);
const setScenario = (name) => writeFileSync(CONTROL, name + '\n');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
class CDP {
constructor(ws) { this.wsUrl = ws; this.id = 1; this.pending = new Map(); this.requests = []; this.responses = []; }
async connect() {
this.ws = new WebSocket(this.wsUrl);
await new Promise((res, rej) => { this.ws.onopen = res; this.ws.onerror = rej; });
this.ws.onmessage = (e) => {
const d = JSON.parse(e.data);
if (d.id && this.pending.has(d.id)) { const p = this.pending.get(d.id); this.pending.delete(d.id); d.error ? p.reject(new Error(JSON.stringify(d.error))) : p.resolve(d.result); }
else if (d.method === 'Network.requestWillBeSent') this.requests.push(d.params.request);
else if (d.method === 'Network.responseReceived') this.responses.push({ url: d.params.response.url, status: d.params.response.status });
};
await this.send('Runtime.enable'); await this.send('Network.enable'); await this.send('Page.enable');
}
send(method, params = {}) { return new Promise((resolve, reject) => { const id = this.id++; this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, method, params })); }); }
async eval(expression) {
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(`eval: ${r.exceptionDetails.text} ${r.exceptionDetails.exception?.description || ''}`);
return r.result?.value;
}
async navigate(url) { await this.send('Page.navigate', { url }); await sleep(900); }
async waitFor(expression, timeoutMs = 15000, label = expression) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) { if (await this.eval(expression)) return; await sleep(100); }
const fb = await this.eval('(document.getElementById("modal-feedback")||{}).textContent + " | " + (document.getElementById("prompt-error")||{}).textContent');
throw new Error(`timeout waiting for ${label}; feedback=${JSON.stringify(fb)}; responses=${JSON.stringify(this.responses.slice(-6))}`);
}
close() { this.ws?.close(); }
}
const visible = (id) => `!document.getElementById("${id}").classList.contains("hidden")`;
async function main() {
const tabs = await (await fetch(`http://127.0.0.1:${CHROME_PORT}/json`)).json();
const tab = tabs.find((t) => t.type === 'page') || tabs[0];
if (!tab?.webSocketDebuggerUrl) throw new Error('no Chrome page tab');
const cdp = new CDP(tab.webSocketDebuggerUrl);
await cdp.connect();
let passed = 0, failed = 0;
const step = async (name, fn) => {
process.stdout.write(`${name}... `);
try { await fn(); console.log('PASS'); passed++; } catch (e) { console.log(`FAIL: ${e.message}`); failed++; }
};
try {
setScenario('standard');
await cdp.send('Network.clearBrowserCookies');
await cdp.navigate(APP + '/');
await step('Bootstrap: real index, HttpOnly cookie, headers', async () => {
assert.equal(await cdp.eval('document.title'), 'Confluence Research');
assert.ok(await cdp.eval(visible('view-prompt')));
const { cookies } = await cdp.send('Network.getCookies', { urls: [APP + '/'] });
const c = cookies.find((k) => k.name === 'cw_session');
assert.ok(c, 'cw_session cookie must be set by GET /');
assert.equal(c.httpOnly, true); assert.equal(c.sameSite, 'Strict'); assert.equal(c.path, '/');
assert.ok(!(await cdp.eval('document.cookie')).includes('cw_session'), 'cookie must not be script-readable');
// Assets loaded from the same origin with a real stylesheet applied.
const bg = await cdp.eval('getComputedStyle(document.body).backgroundColor');
assert.notEqual(bg, '', 'stylesheet must load');
assert.ok(await cdp.eval('typeof marked !== "undefined" && typeof DOMPurify !== "undefined"'), 'vendored libs must load');
});
await step('Verify credentials against the backend (403 then 200)', async () => {
await cdp.eval('document.getElementById("key-btn").click()');
await cdp.eval('(() => { for (const [id, v] of [["cred-url", "https://approved.example.com"], ["cred-pat", "wrong-pat"]]) { const el = document.getElementById(id); el.value = v; el.dispatchEvent(new Event("input", { bubbles: true })); } })()');
await cdp.eval('document.getElementById("btn-test-cred").click()');
await cdp.waitFor('document.getElementById("modal-feedback").textContent.length > 0', 5000, 'verify feedback');
const bad = await cdp.eval('document.getElementById("modal-feedback").textContent');
assert.ok(!/successful/i.test(bad), `wrong PAT must not verify: ${bad}`);
// Retype the token as a user would: the input event aborts any stale verify and re-enables the button.
await cdp.eval('(() => { const p = document.getElementById("cred-pat"); p.value = "dev-pat"; p.dispatchEvent(new Event("input", { bubbles: true })); })()');
await cdp.eval('document.getElementById("btn-test-cred").click()');
await cdp.waitFor('/successful/i.test(document.getElementById("modal-feedback").textContent)', 5000, 'success feedback');
await cdp.eval('document.getElementById("btn-save-cred").click()');
await sleep(200);
assert.ok(await cdp.eval('document.getElementById("cred-indicator").classList.contains("active")'));
assert.ok(!(await cdp.eval('JSON.stringify(localStorage) + JSON.stringify(sessionStorage) + location.href')).includes('dev-pat'));
});
await step('Query through the real backend: statuses, history, sources', async () => {
await cdp.eval('document.getElementById("prompt-input").value = "How do I deploy service X?"');
await cdp.eval('document.getElementById("submit-btn").click()');
assert.ok(await cdp.eval(visible('view-loading')), 'loading view');
await cdp.waitFor(visible('view-result'), 20000, 'result view');
const text = await cdp.eval('document.getElementById("output-content").textContent');
assert.ok(text.includes('Deployment Guide'), text.slice(0, 200));
const link = await cdp.eval('(() => { const a = document.querySelector("#output-content a"); return a && { target: a.target, rel: a.rel, href: a.href }; })()');
assert.deepEqual(link, { target: '_blank', rel: 'noopener noreferrer', href: 'https://approved.example.com/pages/viewpage.action?pageId=847291' });
assert.ok((await cdp.eval('document.getElementById("history-toggle-title").textContent')).includes('1 page accessed'));
await cdp.eval('document.getElementById("history-toggle-btn").click()');
assert.equal(await cdp.eval('document.querySelectorAll(".tool-card").length'), 2);
assert.equal(await cdp.eval('document.querySelector(".page-title-link").textContent'), 'Deployment Guide');
const q = cdp.requests.filter((r) => r.url.endsWith('/api/v1/query'));
assert.ok(q.length >= 1 && q[q.length - 1].method === 'POST');
// The backend enforces a matching Origin on mutations; a 200 proves the browser sent it.
const qr = cdp.responses.filter((r) => r.url.endsWith('/api/v1/query'));
assert.equal(qr[qr.length - 1].status, 200);
});
await step('Artifact download: exact bytes, attachment headers, ownership', async () => {
assert.equal(await cdp.eval('document.querySelector(".artifact-name").textContent'), 'checklist.md');
cdp.requests.length = 0;
await cdp.eval('document.querySelector(".download-btn").click()');
await sleep(600);
assert.ok(await cdp.eval('document.getElementById("artifacts-error").classList.contains("hidden")'), 'download error box hidden');
const dl = cdp.requests.find((r) => r.url.includes('/api/v1/artifacts/'));
assert.ok(dl, 'download request observed');
const check = await cdp.eval(`fetch(${JSON.stringify(dl.url)}).then(async r => ({ status: r.status, ct: r.headers.get('content-type'), cd: r.headers.get('content-disposition'), xcto: r.headers.get('x-content-type-options'), cc: r.headers.get('cache-control'), body: new TextDecoder().decode(await r.arrayBuffer()) }))`);
assert.equal(check.status, 200);
assert.equal(check.body, '# Checklist\n\n- Deploy service X\n');
assert.ok(check.ct.startsWith('application/octet-stream'));
assert.ok(check.cd.startsWith('attachment'));
assert.equal(check.xcto, 'nosniff'); assert.equal(check.cc, 'no-store');
const unknown = await cdp.eval(`fetch('/api/v1/artifacts/not-a-real-id').then(async r => ({ status: r.status, body: await r.text() }))`);
assert.equal(unknown.status, 404);
assert.equal(JSON.parse(unknown.body).error.code, 'artifact_not_found');
// Another browser session (fresh cookie jar) gets the same 404.
const other = await cdp.eval(`fetch(${JSON.stringify(dl.url)}, { credentials: 'omit' }).then(async r => ({ status: r.status, body: await r.text() }))`);
assert.equal(other.status, 404);
assert.equal(other.body, unknown.body);
});
await step('Cancel during run: server observes abort, next query succeeds', async () => {
await cdp.eval('document.getElementById("back-btn").click()');
setScenario('timeout');
await cdp.eval('document.getElementById("submit-btn").click()');
await sleep(400);
assert.ok(await cdp.eval(visible('view-loading')));
await cdp.eval('document.getElementById("cancel-btn").click()');
await cdp.waitFor(visible('view-prompt'), 5000, 'prompt after cancel');
assert.equal(await cdp.eval('document.getElementById("prompt-input").value'), 'How do I deploy service X?');
setScenario('standard');
// Retry until the busy gate releases after cleanup.
let ok = false;
for (let i = 0; i < 20 && !ok; i++) {
await cdp.eval('document.getElementById("submit-btn").click()');
await sleep(500);
ok = await cdp.eval(visible('view-result'));
if (!ok) {
const err = await cdp.eval('document.getElementById("prompt-error").textContent');
assert.ok(/busy/i.test(err) || err === '', `unexpected error: ${err}`);
}
}
assert.ok(ok, 'query after cancellation must succeed');
await cdp.eval('document.getElementById("back-btn").click()');
});
await step('Execution failure shows sanitized error and recovers', async () => {
setScenario('agent_error');
await cdp.eval('document.getElementById("submit-btn").click()');
await cdp.waitFor('document.getElementById("prompt-error").textContent.length > 0', 10000, 'error shown');
const err = await cdp.eval('document.getElementById("prompt-error").textContent');
assert.ok(!/traceback|exception|stack/i.test(err), err);
assert.ok(await cdp.eval(visible('view-prompt')));
setScenario('standard');
});
await step('Malicious Markdown/history under real headers: no script, no external requests', async () => {
setScenario('malicious');
cdp.requests.length = 0;
await cdp.eval('document.getElementById("submit-btn").click()');
await cdp.waitFor(visible('view-result'), 20000, 'result view');
await sleep(800);
assert.equal(await cdp.eval('document.querySelectorAll("#output-content script, #output-content img, #output-content iframe, #output-content form").length'), 0);
assert.equal(await cdp.eval('window.__xss'), undefined);
const hrefs = await cdp.eval('Array.from(document.querySelectorAll("#output-content a")).map(a => a.getAttribute("href"))');
assert.ok(hrefs.every((h) => !/^javascript:/i.test(h || '')), JSON.stringify(hrefs));
assert.ok(hrefs.some((h) => h && h.startsWith('https://approved.example.com')), 'citation kept');
await cdp.eval('document.getElementById("history-toggle-btn").click()');
await sleep(200);
assert.equal(await cdp.eval('document.querySelectorAll("#view-result script").length'), 0);
const external = cdp.requests.filter((r) => !r.url.startsWith(APP + '/') && !r.url.startsWith('data:') && !r.url.startsWith('blob:'));
assert.deepEqual(external.map((r) => r.url), [], 'no automatic external requests');
setScenario('standard');
await cdp.eval('document.getElementById("back-btn").click()');
});
await step('Clear credentials blocks queries', async () => {
await cdp.eval('document.getElementById("key-btn").click()');
const clearBtn = await cdp.eval('!!document.getElementById("btn-clear-cred")');
if (clearBtn) {
await cdp.eval('document.getElementById("btn-clear-cred").click()');
await sleep(200);
assert.ok(!(await cdp.eval('document.getElementById("cred-indicator").classList.contains("active")')));
} else {
await cdp.eval('document.getElementById("btn-cancel-cred").click()');
}
});
} finally {
cdp.close();
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(2); });