- 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.
217 lines
13 KiB
JavaScript
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); });
|