mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 06:15:58 -10:00
Web chat: preview a generated svg or html block in a sandboxed frame (#1628)
A complete ```svg or ```html block in an answer gets a Preview button. The block's own code is drawn in an iframe (srcdoc) that is cut off from the page: sandbox="" (an opaque origin: no access to the page's DOM, storage, cookies or server, no scripts, forms, popups, downloads or navigation of the page), a CSP in the frame's document (default-src 'none': no network, fonts, frames or media; styles inline, pictures as data: only; no <base>, no form target), no referrer and no permissions. A well-formed svg is shown as an <img> with a data: URL: an image runs no script, loads nothing and animates in every browser. The frame takes the page's light or dark colours and is built again when the theme changes. A page (html) can also be run WITH scripts, one block at a time, after a warning that is shown every time: sandbox="allow-scripts" only (still an opaque origin), the CSP allows the block's inline script and no address; closing the preview ends it. What the sandbox flags alone do not stop is a link or <meta refresh> inside the model's html loading a web page into the frame. The browser test shows it does (tools/webtest/browser_test.mjs, with and without the header). So the page is served with Content-Security-Policy: frame-src 'none' (nothing else: a srcdoc frame inherits the page's policy, and the script preview needs its inline script), and a <meta http-equiv=refresh> in a previewed page is dropped. tools/webtest holds the mock server (the page's own files, scripted answers, no model) and the browser tests; serve/test_web_preview.js checks the documents, flags and frames, serve/test_web_preview.py the header and that the page never listens for a frame's postMessage. Ported by hand from PR #1628 by thoives. Co-authored-by: Thomas Ivesdal-Tronstad <[email protected]>
This commit is contained in:
1 parent
a965c962cf
commit
0aa64b2de3
10 files changed
+732
-6
No files matched your search
@@ -449,6 +449,11 @@ class GpuBusy(RuntimeError):
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
# The page's only CSP directive: its preview frames (srcdoc) may not load any address. Other directives are left out
|
||||
# on purpose: a frame made from srcdoc inherits the page's policy, and the opt-in "run scripts" preview needs its own inline script.
|
||||
PAGE_CSP = "frame-src 'none'"
|
||||
|
||||
|
||||
def unload_wait(value, default: float) -> float:
|
||||
"""#1997: the `wait` of POST /unload?wait=...: absent / 0 / false: 0 (answer "busy" at once, as before); 1 / true /
|
||||
yes: `default` seconds; a number of seconds above 1: that long (at most an hour)."""
|
||||
@@ -6646,6 +6651,10 @@ def make_handler(svc: Service):
|
||||
body = (ROOT / "serve" / "web" / ("monitor.html" if path else "index.html")).read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
if not path:
|
||||
# the chat's svg/html preview is a sandboxed frame; this keeps it from navigating away to a web page
|
||||
# (a link or <meta refresh> inside a model's html), which no sandbox flag does (serve/web/preview.js)
|
||||
self.send_header("Content-Security-Policy", PAGE_CSP)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// The svg/html preview (serve/web/preview.js), run in Node. The real browser behaviour (what a sandboxed frame can and
|
||||
// cannot reach) is tested in tools/webtest/browser_test.mjs; here the documents and frames that are built are checked.
|
||||
// node serve/test_web_preview.js (serve/test_web_preview.py runs it too and skips when node is missing)
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const P = require(path.join(__dirname, "web", "preview.js"));
|
||||
|
||||
const light = {dark: false, ink: "#221e1f"}, dark = {dark: true, ink: "#eef1f3"};
|
||||
const cspOf = (doc) => /Content-Security-Policy" content="([^"]*)"/.exec(doc)[1];
|
||||
const noImage = {DOMParser: undefined}; // no DOMParser: the inline svg path
|
||||
|
||||
const cases = {
|
||||
can_preview() {
|
||||
assert.ok(P.canPreview("svg", "<svg></svg>") && P.canPreview("SVG", " <svg xmlns='x'/>"));
|
||||
assert.ok(P.canPreview("html", "<p>x</p>") && P.canPreview("htm", "<!doctype html>") && P.canPreview("HTML", "</p>"));
|
||||
assert.ok(!P.canPreview("python", "<svg></svg>") && !P.canPreview("html", "plain words") && !P.canPreview("svg", "<p>not svg</p>"));
|
||||
assert.ok(!P.canPreview("", "<p>x</p>") && !P.canPreview("svgx", "<svg>"), "the fence's language decides");
|
||||
},
|
||||
sandbox_flags_are_minimal() {
|
||||
assert.equal(P.SANDBOX.static, "", "no flags: no scripts, forms, popups, downloads, modals or navigation");
|
||||
assert.equal(P.SANDBOX.scripts, "allow-scripts", "scripts only");
|
||||
for (const v of Object.values(P.SANDBOX)) assert.ok(!/same-origin|top-navigation|popups|forms|modals|downloads|storage|pointer-lock|presentation/.test(v));
|
||||
},
|
||||
csp_has_no_network() {
|
||||
const off = P.csp(false), on = P.csp(true);
|
||||
for (const c of [off, on]) {
|
||||
assert.match(c, /^default-src 'none'; /, "everything is off unless listed");
|
||||
assert.ok(!/connect-src|frame-src|font-src|media-src|object-src|https?:|\*/.test(c), "no network source: " + c);
|
||||
assert.match(c, /img-src data:/); assert.match(c, /base-uri 'none'/); assert.match(c, /form-action 'none'/);
|
||||
}
|
||||
assert.ok(!off.includes("script-src"), "off: scripts fall back to default-src 'none'");
|
||||
assert.match(on, /script-src 'unsafe-inline'/, "on: the block's own inline script, and no source");
|
||||
assert.ok(!/script-src[^;]*(https?:|\*|'self'|data:)/.test(on));
|
||||
},
|
||||
html_documents() {
|
||||
const frag = P.previewDoc("html", "<p>hi</p>", false, light);
|
||||
assert.ok(frag.startsWith("<!doctype html><html><head><meta http-equiv=\"Content-Security-Policy\""), "the CSP comes first: nothing of the page runs before it");
|
||||
assert.ok(frag.includes("<p>hi</p>") && frag.endsWith("</body></html>"));
|
||||
assert.match(frag, /color-scheme:light/);
|
||||
const full = P.previewDoc("html", "<!DOCTYPE html><html><head><title>t</title></head><body><b>x</b></body></html>", false, dark);
|
||||
assert.ok(full.indexOf("Content-Security-Policy") < full.indexOf("<title>"), "our CSP is before the page's head");
|
||||
assert.equal((full.match(/<!doctype/gi) || []).length, 1, "one doctype");
|
||||
assert.match(full, /color-scheme:dark/);
|
||||
assert.equal(cspOf(full), P.csp(false));
|
||||
assert.equal(cspOf(P.previewDoc("html", "<p>x</p>", true, light)), P.csp(true));
|
||||
assert.equal(P.previewDoc("html", "just words", false, light), null);
|
||||
assert.equal(P.previewDoc("python", "<p>x</p>", false, light), null);
|
||||
},
|
||||
a_page_cannot_redirect_itself() {
|
||||
for (const meta of ['<meta http-equiv="refresh" content="0;url=http://evil.example/">', "<meta http-equiv=refresh content='0; url=http://evil.example'>",
|
||||
'<META HTTP-EQUIV = "Refresh" CONTENT="1;URL=http://evil.example">', '<meta content="0;url=http://evil.example" http-equiv="refresh">']) {
|
||||
const doc = P.previewDoc("html", `<!doctype html><html><head>${meta}</head><body>x</body></html>`, false, light);
|
||||
assert.ok(!/refresh/i.test(doc.replace(/Content-Security-Policy[^>]*>/, "")), meta + " -> " + doc);
|
||||
assert.ok(!/<meta\b[^>]*refresh/i.test(P.previewDoc("html", `<p>x</p>${meta}`, true, light)), "also in a fragment and with scripts");
|
||||
}
|
||||
},
|
||||
svg_documents() {
|
||||
const doc = P.previewDoc("svg", '<svg width="10" height="10"><circle r="4"/></svg>', false, light, noImage);
|
||||
assert.ok(doc.includes("<svg width=\"10\"") && doc.includes("display:flex"), "an svg that cannot be an image is inline");
|
||||
assert.equal(cspOf(doc), P.csp(false), "inline svg: scripts and handlers are still blocked by the CSP");
|
||||
assert.equal(P.previewDoc("svg", "<p>no</p>", false, light), null);
|
||||
// as an image: with a parser, the svg goes in as a data: URL, with the page's ink as currentColor
|
||||
const env = {
|
||||
DOMParser: class { parseFromString(s) {
|
||||
const style = {textContent: ""}, root = {namespaceURI: "http://www.w3.org/2000/svg", localName: "svg", firstChild: null,
|
||||
insertBefore(n) { this.style = n; }, hasAttribute: () => false};
|
||||
return {documentElement: root, querySelector: () => null, createElementNS: () => style, src: s};
|
||||
} },
|
||||
XMLSerializer: class { serializeToString(d) { return "<svg>" + (d.documentElement.style ? d.documentElement.style.textContent : "") + "</svg>"; } },
|
||||
};
|
||||
const img = P.previewDoc("svg", '<svg><script>x</script></svg>', false, {dark: false, ink: "#123456"}, env);
|
||||
assert.match(img, /<img alt="" style="width:100%;height:100%;object-fit:contain" src="data:image\/svg\+xml;charset=utf-8,/);
|
||||
assert.ok(decodeURIComponent(/src="data:image\/svg\+xml;charset=utf-8,([^"]*)"/.exec(img)[1]).includes("svg{color:#123456}"));
|
||||
assert.ok(!img.includes("<svg><script>"), "the svg is not in the document, only its encoded image");
|
||||
// with scripts requested for an svg, the markup is inline (an image cannot run anything)
|
||||
assert.ok(P.previewDoc("svg", "<svg></svg>", true, light, env).includes("<body><svg></svg>"));
|
||||
},
|
||||
the_theme_cannot_inject() {
|
||||
const evil = {dark: false, ink: "red;}</style><script>parent.x=1</script><style>"};
|
||||
assert.ok(!P.previewDoc("html", "<p>x</p>", false, evil).includes("<script>parent.x"), "the ink goes into CSS only after a check");
|
||||
assert.match(P.previewDoc("html", "<p>x</p>", false, evil), /color:CanvasText/);
|
||||
assert.match(P.previewDoc("html", "<p>x</p>", false, {dark: true, ink: "rgb(1, 2, 3)"}), /color:rgb\(1, 2, 3\)/);
|
||||
},
|
||||
frames_are_built_cut_off() {
|
||||
class Frame { constructor() { this.attrs = {}; this.dataset = {}; } setAttribute(k, v) { this.attrs[k] = v; } }
|
||||
const env = {document: {createElement: (t) => { assert.equal(t, "iframe"); return new Frame(); }}};
|
||||
const f = P.makeFrame("<p>x</p>", {scripts: false, title: "Page preview", lang: "html"}, env);
|
||||
assert.deepEqual(f.attrs, {sandbox: "", referrerpolicy: "no-referrer", allow: ""});
|
||||
assert.equal(f.srcdoc, "<p>x</p>"); assert.equal(f.dataset.opened, "1"); assert.equal(f.dataset.scripts, undefined);
|
||||
const run = P.makeFrame("<p>x</p>", {scripts: true}, env);
|
||||
assert.equal(run.attrs.sandbox, "allow-scripts"); assert.equal(run.dataset.scripts, "1");
|
||||
const blank = P.makeFrame(null, {}, env);
|
||||
assert.equal(blank.srcdoc, undefined); assert.equal(blank.dataset.opened, undefined, "an empty frame holds nothing");
|
||||
assert.equal(blank.attrs.sandbox, "", "a blank frame is as closed as a static one");
|
||||
},
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const name = process.argv[2];
|
||||
const run = name ? [[name, cases[name]]] : Object.entries(cases);
|
||||
if (name && !cases[name]) throw new Error("unknown test " + name);
|
||||
for (const [n, fn] of run) { await fn(); console.log("ok " + n); }
|
||||
})().catch((e) => { console.error(e); process.exitCode = 1; });
|
||||
@@ -0,0 +1,79 @@
|
||||
"""The chat's svg/html preview: sandboxed, no network, no way to the page (serve/web/preview.js)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from serve.frontend import ChatTemplate
|
||||
from serve.server import PAGE_CSP, ByteTokenizer, MockEngine, Service, serve
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB = Path(__file__).parent / "web"
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "node is not installed")
|
||||
class PreviewInNode(unittest.TestCase):
|
||||
def test_documents_frames_and_flags(self):
|
||||
r = subprocess.run([shutil.which("node"), str(Path(__file__).with_name("test_web_preview.js"))],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
|
||||
|
||||
class ThePageSource(unittest.TestCase):
|
||||
def test_no_way_for_a_frame_to_reach_the_page(self):
|
||||
scripts = {p.name: p.read_text(encoding="utf-8") for p in WEB.glob("*.js")}
|
||||
for name, src in scripts.items():
|
||||
with self.subTest(name=name):
|
||||
self.assertNotIn("allow-same-origin", src.replace("no allow-same-origin", ""), "a frame with the page's origin is the page")
|
||||
self.assertNotRegex(src, r"allow-(top-navigation|popups|forms|modals|downloads)")
|
||||
# the page never listens for what a frame posts to it
|
||||
self.assertNotRegex(scripts["app.js"], r"addEventListener\(\s*[\"']message[\"']")
|
||||
self.assertNotRegex(scripts["app.js"], r"\bonmessage\b")
|
||||
# every iframe starts closed, and is only ever made by preview.js
|
||||
for tag in re.findall(r"<iframe\b[^>]*>", scripts["app.js"]):
|
||||
self.assertIn('sandbox=""', tag)
|
||||
self.assertNotIn("createElement(\"iframe\")", scripts["app.js"])
|
||||
self.assertIn("srcdoc", scripts["preview.js"])
|
||||
self.assertNotRegex(scripts["preview.js"], r"\.src\s*=")
|
||||
|
||||
|
||||
class TheHeader(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
tok = ByteTokenizer()
|
||||
cls.svc = Service(MockEngine(tok, "ok"), tok, ChatTemplate(ROOT / "serve" / "chat_template.jinja"))
|
||||
cls.httpd = serve(cls.svc, port=0)
|
||||
cls.base = f"http://127.0.0.1:{cls.httpd.server_address[1]}"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
|
||||
def test_the_page_forbids_a_frame_to_load_an_address(self):
|
||||
with urllib.request.urlopen(self.base + "/", timeout=10) as r:
|
||||
self.assertEqual(r.headers.get("Content-Security-Policy"), PAGE_CSP)
|
||||
self.assertEqual(PAGE_CSP, "frame-src 'none'",
|
||||
"only this directive: a srcdoc frame inherits the page's policy, and the opt-in script preview needs its inline script")
|
||||
|
||||
def test_other_files_do_not_carry_it(self):
|
||||
with urllib.request.urlopen(self.base + "/web/app.js", timeout=10) as r:
|
||||
self.assertIsNone(r.headers.get("Content-Security-Policy"))
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("WEBTEST_MODULES") and shutil.which("node"),
|
||||
"set WEBTEST_MODULES (a folder with node_modules/puppeteer-core) to run the browser tests")
|
||||
class InABrowser(unittest.TestCase):
|
||||
def test_sandbox_in_chrome(self):
|
||||
r = subprocess.run([shutil.which("node"), str(ROOT / "tools" / "webtest" / "browser_test.mjs"), "preview"],
|
||||
capture_output=True, text=True, timeout=300)
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -228,3 +228,16 @@ main { flex: 1; min-height: 0; display: flex; }
|
||||
.chat-math--display > * { margin: 0 !important; }
|
||||
.chat-math[data-math-error] { white-space: pre-wrap; }
|
||||
.chat-math .katex { font-size: 1.08em; }
|
||||
|
||||
/* svg/html preview (serve/web/preview.js): the frame is sandboxed and carries the page's colours, so it is transparent on the
|
||||
theme's surface; the code and the preview swap places inside the same box. */
|
||||
.st-code__btns { display: flex; gap: 4px; }
|
||||
.st-code .st-btn--icon[aria-pressed="true"] { background: rgba(255, 255, 255, .12); color: #fff; }
|
||||
.st-code__preview { display: none; }
|
||||
.st-code[data-preview="true"] > pre { display: none; }
|
||||
.st-code[data-preview="true"] .st-code__preview { display: block; height: min(340px, 60vh); background: var(--st-surface-2); }
|
||||
.st-code [data-preview-scripts] { display: none; }
|
||||
.st-code[data-preview="true"] [data-preview-scripts] { display: inline-flex; }
|
||||
.st-code .st-btn--icon[data-preview-scripts][aria-pressed="true"] { background: var(--st-warn-tint); color: var(--st-warn-text); }
|
||||
.st-code--run .st-code__preview { outline: 2px solid var(--st-warn-text); outline-offset: -2px; }
|
||||
.st-code__preview iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; }
|
||||
+54
-6
@@ -60,6 +60,7 @@ function setTheme(t, save) {
|
||||
if (save) try { localStorage.setItem("strata.theme", t); } catch (e) { /* ignore */ }
|
||||
$("theme-icon").setAttribute("href", `${SPRITE}#i-${t === "dark" ? "sun" : "moon"}`);
|
||||
$("dark-toggle").setAttribute("aria-checked", String(t === "dark"));
|
||||
refreshPreviews(); // the preview frames carry the page's colours
|
||||
}
|
||||
const flipTheme = () => setTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true);
|
||||
$("theme-btn").onclick = flipTheme;
|
||||
@@ -512,10 +513,36 @@ function inline(s) {
|
||||
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
return s.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code class="inline">${esc(codes[+i])}</code>`);
|
||||
}
|
||||
function codeBlock(lang, code) {
|
||||
return `<div class="st-code"><div class="st-code__head"><span>${esc(lang || "code")}</span>` +
|
||||
`<button class="st-btn st-btn--icon" data-code-copy aria-label="Copy code">${icon("copy")}</button></div>` +
|
||||
`<pre><code>${esc(code)}</code></pre></div>`;
|
||||
// An ```svg or ```html block that is complete gets a Preview button (serve/web/preview.js): the block's own code in a sandboxed
|
||||
// frame, built when it is first opened and again when the theme changes. The state lives on the box (data-preview), so it
|
||||
// resets if the answer is rebuilt. A page (html) can also be run WITH scripts, after a warning, one block at a time.
|
||||
function pageTheme() {
|
||||
const root = document.documentElement;
|
||||
return {dark: root.dataset.theme === "dark", ink: getComputedStyle(root).getPropertyValue("--st-ink").trim()};
|
||||
}
|
||||
function fillPreview(fr, scripts, blank) {
|
||||
const box = fr.closest(".st-code");
|
||||
const doc = blank ? null : StrataPreview.previewDoc(fr.dataset.lang, box.querySelector("pre").textContent, scripts, pageTheme(), window);
|
||||
const nf = StrataPreview.makeFrame(doc, {scripts, title: fr.title, lang: fr.dataset.lang});
|
||||
fr.replaceWith(nf);
|
||||
box.classList.toggle("st-code--run", !!(doc && scripts));
|
||||
const rb = box.querySelector("[data-preview-scripts]");
|
||||
if (rb) {
|
||||
rb.setAttribute("aria-pressed", String(!!(doc && scripts)));
|
||||
rb.setAttribute("aria-label", doc && scripts ? tr("preview.stopScripts", "Stop scripts") : tr("preview.runScripts", "Run scripts"));
|
||||
}
|
||||
}
|
||||
function refreshPreviews() { for (const fr of document.querySelectorAll("iframe[data-opened]")) fillPreview(fr, fr.dataset.scripts === "1"); }
|
||||
function codeBlock(lang, code, complete) {
|
||||
const ok = complete && StrataPreview.canPreview(lang || "", code);
|
||||
const kind = ok ? StrataPreview.kindOf(lang) : null;
|
||||
const label = kind === "svg" ? tr("preview.showSvg", "Preview the picture") : tr("preview.showPage", "Preview the page");
|
||||
const head = `<div class="st-code__head"><span>${esc(lang || tr("code.label", "code"))}</span><span class="st-code__btns">` +
|
||||
(kind === "page" ? `<button class="st-btn st-btn--icon" data-preview-scripts aria-label="${esc(tr("preview.runScripts", "Run scripts"))}" aria-pressed="false" title="${esc(tr("preview.runScripts", "Run scripts"))}">${icon("warning")}</button>` : "") +
|
||||
(ok ? `<button class="st-btn st-btn--icon" data-preview-toggle data-preview-label="${esc(label)}" aria-label="${esc(label)}" title="${esc(label)}" aria-pressed="false">${icon("image")}</button>` : "") +
|
||||
`<button class="st-btn st-btn--icon" data-code-copy aria-label="${esc(tr("code.copy", "Copy code"))}">${icon("copy")}</button></span></div>`;
|
||||
const prev = ok ? `<div class="st-code__preview"><iframe sandbox="" title="${esc(kind === "svg" ? tr("preview.titleSvg", "Picture preview") : tr("preview.titlePage", "Page preview"))}" data-lang="${esc(lang)}"></iframe></div>` : "";
|
||||
return `<div class="st-code">${head}<pre><code>${esc(code)}</code></pre>${prev}</div>`;
|
||||
}
|
||||
function blocks(text) {
|
||||
const out = [], lines = text.split("\n");
|
||||
@@ -561,8 +588,8 @@ function markdown(text) {
|
||||
html += blocks(rest.slice(0, m.index));
|
||||
rest = rest.slice(m.index + m[0].length);
|
||||
const end = rest.match(/(^|\n)```[ \t]*(\n|$)/);
|
||||
if (!end) { html += codeBlock(m[2].trim(), rest); break; } // still streaming
|
||||
html += codeBlock(m[2].trim(), rest.slice(0, end.index));
|
||||
if (!end) { html += codeBlock(m[2].trim(), rest, false); break; } // still streaming: no preview of a half-written block
|
||||
html += codeBlock(m[2].trim(), rest.slice(0, end.index), true);
|
||||
rest = rest.slice(end.index + end[0].length);
|
||||
}
|
||||
return html;
|
||||
@@ -727,6 +754,27 @@ function scrollDown(force) { const s = $("chat-scroll"); if (force || nearBottom
|
||||
$("chat").addEventListener("click", (e) => {
|
||||
const cc = e.target.closest("[data-code-copy]");
|
||||
if (cc) { copyText(cc.closest(".st-code").querySelector("pre").textContent, cc); return; }
|
||||
// an svg/html block: flip between its code and its sandboxed preview. Closing the preview also turns scripts off.
|
||||
const pv = e.target.closest("[data-preview-toggle]");
|
||||
if (pv) {
|
||||
const box = pv.closest(".st-code");
|
||||
const on = box.dataset.preview === "true";
|
||||
if (on) delete box.dataset.preview; else box.dataset.preview = "true";
|
||||
pv.setAttribute("aria-pressed", String(!on));
|
||||
pv.setAttribute("aria-label", on ? pv.dataset.previewLabel : tr("preview.showCode", "Show the code"));
|
||||
const fr = box.querySelector("iframe");
|
||||
if (on) fillPreview(fr, false, true);
|
||||
else if (!fr.dataset.opened) fillPreview(fr, false);
|
||||
return;
|
||||
}
|
||||
const rs = e.target.closest("[data-preview-scripts]");
|
||||
if (rs) {
|
||||
const fr = rs.closest(".st-code").querySelector("iframe");
|
||||
const run = fr.dataset.scripts !== "1";
|
||||
if (run && !confirm(tr("preview.confirm", "Run this code with scripts?\n\nIt runs in your browser, cut off from this chat and from the network, but it can still freeze the tab, and it is code the model wrote.\n\nOnly this block, and only until you close the preview."))) return;
|
||||
fillPreview(fr, run);
|
||||
return;
|
||||
}
|
||||
const mc = e.target.closest("[data-msg-copy]");
|
||||
if (mc) { const i = +mc.closest(".st-msg").dataset.i; copyText(messages[i].text, mc); return; }
|
||||
// a tool block: its open state lives in the message (the answer is rebuilt while it streams), so the click sets it
|
||||
|
||||
@@ -271,6 +271,7 @@
|
||||
<div class="scrim" id="scrim" hidden></div>
|
||||
<div class="st-toasts" id="toasts" aria-live="polite"></div>
|
||||
<script src="web/i18n.js"></script>
|
||||
<script src="web/preview.js"></script>
|
||||
<script src="web/math.js"></script>
|
||||
<script src="web/math-adapter.js"></script>
|
||||
<script src="web/app.js"></script>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// serve/web/preview.js - the preview of an ```svg or ```html block in an answer (PR #1628 by thoives).
|
||||
//
|
||||
// A block is shown in an <iframe srcdoc> that is cut off from the page:
|
||||
// - sandbox="" : an opaque origin, so no access to the page's DOM, storage, cookies or the server; no scripts, no forms,
|
||||
// no popups, no navigation of the page, no downloads;
|
||||
// - a CSP in the frame's own document: default-src 'none' (no network, no fonts, no frames, no media), styles inline and
|
||||
// pictures as data: only, no <base>, no form target;
|
||||
// - the page's own header CSP frame-src 'none' (serve/server.py) stops the frame from navigating away to a web page
|
||||
// (a link, or <meta refresh>), which no sandbox flag can.
|
||||
// An svg is shown as an <img> with a data: URL where it parses as XML: an image runs no scripts and loads nothing, and it
|
||||
// animates in every browser. The user can run ONE html block with scripts, after a warning, never remembered: the frame then
|
||||
// has sandbox="allow-scripts" (still an opaque origin, still no network), and the CSP allows its inline script.
|
||||
//
|
||||
// This file builds strings and frames only; app.js decides when. Nothing in it touches the page's own DOM.
|
||||
"use strict";
|
||||
(function (root) {
|
||||
// no allow-same-origin: that would give the frame the page's origin and with it the page itself
|
||||
const SANDBOX = {static: "", scripts: "allow-scripts"};
|
||||
const csp = (scripts) => "default-src 'none'; " + (scripts ? "script-src 'unsafe-inline'; " : "") +
|
||||
"style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'";
|
||||
const metaCsp = (scripts) => `<meta http-equiv="Content-Security-Policy" content="${csp(scripts)}">`;
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
// theme = {dark: bool, ink: "#rrggbb"}: the frame carries the page's colours, so it is transparent on the page's surface
|
||||
const safeInk = (ink) => (/^[#\w(),.\s%-]{1,40}$/.test(ink || "") ? ink : "CanvasText");
|
||||
const themeCss = (theme) => `html{color-scheme:${theme && theme.dark ? "dark" : "light"};color:${safeInk(theme && theme.ink)}}`;
|
||||
const frameHead = (css, scripts, theme) => `<!doctype html><html><head>${metaCsp(scripts)}<style>${themeCss(theme)}html,body{background:transparent;margin:0;height:100%}${css}</style></head><body>`;
|
||||
|
||||
// a plain svg as an <img> with a data: URL, or null when it is not well-formed XML (then the inline svg is used)
|
||||
function svgImage(s, theme, env = root) {
|
||||
try {
|
||||
const doc = new env.DOMParser().parseFromString(/^<svg\b[^>]*\sxmlns\s*=/i.test(s) ? s : s.replace(/^<svg\b/i, `<svg xmlns="${SVG_NS}"`), "image/svg+xml");
|
||||
const r = doc.documentElement;
|
||||
if (doc.querySelector("parsererror") || r.namespaceURI !== SVG_NS || r.localName !== "svg") return null;
|
||||
const st = doc.createElementNS(SVG_NS, "style"); // an image has no page around it: give currentColor the page's ink
|
||||
st.textContent = `svg{color:${safeInk(theme && theme.ink)}}`;
|
||||
r.insertBefore(st, r.firstChild);
|
||||
const fill = !r.hasAttribute("width") && !r.hasAttribute("height");
|
||||
return `<img alt="" ${fill ? 'style="width:100%;height:100%;object-fit:contain" ' : ""}src="data:image/svg+xml;charset=utf-8,${encodeURIComponent(new env.XMLSerializer().serializeToString(doc))}">`;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// the document for a block, or null when the block is not a previewable svg or html (lang is the fence's language)
|
||||
function previewDoc(lang, code, scripts, theme, env) {
|
||||
let s = String(code).trim();
|
||||
if (/^svg\b/i.test(lang) && /^<svg[\s>]/i.test(s)) {
|
||||
const img = scripts ? null : svgImage(s, theme, env);
|
||||
return frameHead("body{display:flex;align-items:center;justify-content:center}" + (img ? "img" : "svg") + "{max-width:100%;max-height:100%}", scripts, theme) +
|
||||
(img || s) + "</body></html>";
|
||||
}
|
||||
if (/^html?$/i.test(lang) && /^<[/a-z!]/i.test(s)) {
|
||||
s = s.replace(/<meta\b[^>]*\bhttp-equiv\s*=\s*["']?\s*refresh\b[^>]*>/gi, ""); // a page that redirects itself would leave the sandbox's document
|
||||
// a complete document: our CSP and theme come first, then the page without its doctype (its own html and head tags merge
|
||||
// into ours, and its own CSS still wins); nothing of the page can run before the CSP is in force
|
||||
if (/^<!doctype\s+html/i.test(s)) return `<!doctype html><html><head>${metaCsp(scripts)}<style>${themeCss(theme)}</style></head>` + s.replace(/^<!doctype[^>]*>/i, "");
|
||||
return frameHead("", scripts, theme) + s + "</body></html>"; // a fragment: wrap in a minimal document
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const kindOf = (lang) => (/^svg\b/i.test(lang) ? "svg" : "page");
|
||||
// cheap: can this block be previewed at all (the same test as previewDoc, without building anything)
|
||||
const canPreview = (lang, code) => {
|
||||
const s = String(code).trim();
|
||||
return (/^svg\b/i.test(lang) && /^<svg[\s>]/i.test(s)) || (/^html?$/i.test(lang) && /^<[/a-z!]/i.test(s));
|
||||
};
|
||||
|
||||
// a new frame holding the document (the sandbox attribute only counts when a frame is created, so a frame is never reused)
|
||||
function makeFrame(doc, {scripts = false, title = "", lang = ""} = {}, env = root) {
|
||||
const f = env.document.createElement("iframe");
|
||||
f.setAttribute("sandbox", scripts ? SANDBOX.scripts : SANDBOX.static);
|
||||
f.setAttribute("referrerpolicy", "no-referrer");
|
||||
f.setAttribute("allow", "");
|
||||
f.title = title; f.dataset.lang = lang;
|
||||
if (doc) { f.dataset.opened = "1"; if (scripts) f.dataset.scripts = "1"; f.srcdoc = doc; }
|
||||
return f;
|
||||
}
|
||||
|
||||
const api = {SANDBOX, csp, metaCsp, previewDoc, svgImage, kindOf, canPreview, makeFrame, themeCss};
|
||||
root.StrataPreview = api;
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this);
|
||||
@@ -0,0 +1,185 @@
|
||||
// Browser tests of the served web page (serve/web) in headless Chrome or Edge, against the mock server in mock.mjs.
|
||||
// No model, no GPU. Optional: the normal test run (serve/test_web_*.py, node serve/test_web_*.js) does not need a browser.
|
||||
//
|
||||
// WEBTEST_MODULES=<folder with node_modules/puppeteer-core> [CHROME_BIN=<chrome>] node tools/webtest/browser_test.mjs [name]
|
||||
import assert from "node:assert/strict";
|
||||
import { startMock } from "./mock.mjs";
|
||||
import { launch, openPage, say, idle, sleep, runner } from "./lib.mjs";
|
||||
|
||||
const { test, run } = runner("web page browser tests");
|
||||
|
||||
const turn = (role, text, extra = {}) => ({ role, text, time: 1, ...extra });
|
||||
const fence = (lang, code) => "```" + lang + "\n" + code + "\n```\n";
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ preview (#1628)
|
||||
const hostilePage = (base) => `<!doctype html><html><head><meta http-equiv="refresh" content="0;url=${base}/exfil-refresh"></head><body>
|
||||
<pre id="out">static</pre><a id="l" href="${base}/exfil-link">link</a>
|
||||
<form id="f" action="${base}/exfil-form" method="post"><button id="s">go</button></form>
|
||||
<script>
|
||||
const out = [];
|
||||
const log = (k, v) => { out.push(k + ": " + v); document.getElementById("out").textContent = out.join("\\n"); };
|
||||
try { log("parent.document", typeof parent.document.title); } catch (e) { log("parent.document", "blocked " + e.name); }
|
||||
try { log("top.document", typeof top.document.title); } catch (e) { log("top.document", "blocked " + e.name); }
|
||||
try { log("localStorage", localStorage.length); } catch (e) { log("localStorage", "blocked " + e.name); }
|
||||
try { log("cookie", document.cookie.length); } catch (e) { log("cookie", "blocked " + e.name); }
|
||||
try { top.location = "${base}/exfil-top"; log("top.location=", "no error"); } catch (e) { log("top.location=", "blocked " + e.name); }
|
||||
try { const w = window.open("${base}/exfil-open"); log("window.open", w ? "opened" : "blocked"); } catch (e) { log("window.open", "blocked"); }
|
||||
try { parent.postMessage("hello", "*"); log("postMessage", "sent"); } catch (e) { log("postMessage", "blocked"); }
|
||||
fetch("${base}/exfil-fetch").then(() => log("fetch", "went out"), () => log("fetch", "blocked"));
|
||||
const x = new XMLHttpRequest(); x.open("GET", "${base}/exfil-xhr"); x.onerror = () => log("xhr", "blocked"); x.onload = () => log("xhr", "went out"); try { x.send(); } catch (e) { log("xhr", "blocked"); }
|
||||
new Image().src = "${base}/exfil-img";
|
||||
const s = document.createElement("script"); s.src = "${base}/exfil-script.js"; document.head.appendChild(s);
|
||||
try { new WebSocket("ws://127.0.0.1:9/exfil-ws"); } catch (e) { log("websocket", "blocked"); }
|
||||
log("ran", "yes");
|
||||
</script></body></html>`;
|
||||
|
||||
async function hostileChat(mock) {
|
||||
return { chat: [turn("user", "page please"), turn("assistant", "Here:\n\n" + fence("html", hostilePage(mock.url)))] };
|
||||
}
|
||||
const exfil = (mock) => mock.state.calls.filter((c) => c.includes("exfil"));
|
||||
|
||||
test("preview: an svg block previews as a picture, with no script and no network", async ({ browser, mock }) => {
|
||||
const page = await openPage(browser, mock, { seed: { chat: [turn("user", "draw"), turn("assistant", "x\n\n" + fence("svg",
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><script>parent.__pwned=1</script><image href="' + mock.url + '/exfil-svg-img"/><circle cx="40" cy="40" r="30" fill="red" onload="parent.__pwned=2"/></svg>'))] } });
|
||||
await page.click("[data-preview-toggle]");
|
||||
const frame = await page.$("iframe");
|
||||
assert.equal(await frame.evaluate((f) => f.getAttribute("sandbox")), "", "the sandbox has no flags at all");
|
||||
assert.equal(await frame.evaluate((f) => f.getAttribute("referrerpolicy")), "no-referrer");
|
||||
const doc = await frame.evaluate((f) => f.srcdoc);
|
||||
assert.match(doc, /Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"/);
|
||||
assert.match(doc, /<img [^>]*src="data:image\/svg\+xml/, "a well-formed svg is shown as an image: images run no script");
|
||||
await sleep(400);
|
||||
assert.equal(await page.evaluate(() => window.__pwned), undefined);
|
||||
assert.deepEqual(exfil(mock), []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("preview: a page does not run scripts, load addresses or leave the sandbox until asked", async ({ browser, mock }) => {
|
||||
const page = await openPage(browser, mock, { seed: await hostileChat(mock) });
|
||||
const title = await page.title(), url = page.url();
|
||||
await page.click("[data-preview-toggle]");
|
||||
await sleep(600);
|
||||
const frame = await (await page.$("iframe")).contentFrame();
|
||||
assert.equal(await frame.evaluate(() => document.getElementById("out").textContent), "static", "the script did not run");
|
||||
assert.equal(await (await page.$("iframe")).evaluate((f) => f.getAttribute("sandbox")), "");
|
||||
assert.equal(page.lastDialog, undefined, "no dialog without the run button");
|
||||
await frame.click("#l").catch(() => {}); // a link in the page
|
||||
await frame.click("#s").catch(() => {}); // a form in the page
|
||||
await sleep(500);
|
||||
assert.deepEqual(exfil(mock), [], "no request left the preview");
|
||||
assert.equal(page.url(), url); assert.equal(await page.title(), title);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("preview: run with scripts keeps the page and the network out of reach", async ({ browser, mock }) => {
|
||||
const page = await openPage(browser, mock, { seed: await hostileChat(mock) });
|
||||
const url = page.url(), title = await page.title();
|
||||
const messages = [];
|
||||
await page.evaluate(() => { window.addEventListener("message", (e) => { window.__message = e.data; }); });
|
||||
await page.click("[data-preview-toggle]");
|
||||
await page.click("[data-preview-scripts]");
|
||||
assert.match(page.lastDialog, /Run this code with scripts/, "a warning comes first");
|
||||
const iframe = await page.$("iframe");
|
||||
assert.equal(await iframe.evaluate((f) => f.getAttribute("sandbox")), "allow-scripts", "scripts, and nothing else");
|
||||
await sleep(1200);
|
||||
const frame = await iframe.contentFrame();
|
||||
const out = await frame.evaluate(() => document.getElementById("out").textContent);
|
||||
for (const line of ["ran: yes", "parent.document: blocked SecurityError", "top.document: blocked SecurityError",
|
||||
"localStorage: blocked SecurityError", "cookie: blocked SecurityError", "fetch: blocked", "xhr: blocked", "window.open: blocked"]) {
|
||||
assert.ok(out.includes(line), `${line}\n--- got:\n${out}`);
|
||||
}
|
||||
await frame.click("#l").catch(() => {});
|
||||
await frame.click("#s").catch(() => {});
|
||||
await sleep(800);
|
||||
assert.deepEqual(exfil(mock), [], "no address was reached from the preview: " + exfil(mock));
|
||||
assert.equal(page.url(), url, "the page did not navigate");
|
||||
assert.equal(await page.title(), title);
|
||||
assert.equal(await page.evaluate(() => document.querySelectorAll("iframe").length), 1, "no frame was added to the page");
|
||||
assert.equal(await page.evaluate(() => typeof localStorage.getItem("strata.chat")), "string", "the page's own store is untouched");
|
||||
// a message to the page is the only thing that can cross; the page has no listener of its own, so nothing happens with it
|
||||
assert.ok(!(await page.evaluate(() => /addEventListener\(["']message["']/.test(document.scripts[document.scripts.length - 1].textContent || ""))));
|
||||
// closing the preview ends the script
|
||||
await page.click("[data-preview-toggle]");
|
||||
assert.equal(await page.$eval(".st-code", (b) => b.classList.contains("st-code--run")), false);
|
||||
assert.equal(await page.$eval("iframe", (f) => f.getAttribute("sandbox")), "");
|
||||
assert.equal(await page.$eval("iframe", (f) => f.getAttribute("srcdoc")), null);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("preview: the page's CSP header is what keeps a link inside the preview from loading a web page", async ({ browser }) => {
|
||||
const link = (m) => ({ chat: [turn("user", "x"), turn("assistant", fence("html", `<a id="l" href="${m.url}/exfil-link">go</a>`))] });
|
||||
for (const csp of [false, true]) {
|
||||
const m = await startMock({ csp });
|
||||
const page = await openPage(browser, m, { seed: link(m) });
|
||||
await page.click("[data-preview-toggle]");
|
||||
await sleep(300);
|
||||
await (await (await page.$("iframe")).contentFrame()).click("#l");
|
||||
await sleep(700);
|
||||
const went = exfil(m).length;
|
||||
assert.equal(went, csp ? 0 : 1, csp ? "with the header the link goes nowhere" : "without it the sandbox alone does let a link load a page (why the header exists)");
|
||||
await page.close();
|
||||
await m.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("preview: declining the warning runs nothing", async ({ browser, mock }) => {
|
||||
const page = await openPage(browser, mock, { seed: await hostileChat(mock), dialogs: "dismiss" });
|
||||
await page.click("[data-preview-toggle]");
|
||||
await page.click("[data-preview-scripts]");
|
||||
assert.ok(page.lastDialog);
|
||||
assert.equal(await page.$eval("iframe", (f) => f.getAttribute("sandbox")), "");
|
||||
assert.equal(await (await (await page.$("iframe")).contentFrame()).evaluate(() => document.getElementById("out").textContent), "static");
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("preview: not offered for a half-written block, other languages or text that is not markup", async ({ browser, mock }) => {
|
||||
const page = await openPage(browser, mock, { seed: { chat: [turn("user", "x"),
|
||||
turn("assistant", fence("python", "<svg></svg>") + fence("html", "just words") + fence("svg", "not an svg") + "```html\n<p>unfinished")] } });
|
||||
assert.equal(await page.$$eval("[data-preview-toggle]", (e) => e.length), 0);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("preview: follows the theme and shows the code again", async ({ browser, mock }) => {
|
||||
const page = await openPage(browser, mock, { dark: false, seed: { chat: [turn("user", "x"), turn("assistant", fence("html", "<p>hello</p>"))] } });
|
||||
await page.click("[data-preview-toggle]");
|
||||
const doc = () => page.$eval("iframe", (f) => f.srcdoc);
|
||||
assert.match(await doc(), /color-scheme:light/);
|
||||
await page.click("#theme-btn");
|
||||
assert.match(await doc(), /color-scheme:dark/, "the frame is built again when the theme changes");
|
||||
assert.equal(await page.$eval("pre", (p) => getComputedStyle(p).display), "none");
|
||||
await page.click("[data-preview-toggle]");
|
||||
assert.notEqual(await page.$eval("pre", (p) => getComputedStyle(p).display), "none");
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("preview: from a streamed answer, only once the block is complete", async ({ browser, mock }) => {
|
||||
const page = await openPage(browser, mock);
|
||||
await say(page, "#svg draw a circle");
|
||||
await idle(page);
|
||||
assert.equal(await page.$$eval("[data-preview-toggle]", (e) => e.length), 1);
|
||||
await page.click("[data-preview-toggle]");
|
||||
const w = await (await page.$("iframe")).evaluate((f) => f.getBoundingClientRect().width);
|
||||
assert.ok(w > 100, "the frame has room");
|
||||
await page.close();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ math (#2972, #2974)
|
||||
test("math: off by default, drawn when switched on, source kept", async ({ browser, mock }) => {
|
||||
const page = await openPage(browser, mock);
|
||||
await say(page, "#math please");
|
||||
await idle(page);
|
||||
assert.equal(await page.$$eval(".katex", (e) => e.length), 0, "off: the model's text as written");
|
||||
await page.evaluate(() => { settings.math = true; renderChat(); });
|
||||
await page.waitForSelector(".katex");
|
||||
assert.equal(await page.$$eval(".katex", (e) => e.length), 3, "the inline, the display and the blocked-command formula");
|
||||
const requestText = await page.evaluate(() => JSON.stringify(apiMessages()));
|
||||
assert.ok(requestText.includes("\\\\pi r^2") && requestText.includes("\\\\frac{n(n+1)}{2}"), "the request keeps the source");
|
||||
assert.equal(await page.$$eval("#chat a[href], #chat .chat-math img", (e) => e.length), 0, "no link or picture from a formula");
|
||||
assert.deepEqual(exfil(mock), []);
|
||||
assert.equal(await page.evaluate(() => messages.at(-1).text.includes("$\\pi r^2$")), true);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
const mock = await startMock();
|
||||
const browser = await launch();
|
||||
await run(async () => ({ browser, mock, close: async () => { await browser.close(); await mock.stop(); } }));
|
||||
@@ -0,0 +1,77 @@
|
||||
// Shared bits for the browser tests and the screenshot script of the served web page (serve/web): a headless Chromium,
|
||||
// a page against the mock server, small helpers. puppeteer-core is not a dependency of the repo: install it anywhere and
|
||||
// point WEBTEST_MODULES at that folder (the one that holds node_modules); CHROME_BIN is the Chrome or Edge to drive.
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
const req = createRequire(path.join(process.env.WEBTEST_MODULES || process.cwd(), "noop.js"));
|
||||
export const puppeteer = req("puppeteer-core");
|
||||
|
||||
const CANDIDATES = [process.env.CHROME_BIN, "C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser",
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"].filter(Boolean);
|
||||
export const chrome = () => {
|
||||
const found = CANDIDATES.find((p) => fs.existsSync(p));
|
||||
if (!found) throw new Error("no Chrome or Edge found; set CHROME_BIN");
|
||||
return found;
|
||||
};
|
||||
export const launch = () => puppeteer.launch({ executablePath: chrome(), headless: true, args: ["--no-sandbox", "--disable-gpu"] });
|
||||
|
||||
// a page with a seeded browser store (localStorage values are JSON, as the page's own store writes them), console errors collected
|
||||
export async function openPage(browser, mock, { width = 1280, height = 800, seed = {}, query = "", dark = false, hash = "", dialogs = "accept" } = {}) {
|
||||
const context = await browser.createBrowserContext();
|
||||
const page = await context.newPage();
|
||||
page.errors = [];
|
||||
page.on("pageerror", (e) => page.errors.push("pageerror: " + e.message));
|
||||
page.on("console", (m) => { if (m.type() === "error") page.errors.push("console: " + m.text()); });
|
||||
page.on("dialog", async (d) => { page.lastDialog = d.message(); if (dialogs === "accept") await d.accept(); else await d.dismiss(); });
|
||||
await page.setViewport({ width, height });
|
||||
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: dark ? "dark" : "light" }]);
|
||||
await page.evaluateOnNewDocument((s) => {
|
||||
if (window !== window.top) return; // not in the preview frames: they have no storage
|
||||
if (sessionStorage.getItem("seeded")) return;
|
||||
sessionStorage.setItem("seeded", "1");
|
||||
for (const [k, v] of Object.entries(s)) localStorage.setItem("strata." + k, JSON.stringify(v));
|
||||
}, seed);
|
||||
await page.goto(mock.url + "/" + query + hash, { waitUntil: "networkidle0" });
|
||||
return page;
|
||||
}
|
||||
|
||||
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
// the page's own `busy` is a top-level let of app.js: reachable by name from page.evaluate
|
||||
export const idle = (page, timeout = 15000) => page.waitForFunction(() => busy === null, { timeout });
|
||||
export async function say(page, text) {
|
||||
await page.click("#input");
|
||||
await page.type("#input", text);
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
export const assistantText = (page, i = -1) => page.evaluate((n) => { const a = messages.filter((m) => m.role === "assistant"); return a.at(n).text; }, i);
|
||||
export const shot = async (page, dir, name, opts = {}) => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, name + ".png");
|
||||
await page.screenshot({ path: file, ...opts });
|
||||
return file;
|
||||
};
|
||||
|
||||
// tiny test runner: node file.mjs [name-substring]
|
||||
export function runner(title) {
|
||||
const tests = [];
|
||||
const only = process.argv[2];
|
||||
return {
|
||||
test: (name, fn) => tests.push([name, fn]),
|
||||
async run(setup) {
|
||||
const ctx = await setup();
|
||||
let failed = 0;
|
||||
for (const [name, fn] of tests) {
|
||||
if (only && !name.includes(only)) continue;
|
||||
const t = Date.now();
|
||||
try { await fn(ctx); console.log(`ok ${name} (${Date.now() - t} ms)`); }
|
||||
catch (e) { failed++; console.log(`FAIL ${name}\n ${String(e.stack || e).split("\n").slice(0, 6).join("\n ")}`); }
|
||||
}
|
||||
await ctx.close();
|
||||
console.log(failed ? `${title}: ${failed} failed` : `${title}: all passed`);
|
||||
process.exitCode = failed ? 1 : 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// A stand-in for the Strata server for tests of the served web page (serve/web): it serves the page's own files and
|
||||
// answers the few calls the page makes with scripted text. No model, no GPU, no Python. Used by browser_test.mjs and shots.mjs.
|
||||
//
|
||||
// import { startMock } from "./mock.mjs";
|
||||
// const mock = await startMock(); // { url, state, stop() }
|
||||
//
|
||||
// What the page asks and what it gets: GET /health /metrics /mcp /settings /config, POST /load /unload,
|
||||
// POST /v1/chat/completions (streamed). The reply depends on a word in the last user message:
|
||||
// #math inline and display formulas, a price #svg / #html a fenced svg / html block #think reasoning first
|
||||
// #long a slow answer (about 6 s) to interrupt #hold never ends until the page stops it #tool an MCP tool call, then text
|
||||
// #error a 500 anything else: "Mock answer N."
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const WEB = path.resolve(here, "..", "..", "serve", "web");
|
||||
const TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8",
|
||||
".svg": "image/svg+xml", ".json": "application/json; charset=utf-8", ".woff2": "font/woff2" };
|
||||
|
||||
// the one header the real server puts on the page (serve/server.py, PAGE_CSP): read from the source so the two cannot drift
|
||||
const PAGE_CSP = (/^PAGE_CSP = "(.*)"$/m.exec(fs.readFileSync(path.join(here, "..", "..", "serve", "server.py"), "utf8")) || [])[1] || "";
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const lastUser = (messages) => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role !== "user") continue;
|
||||
const c = messages[i].content;
|
||||
return typeof c === "string" ? c : (c.find((p) => p.type === "text") || {}).text || "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const MATH = "The area is $\\pi r^2$ and the sum is\n\n$$\\sum_{k=1}^{n} k = \\frac{n(n+1)}{2}$$\n\nIt costs $5 or $10, and `$x$` is code.\n\n" +
|
||||
"A bad one: $\\frac{1}{$ and a link-ish \\href{javascript:alert(1)}{click}: $\\href{javascript:alert(1)}{click}$.";
|
||||
const SVG = "Here is a circle:\n\n```svg\n<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\" width=\"120\" height=\"120\">" +
|
||||
"<circle cx=\"50\" cy=\"50\" r=\"40\" fill=\"#10b981\"/><text x=\"50\" y=\"56\" text-anchor=\"middle\" fill=\"white\" font-size=\"18\">SVG</text></svg>\n```\n";
|
||||
const HTML = "A small page:\n\n```html\n<!doctype html><html><body style=\"font-family:sans-serif\"><h2>Hello</h2>" +
|
||||
"<p id=\"p\">Static text.</p><script>document.getElementById('p').textContent='Script ran.'</script></body></html>\n```\n";
|
||||
|
||||
export async function startMock({ port = 0, root = WEB, loaded = true, csp = true } = {}) {
|
||||
const state = { requests: [], aborted: 0, completed: 0, loaded, calls: [], answers: 0 };
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, "http://x");
|
||||
const p = url.pathname.replace(/\/+$/, "");
|
||||
state.calls.push(`${req.method} ${p}`);
|
||||
const json = (code, body) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); };
|
||||
if (req.method === "GET") {
|
||||
if (p === "") return file(res, path.join(root, "index.html"), undefined, csp && PAGE_CSP ? { "Content-Security-Policy": PAGE_CSP } : {});
|
||||
if (p.startsWith("/web/")) return file(res, path.join(root, p.slice(5)), root);
|
||||
if (p.startsWith("/fonts/")) return file(res, path.join(root, "fonts", p.slice(7)), root);
|
||||
if (p === "/health") return json(200, { status: "ok", max_context: 32768, model: "Mock-Model", images: true, image_generation: false, loaded: state.loaded, service: "strata" });
|
||||
if (p === "/metrics") return json(200, metrics(state));
|
||||
if (p === "/mcp") return json(200, { servers: [], tools: 0 });
|
||||
if (p === "/settings") return json(200, { shared: false, defaults: null });
|
||||
return json(404, { error: { message: "not found" } });
|
||||
}
|
||||
let body = "";
|
||||
for await (const part of req) body += part;
|
||||
if (p === "/unload") { await sleep(150); state.loaded = false; return json(200, { status: "unloaded" }); }
|
||||
if (p === "/load") { await sleep(300); state.loaded = true; return json(200, { status: "loaded" }); }
|
||||
if (p === "/settings") return json(200, { shared: false });
|
||||
if (p !== "/v1/chat/completions") return json(404, { error: { message: "not found" } });
|
||||
const b = JSON.parse(body || "{}");
|
||||
state.requests.push(b);
|
||||
const text = lastUser(b.messages || []);
|
||||
if (text.includes("#error")) return json(500, { error: { message: "The mock engine failed on purpose." } });
|
||||
state.loaded = true;
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
|
||||
let closed = false;
|
||||
res.on("close", () => { if (!res.writableEnded) { closed = true; state.aborted++; } });
|
||||
const send = (o) => { if (!closed) res.write("data: " + JSON.stringify(o) + "\n\n"); };
|
||||
const say = async (s, ms = 0, step = 6) => {
|
||||
for (let i = 0; i < s.length && !closed; i += step) { send({ choices: [{ delta: { content: s.slice(i, i + step) } }] }); if (ms) await sleep(ms); }
|
||||
};
|
||||
state.answers++;
|
||||
if (text.includes("#think")) {
|
||||
for (const w of ["Let me think ", "about this ", "for a moment."]) { send({ choices: [{ delta: { reasoning_content: w } }] }); await sleep(30); }
|
||||
await say("Thought through. The answer is 42.");
|
||||
} else if (text.includes("#math")) await say(MATH, 4);
|
||||
else if (text.includes("#svg")) await say(SVG, 2);
|
||||
else if (text.includes("#html")) await say(HTML, 2);
|
||||
else if (text.includes("#hold")) { await say("Working on it", 20); while (!closed) await sleep(50); return; }
|
||||
else if (text.includes("#long")) { await say("This is a long answer that goes on for a while so that it can be interrupted. ".repeat(3), 40, 4); }
|
||||
else if (text.includes("#tool")) {
|
||||
await say("Let me look that up. ", 5);
|
||||
const id = "call-1";
|
||||
send({ strata_mcp: { event: "call", id, name: "fs__read", server: "fs", tool: "read", arguments: { path: "notes.txt" }, round: 0 } });
|
||||
await sleep(900);
|
||||
send({ strata_mcp: { event: "result", id, text: "The file says hello.", ok: true, chars: 20, ms: 900 } });
|
||||
await say("The file says hello.", 5);
|
||||
} else await say(`Mock answer ${state.answers}.`, 5);
|
||||
if (!closed) {
|
||||
send({ choices: [{ delta: {}, finish_reason: "stop" }], usage: { completion_tokens: 12 } });
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
state.completed++;
|
||||
}
|
||||
});
|
||||
await new Promise((r) => server.listen(port, "127.0.0.1", r));
|
||||
return { url: `http://127.0.0.1:${server.address().port}`, state, stop: () => new Promise((r) => { server.closeAllConnections?.(); server.close(r); }) };
|
||||
}
|
||||
|
||||
function file(res, f, root, extra = {}) {
|
||||
const resolved = path.resolve(f);
|
||||
if (root && !resolved.startsWith(path.resolve(root) + path.sep)) { res.writeHead(404); return res.end(); }
|
||||
fs.readFile(resolved, (err, data) => {
|
||||
if (err) { res.writeHead(404, { "Content-Type": "application/json" }); return res.end('{"error":{"message":"not found"}}'); }
|
||||
res.writeHead(200, { "Content-Type": TYPES[path.extname(resolved)] || "application/octet-stream", "Cache-Control": "no-cache", ...extra });
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
function metrics(state) {
|
||||
const spark = Array.from({ length: 30 }, (_, i) => 20 + 10 * Math.sin(i / 3));
|
||||
return {
|
||||
live: { state: state.loaded ? "idle" : "unloaded", queued: 0 },
|
||||
engine: { model: "Mock-Model", version: "0.0", max_context: 32768, expert_slots: 1200, expert_cache_mib: 9000 },
|
||||
hardware: { gpu_util: 35, gpu_mem_used: 9e9, gpu_mem_total: 12e9, gpu_temp: 61, gpu_power: 140, cpu: 22, ram_total: 64e9, ram_used: 30e9, disk_read_mb: 5 },
|
||||
hardware_static: { gpu_name: "Mock GPU", cpu_name: "Mock CPU", threads: 16, cores: 8, psutil: true },
|
||||
history: { tok_s: spark, gpu_util: spark, gpu_mem_used: spark, gpu_temp: spark, gpu_power: spark, cpu: spark, disk_read_mb: spark, gpu_pcie_rx_mb: spark, prefill_tok_s_mean: spark },
|
||||
requests: [], totals: null, requests_kept: 0, conversation_cache: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const mock = await startMock({ port: Number(process.argv[2] || 0) });
|
||||
console.log("mock server on " + mock.url);
|
||||
}
|
||||
Reference in new issue
Block a user