/* Pure front-end logic, tested without a browser. * * The panel scripts have no build step and no module system, which is the point * — and it also means the functions in them have nowhere to be tested from. Most of the * file is DOM work that only makes sense in a page, but the decisions are not: * what needs you first, and what counts as unread, are sorts over plain data, * and they are exactly the kind of thing that breaks quietly. * * So: read the file, cut out the region under test, eval it against stubs for * the handful of things it reads. No jsdom, no bundler, no package.json — the * same bargain the rest of the project makes. * * node tools/frontend_check.js */ const fs = require("fs"); const path = require("path"); const ROOT = path.resolve(__dirname, ".."); const SRC = ["file-preview.js", "providers.js", "panes.js", "review.js", "app.js"] .map((name) => fs.readFileSync(path.join(ROOT, "clique/web", name), "utf8")) .join("\n"); let passed = 0; let failed = 0; function check(label, cond, detail) { if (cond) { passed++; console.log(` ok ${label}`); } else { failed++; console.log(` FAIL ${label} ${detail === undefined ? "" : detail}`); } } /* A named region of the panel scripts, so a test names the code it covers rather than a * line number that moves the next time anything is inserted above it. */ function region(from, to) { const start = SRC.indexOf(from); const end = SRC.indexOf(to); if (start < 0 || end < 0 || end <= start) { console.log(` FAIL could not find the region ${from} … ${to}`); failed++; return ""; } return SRC.slice(start, end); } console.log("what needs you first"); { const code = region("const NEXT_RANK", "/* Long press, because touch"); let state; let activeId = null; const document = { hasFocus: () => true }; const workState = (s) => { if (!s.alive) return "stopped"; if (s.signal === "error") return "error"; if (s.signal === "waiting") return "asking"; if (s.busy) return "working"; return "idle"; }; const unread = (s) => Boolean(s.alive && s.id !== activeId && s.activity > (s.last_seen || 0)); const ago = () => "11m"; void document; void workState; void unread; void ago; // `eval` of a block that declares functions puts them in this scope, so the // region is evaluated and then referenced — declaring a holder first would // collide with the declaration inside the code being tested. eval(code); const now = Math.floor(Date.now() / 1000); state = { sessions: [ { id: "a", name: "fresh-error", alive: 1, signal: "error", activity: now - 5, last_seen: now }, { id: "b", name: "old-waiting", alive: 1, signal: "waiting", activity: now - 660, last_seen: now }, { id: "c", name: "new-waiting", alive: 1, signal: "waiting", activity: now - 10, last_seen: now }, { id: "d", name: "unread-only", alive: 1, signal: "", activity: now - 300, last_seen: now - 900 }, { id: "e", name: "busy", alive: 1, signal: "", busy: 1, activity: now, last_seen: 0 }, { id: "f", name: "idle-and-read", alive: 1, signal: "", activity: now - 90, last_seen: now }, { id: "g", name: "dead", alive: 0, signal: "error", activity: now - 30, last_seen: now }, { id: "h", name: "archived", alive: 1, archived: 1, signal: "waiting", activity: now - 60, last_seen: now }, ]}; const got = nextUp(); const named = (n) => got.findIndex((r) => r.s.name === n); const has = (n) => named(n) >= 0; // Stopped badly outranks blocked, even when the blocked one has been blocked // for eleven minutes: an error is not going to resolve itself. check("an error outranks a much older waiting", got[0] && got[0].s.name === "fresh-error", got.map((r) => r.s.name).join(",")); check("among equals, the one blocked longest comes first", named("old-waiting") < named("new-waiting")); check("unread ranks below both", named("unread-only") === got.length - 1); // The list has to be short to be worth reading, so everything that does not // need a person is absent rather than ranked low. check("a working session never appears", !has("busy")); check("nor one that is idle and already read", !has("idle-and-read")); check("nor a dead one", !has("dead")); check("nor an archived one", !has("archived")); activeId = "b"; check("nor the one you are already looking at", !nextUp().some((r) => r.s.id === "b")); } console.log("directories the panel already knows"); { const code = region("const CWD_SUGGESTIONS", "function openModal()"); let state; let resumable; const shortPath = (p) => p; void shortPath; eval(code); const now = Math.floor(Date.now() / 1000); state = { sessions: [ { cwd: "/srv/old", alive: 0, last_seen: now - 10 }, { cwd: "/srv/live", alive: 1, last_seen: now - 5000 }, { cwd: "/srv/dupe", alive: 0, last_seen: now - 900 }, { cwd: "/srv/dupe", alive: 1, last_seen: now - 9000 }, { cwd: "/srv/gone", alive: 0, last_seen: now, archived: 1 }, { cwd: "", alive: 1, last_seen: now }, ]}; resumable = [{ cwd: "/srv/history", updated: now }, { cwd: "/srv/live", updated: now }]; const dirs = knownDirs().map((d) => d.cwd); const kindOf = (cwd) => (knownDirs().find((d) => d.cwd === cwd) || {}).kind; // A live session is where you are working, so it beats a more recent look at // something that has since stopped. check("a running directory outranks a recently-viewed dead one", dirs.indexOf("/srv/live") < dirs.indexOf("/srv/old"), dirs.join(",")); check("a directory appears once however many sessions are in it", dirs.filter((d) => d === "/srv/dupe").length === 1, dirs.join(",")); check("and it is ranked by its best session, not its worst", dirs.indexOf("/srv/dupe") < dirs.indexOf("/srv/old"), dirs.join(",")); check("past conversations are offered too", dirs.includes("/srv/history")); check("but rank below anything with a session", dirs.indexOf("/srv/history") > 0, dirs.join(",")); check("archived is not somewhere you are working", !dirs.includes("/srv/gone")); check("and an empty path is never offered", !dirs.includes("")); // The groups the picker draws. A directory with a live session is "running // now" whatever else it also is, which is why the kind travels with the // winning score rather than being decided afterwards. check("a live session is grouped as running", kindOf("/srv/live") === "running"); check("a stopped one as recent", kindOf("/srv/old") === "recent"); check("and one only history knows about as history", kindOf("/srv/history") === "history"); check("a directory with both is running, not recent", kindOf("/srv/dupe") === "running"); } console.log("which app is holding this page"); { const code = region("function shellLabel", "function renderVersion"); const { shellLabel } = new Function(code + "; return { shellLabel };")(); check("a desktop shell is named with its version", shellLabel({ kind: "desktop", version: "0.3.12" }) === "desktop 0.3.12"); check("a browser has no shell, so nothing is drawn", shellLabel(undefined) === ""); check("and neither does a page that never set it", shellLabel(null) === ""); check("a shell with no version still says what it is", shellLabel({ kind: "desktop" }) === "desktop"); check("whitespace is not a version", shellLabel({ kind: "desktop", version: " " }) === "desktop"); check("a shell with no kind says nothing, because the version alone would " + "read as the panel's", shellLabel({ version: "0.3.12" }) === ""); check("a non-string version is ignored rather than printed", shellLabel({ kind: "desktop", version: 3 }) === "desktop"); check("something that is not an object is not a shell", shellLabel("desktop 0.3.12") === ""); } console.log("paths a pane printed"); { const code = region("const LINK_RE", "function openLink"); // const in eval is trapped in the eval; a Function returns the bindings. const { PATH_RE, trimPath, pathFromText } = new Function( code + "; return { PATH_RE, trimPath, pathFromText };")(); check("strips a compiler suffix", trimPath("src/app.js:42:7") === "src/app.js"); check("strips a trailing period", trimPath("docs/foo.md.") === "docs/foo.md"); check("a selected relative path is a path", pathFromText("docs/foo.md") === "docs/foo.md"); check("a selected absolute path is a path", pathFromText("/tmp/foo.md") === "/tmp/foo.md"); check("a selected sentence is not a path", pathFromText("hello world") === ""); const paths = (text) => { const out = []; PATH_RE.lastIndex = 0; let match; while ((match = PATH_RE.exec(text)) !== null) out.push(trimPath(match[1])); return out; }; check("an absolute path", paths("see /tmp/foo.md")[0] === "/tmp/foo.md"); check("a home path", paths("open ~/src/app.js")[0] === "~/src/app.js"); check("a relative path with a slash and an extension", paths("wrote docs/foo.md")[0] === "docs/foo.md"); check("dot-slash", paths("./src/app.js")[0] === "./src/app.js"); check("a bare word is not a path", paths("hello world").length === 0); check("and a URL is not a path", paths("https://example.com/docs/foo.md").length === 0); } console.log("a login link that wrapped is still one link"); { const code = region("function paneRowsText", "function openLink"); const { paneRowsText, paneUrlSegments, urlNeedsLocalCallback, paneHostIsRemote, tidyCopiedLink, } = new Function(code + "; return { paneRowsText, paneUrlSegments, urlNeedsLocalCallback, paneHostIsRemote, tidyCopiedLink };")(); const parts = [ { y: 8, text: "https://auth.openai.com/oauth/authorize?foo=1&code_challeng" }, { y: 9, text: "e_method=S256&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback" }, ]; const joined = paneRowsText(parts); check("the two halves join without a gap", joined.indexOf("code_challenge_method") > 0); const segs = paneUrlSegments(parts, 0, joined.length); check("each row keeps its own slice", segs.length === 2 && segs[0].y === 8 && segs[1].y === 9, segs); check("a localhost callback is a remote-box login", urlNeedsLocalCallback(joined)); check("a device-code page is not", !urlNeedsLocalCallback("https://auth.openai.com/codex/device")); check("opening this panel on the box itself is local", !paneHostIsRemote("127.0.0.1") && !paneHostIsRemote("localhost")); check("opening it over the tailnet is remote", paneHostIsRemote("box.tail1234.ts.net")); check("copying a wrapped URL drops the line break", tidyCopiedLink("https://auth.openai.com/codex/device\n/extra") === "https://auth.openai.com/codex/device/extra"); } console.log("a path that wrapped is still one link"); { const code = region("function paneRowsText", "function openLink"); const { panePathLinks } = new Function(code + "; return { panePathLinks };")(); const full = "/root/platform/clique/.claude-images/paste-1789750882398-fc77b1bf.png"; // 69 characters, so a narrow pane or a phone splits it. Each half on its own // matches nothing, which is why long paths were the ones that never worked. const parts = [ { y: 12, text: "wrote /root/platform/clique/.claude-imag" }, { y: 13, text: "es/paste-1789750882398-fc77b1bf.png" }, ]; const first = panePathLinks(parts, 12); const second = panePathLinks(parts, 13); check("the first row carries a link", first.length === 1, first); check("so does the second", second.length === 1, second); check("both name the whole path, not their own half", first[0] && second[0] && first[0].path === full && second[0].path === full, [first, second]); check("the first row's slice starts after the word before it", first[0] && first[0].x0 === 7 && first[0].x1 === 40, first); check("the second row's slice is the remainder", second[0] && second[0].x0 === 1 && second[0].x1 === 35, second); const one = panePathLinks([{ y: 3, text: "see /tmp/foo.md here" }], 3); check("a path that fits on one row still works", one.length === 1 && one[0].path === "/tmp/foo.md", one); check("and a URL is still not a path", panePathLinks([{ y: 4, text: "https://example.com/docs/foo.md" }], 4).length === 0); } console.log("a host with no https:// in front of it"); { const code = region("function paneRowsText", "function openLink"); const { paneBareLinks, panePathLinks } = new Function( code + "; return { paneBareLinks, panePathLinks };")(); const bare = (text, y) => paneBareLinks([{ y: y || 1, text }], y || 1); check("our own output is a link", bare("repo at fdroid.useclique.dev/repo (ver")[0] && bare("repo at fdroid.useclique.dev/repo (ver")[0].url === "https://fdroid.useclique.dev/repo", bare("repo at fdroid.useclique.dev/repo (ver")); check("the scheme is added, not assumed present", bare("see github.com/thejdubb02/clique")[0].url === "https://github.com/thejdubb02/clique"); check("a trailing period belongs to the sentence", bare("go to useclique.dev/docs.")[0].url === "https://useclique.dev/docs"); check("and a wrapping bracket to the prose", bare("(useclique.dev/docs)")[0].url === "https://useclique.dev/docs"); // The slash is the whole defence against filenames: .md, .sh, .pl, .zip and // .mov are all real TLDs, so a bare dotted word can never be enough. check("README.md is not a domain", bare("edit README.md now").length === 0); check("neither is build.sh", bare("run build.sh now").length === 0); check("nor a Perl script", bare("perl gen.pl now").length === 0); check("a dotted directory in a path is not a host", bare("wrote /root/.claude/projects/x").length === 0); check("an email address is not a host", bare("mail user@host.com/x").length === 0); check("the tail of a real URL is not matched a second time", bare("https://example.com/docs").length === 0); check("a version number is not a TLD", bare("in v1.2/file.txt").length === 0); const parts = [ { y: 5, text: "install from fdroid.usecliq" }, { y: 6, text: "ue.dev/repo today" }, ]; check("a host that wrapped is still one link", paneBareLinks(parts, 5).length === 1 && paneBareLinks(parts, 6).length === 1 && paneBareLinks(parts, 5)[0].url === "https://fdroid.useclique.dev/repo", [paneBareLinks(parts, 5), paneBareLinks(parts, 6)]); // The two passes must not both claim the same text, or the pane gets two // overlapping links on one run of characters. check("a host with a file on the end is a link, not a path", panePathLinks([{ y: 7, text: "see fdroid.useclique.dev/repo/index.xml" }], 7).length === 0); check("and a real relative path is still a path", panePathLinks([{ y: 8, text: "wrote docs/foo.md" }], 8).length === 1); } console.log("a suggested shell command, stitched back into one line"); { const code = region("const SHELL_SUGGEST_RE", "const PATH_RE"); const { SHELL_SUGGEST_RE } = new Function(code + "; return { SHELL_SUGGEST_RE };")(); const run = (text) => { const m = SHELL_SUGGEST_RE.exec(text); SHELL_SUGGEST_RE.lastIndex = 0; return m; }; const hit = run("! cd /root/ventures/x && git push"); check("the command is everything after '! '", hit && hit[1] === "cd /root/ventures/x && git push", hit); check("ordinary output starting with '!' but no space is not a suggestion", !run("!important: read this")); check("a line with no leading '!' is not a suggestion", !run("cd /root/ventures/x")); } console.log("things that sit on top of other things"); { // A preview that outranks a menu is a menu nobody can use — and it fails // silently, because the click still lands on the control you cannot see. // The stack is small enough to assert outright. const css = fs.readFileSync(path.join(ROOT, "clique/web/app.css"), "utf8"); const layerOf = (selector) => { const at = css.indexOf(selector + " {"); if (at < 0) return null; const found = /z-index:\s*(\d+)/.exec(css.slice(at, css.indexOf("}", at))); return found ? Number(found[1]) : null; }; const menu = layerOf("#menu"); const palette = layerOf("#palette"); const follow = layerOf("#follow"); const file = layerOf("#file"); check("the palette sits above the context menu", palette > menu, `${palette} vs ${menu}`); check("and the menu above the pane's own controls", menu > follow, `${menu} vs ${follow}`); check("the file sheet sits above the palette", file > palette, `${file} vs ${palette}`); } console.log("tinted greys keep their contrast"); { // The point of tinting the 256-colour greyscale ramp is that a monochrome // theme owns the shades a CLI paints with. The point of doing it by hue // rather than by interpolation is that an application picking 233 chose how // far from the background it wanted to be — that choice is not ours to move, // and moving it is what makes text stop being readable on top. global.window = {}; require(path.join(ROOT, "clique/web/themes.js")); const CUBE = [0, 95, 135, 175, 215, 255]; eval(region("function hexRgb", "const _termThemes").replace(/^const CUBE.*$/m, "")); const theme = global.window.CLIQUE_THEMES.trinity; const ramp = extendedAnsi(theme).slice(216); const lum = (hex) => { const p = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); return 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; }; check("the ramp still has 24 steps", ramp.length === 24, ramp.length); let held = true; let rising = true; for (let i = 0; i < 24; i++) { const v = 8 + 10 * i; const standard = "#" + [v, v, v].map((c) => c.toString(16).padStart(2, "0")).join(""); if (Math.abs(lum(standard) - lum(ramp[i])) > 0.07) held = false; if (i && lum(ramp[i]) <= lum(ramp[i - 1])) rising = false; } check("every step keeps the lightness xterm would have given it", held, ramp.slice(0, 3)); check("and the ramp still climbs from dark to light", rising); // The cube is not ours. An application asking for colour 82 wants that // green, not a theme's idea of one. const cube = extendedAnsi(theme).slice(0, 216); check("the 6x6x6 cube is left exactly as xterm defines it", cube[0] === "#000000" && cube[215] === "#ffffff", [cube[0], cube[215]]); const plain = global.window.CLIQUE_THEMES[""]; check("a theme that did not ask keeps the standard ramp", !plain.tint_greys); } console.log("copy from a pane that is eating the mouse"); { const clickCode = region("function paneAtLiveScreen", "const PANE_DRAG_PX"); const { paneAtLiveScreen, paneSgrClick, paneGridCell, } = new Function(clickCode + "; return { paneAtLiveScreen, paneSgrClick, paneGridCell };")(); check("a click at the live screen is a click", paneAtLiveScreen({ buffer: { active: { viewportY: 10, baseY: 10 } } })); check("a click on older scrollback is not", !paneAtLiveScreen({ buffer: { active: { viewportY: 2, baseY: 10 } } })); check("the click report is SGR press then release", paneSgrClick(12, 4) === "\x1b[<0;12;4M\x1b[<0;12;4m"); const cell = paneGridCell( { element: { getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 40 }) }, cols: 10, rows: 4 }, 50, 10); check("a click in the middle maps to a 1-based cell", cell.col === 6 && cell.row === 2, cell); const code = region("const PANE_DRAG_PX", "function wirePaneClipboard"); const { paneForceSelectMods, paneDragFarEnough, paneShouldStealMouse, paneEdgeScrollDir, } = new Function(code + "; return { paneForceSelectMods, paneDragFarEnough, paneShouldStealMouse, paneEdgeScrollDir };")(); const click = { button: 0, ctrlKey: false, metaKey: false, shiftKey: false, altKey: false }; check("a Mac uses Option, not Shift, to force a select", paneForceSelectMods("MacIntel").altKey && !paneForceSelectMods("MacIntel").shiftKey); check("everything else uses Shift", paneForceSelectMods("Linux x86_64").shiftKey && !paneForceSelectMods("Linux x86_64").altKey); check("five pixels is a drag", paneDragFarEnough(0, 0, 5, 0)); check("four is not", !paneDragFarEnough(0, 0, 3, 3)); check("a drag is stolen when the CLI is eating the mouse", paneShouldStealMouse(click, true, true)); check("a click with a modifier is left alone", !paneShouldStealMouse({ ...click, shiftKey: true }, true, true)); check("and so is Ctrl-click, which drops a path", !paneShouldStealMouse({ ...click, ctrlKey: true }, true, true)); check("past the top edge scrolls up", paneEdgeScrollDir(5, 10, 110, 24) === -1); check("past the bottom edge scrolls down", paneEdgeScrollDir(95, 10, 110, 24) === 1); check("mid-pane does not scroll", paneEdgeScrollDir(50, 10, 110, 24) === 0); check("exactly on the threshold does not scroll yet", paneEdgeScrollDir(34, 10, 110, 24) === 0 && paneEdgeScrollDir(86, 10, 110, 24) === 0); check("a phone is not stolen from — the Copy chip is the way", !paneShouldStealMouse(click, true, false)); check("and a shell with no mouse tracking selects on its own", !paneShouldStealMouse(click, false, true)); check("a touch-synthesised mouse is left alone", !paneShouldStealMouse({ ...click, sourceCapabilities: { firesTouchEvents: true } }, true, true)); } console.log("the terminal wears the theme"); { const code = region("function mix(", "function currentTheme"); const { mix, termTokens, luminanceOf } = new Function( "function luminance(hex){const v=hex.replace('#','');" + "const f=v.length===3?[...v].map(c=>c+c).join(''):v;" + "const ch=[0,2,4].map(i=>parseInt(f.slice(i,i+2),16)/255)" + ".map(c=>c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4));" + "return 0.2126*ch[0]+0.7152*ch[1]+0.0722*ch[2];}" + "const _termThemes = new WeakMap();" + code + "; return { mix, termTokens, luminanceOf: luminance };")(); check("halfway between black and white is grey", mix("#000000", "#ffffff", 0.5) === "#808080"); check("no distance is the colour you started from", mix("#1e1e1e", "#ffffff", 0) === "#1e1e1e"); check("short hex is read the same as long", mix("#fff", "#000000", 0) === "#ffffff"); const dark = { term: { background: "#1e1e1e", selectionBackground: "#264f78" } }; const light = { term: { background: "#ffffff", selectionBackground: "#b6d7ff" } }; check("the character under a block cursor gets the theme's background", termTokens(dark).cursorAccent === "#1e1e1e"); check("a dark selection is written on in light text", termTokens(dark).selectionForeground === "#f5f5f5"); check("and a pale one in dark text", termTokens(light).selectionForeground === "#101010"); check("an unfocused selection sits halfway back to the background", termTokens(dark).selectionInactiveBackground === mix("#264f78", "#1e1e1e", 0.5)); check("a theme that states these keeps them", termTokens({ term: { background: "#000", selectionBackground: "#333", selectionForeground: "#0f0" } }).selectionForeground === "#0f0"); check("and a theme with no selection colour is left alone", termTokens({ term: { background: "#000" } }).selectionForeground === undefined); /* Every shipped theme, not just a made-up one. The point of deriving these * is that a new theme cannot arrive with a selection you cannot read on, so * the guarantee is worth asserting against the real list. */ const themeSrc = fs.readFileSync( path.join(__dirname, "..", "clique", "web", "themes.js"), "utf8"); const themes = new Function("const window = {}; " + themeSrc + "; return window.CLIQUE_THEMES;")(); const names = Object.keys(themes); check("there are themes to check", names.length >= 4, names.length); const contrast = (a, b) => { const [hi, lo] = [luminanceOf(a), luminanceOf(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); }; const thin = names.filter((name) => { const t = termTokens(themes[name]); if (!t.selectionBackground) return false; return contrast(t.selectionForeground, t.selectionBackground) < 4.5; }); check("every theme's selection can be read on", thin.length === 0, thin); const noCursor = names.filter((name) => !termTokens(themes[name]).cursorAccent); check("and every theme says what sits under its cursor", noCursor.length === 0, noCursor); } console.log("zoom a boxed pane instead of wrapping it"); { const code = region("const PANE_ZOOM_MIN", "function paneForceSelectMods"); const { paneWidthScale, paneShouldZoom, paneQueueOut, PANE_ZOOM_MIN, } = new Function(code + "; return { paneWidthScale, paneShouldZoom, paneQueueOut, PANE_ZOOM_MIN };")(); check("room to spare is no zoom", paneWidthScale(800, 80, 8) === 1); check("half the width zooms to half", Math.abs(paneWidthScale(320, 80, 8) - 0.5) < 0.01); check("a boxed CLI at half size zooms", paneShouldZoom(true, 0.5)); check("a shell never zooms", !paneShouldZoom(false, 0.5)); check("a phone-sized shrink still resizes for real", !paneShouldZoom(true, PANE_ZOOM_MIN - 0.01)); const held = paneQueueOut([], "hi", 8); check("keys typed while reconnecting are kept", held.join("") === "hi"); const capped = paneQueueOut(["aaaaaaaa"], "bbbb", 8); check("and an 8k flood cannot pile up forever", capped.join("").length <= 8 && capped.join("").endsWith("bbbb")); } console.log("what's new"); { const code = region("function baseVersion", "let loadedVersion"); const { baseVersion, changelogHasNews, CLOG_SHOW, } = new Function(code + "; return { baseVersion, changelogHasNews, CLOG_SHOW };")(); check("the sheet holds five releases", CLOG_SHOW === 5); check("a build suffix is not a new release", baseVersion("0.50.24+abc") === "0.50.24"); check("an unread upgrade is news", changelogHasNews("0.50.24", "0.50.23")); check("the same version is not", !changelogHasNews("0.50.24", "0.50.24")); check("and neither is a first look, before anything is stamped", !changelogHasNews("0.50.24", "")); } console.log("pairing QR sign-in URL"); { const code = region( "function pairSignInTarget(base, code, origin)", "/* ------------------------------------------------------------------- modal */" ); const { pairSignInTarget } = new Function( code + "; return { pairSignInTarget };" )(); check( "a public URL plus a code is the sign-in link", pairSignInTarget("https://box.tail1234.ts.net/clique", "K7PM-3XQF", "") === "https://box.tail1234.ts.net/clique/?pair=K7PM-3XQF" ); check( "a trailing slash is not doubled", pairSignInTarget("https://box.example.ts.net/clique/", "ABCD-EFGH", "") === "https://box.example.ts.net/clique/?pair=ABCD-EFGH" ); check( "localhost is not a QR", pairSignInTarget("http://localhost:3200", "ABCD-EFGH", "") === "" ); check( "127.0.0.1 is not a QR", pairSignInTarget("http://127.0.0.1:3200", "ABCD-EFGH", "https://ok.example") === "" ); check( "IPv6 loopback is not a QR either", pairSignInTarget("http://[::1]:3200", "ABCD-EFGH", "") === "" ); check( "an empty setting falls back to the page origin", pairSignInTarget("", "ABCD-EFGH", "https://box.example.ts.net") === "https://box.example.ts.net/?pair=ABCD-EFGH" ); check( "and a loopback origin with no setting is still not a QR", pairSignInTarget("", "ABCD-EFGH", "http://127.0.0.1:3200") === "" ); check( "a hostname that merely contains 127.0.0.1 is fine", pairSignInTarget("https://127.0.0.1.example.com", "ABCD-EFGH", "") === "https://127.0.0.1.example.com/?pair=ABCD-EFGH" ); check( "a scheme-less public host is accepted", pairSignInTarget("box.example.ts.net/clique", "ABCD-EFGH", "") === "https://box.example.ts.net/clique/?pair=ABCD-EFGH" ); } console.log("markdown in the file sheet"); { const code = region( "/* parseMarkdown: blocks from a string, no DOM. */", "function renderMarkdown(" ); const { parseMarkdown } = new Function(code + "; return { parseMarkdown };")(); const walk = (n, acc) => { if (!n) return acc; if (Array.isArray(n)) { n.forEach((x) => walk(x, acc)); return acc; } acc.push(n); walk(n.inlines, acc); walk(n.children, acc); walk(n.items, acc); walk(n.header, acc); walk(n.rows, acc); return acc; }; for (let n = 1; n <= 6; n++) { const b = parseMarkdown("#".repeat(n) + " Title")[0]; check("heading h" + n, b && b.type === "heading" && b.level === n, b); } const para = parseMarkdown("hello world")[0]; check("a paragraph", para && para.type === "paragraph" && para.inlines[0] && para.inlines[0].text === "hello world", para); const fenced = parseMarkdown("```js\n# not a heading\n- not a list\n```"); check("fenced contents are not markdown", fenced.length === 1 && fenced[0].type === "code" && fenced[0].text.indexOf("# not a heading") >= 0 && fenced[0].text.indexOf("- not a list") >= 0, fenced); check("and keeps the language", fenced[0] && fenced[0].lang === "js", fenced[0]); const tildes = parseMarkdown("~~~\n# still code\n~~~")[0]; check("tilde fences are code too", tildes && tildes.type === "code" && tildes.text.indexOf("# still code") >= 0, tildes); const ul = parseMarkdown("- a\n- b")[0]; check("unordered list", ul && ul.type === "list" && !ul.ordered && ul.items.length === 2, ul); const ol = parseMarkdown("1. a\n2. b")[0]; check("ordered list", ol && ol.type === "list" && ol.ordered && ol.items.length === 2, ol); const table = parseMarkdown("| a | b |\n| --- | ---: |\n| 1 | 2 |")[0]; check("a table", table && table.type === "table" && table.rows.length === 1, table); check("and its alignment row", table && table.align && table.align[1] === "right", table && table.align); const hr = parseMarkdown("---")[0]; check("a rule", hr && hr.type === "hr", hr); const quote = parseMarkdown("> quoted")[0]; check("a blockquote", quote && quote.type === "blockquote", quote); const inline = parseMarkdown( "use `x` **bold** __also__ *em* _em2_ [ok](https://ex.com) " + "[bad](javascript:alert(1)) \\*star\\*" )[0]; const nodes = walk(inline, []); check("inline code", nodes.some((n) => n.type === "code" && n.text === "x")); check("** and __ are bold", nodes.filter((n) => n.type === "strong").length === 2); check("* and _ are italic", nodes.filter((n) => n.type === "em").length === 2); check("http link survives", nodes.some((n) => n.type === "link" && n.href === "https://ex.com")); check("javascript: is not a link", !nodes.some((n) => n.type === "link" && /javascript:/i.test(n.href || "")), nodes.filter((n) => n.type === "link")); const jsOnly = parseMarkdown("[XSS](javascript:alert(1))")[0]; const jsText = walk(jsOnly, []).filter((n) => n.type === "text").map((n) => n.text).join(""); check("and it does not leave a stray parenthesis", jsText === "XSS", jsText); const esc = parseMarkdown("\\*star\\*")[0]; check("escaped asterisk is text", esc && esc.type === "paragraph" && esc.inlines.length === 1 && esc.inlines[0].type === "text" && esc.inlines[0].text === "*star*", esc); } console.log("settings queue, send errors, file save race, hidden polling"); { const toasts = []; const toast = (t, bad) => toasts.push(t); let state = { settings: {} }, calls = [], pend = []; const api = () => new Promise((res, rej) => { pend.push({ res, rej }); }); const applySettings = () => {}, reclaimSize = () => {}, renderTree = () => {}, renderGroups = () => {}, renderTabs = () => {}; eval(region("let settingsQueue", "function fmtSize") + "; globalThis.__s = saveSettings;"); const save = globalThis.__s; const tick = () => new Promise((r) => setTimeout(r, 5)); const pre = (async () => { const a = save({ a: 1 }), b = save({ b: 1 }); await tick(); check("second settings write waits for the first", pend.length === 1, pend.length); pend[0].res({ v: "first" }); await tick(); pend[1].res({ v: "second" }); await a; await b; check("settings responses apply in order", state.settings.v === "second", state.settings); pend = []; const c = save({ c: 1 }); await tick(); pend[0].rej(new Error("down")); let threw = false; try { await c; } catch { threw = true; } check("a failed settings save is shown AND rejected to the caller", threw && toasts.some((t) => /down/.test(t)), toasts); const d = save({ d: 1 }); await tick(); check("and the queue keeps working after a failure", pend.length === 2); pend[1].res({ v: "ok" }); await d; // prompt send failure keeps the draft and says so toasts.length = 0; pend = []; let sending = false, activeId = "s", repeat = 1, cleared = 0; const reviewLockedOf = () => false, hintReviewLocked = () => {}, session = () => ({ name: "n" }); const okToSend = async () => true, control = () => false, maybeAutoTitle = () => {}; const clearPrompt = () => { cleared++; }, saveDraft = () => {}, setRepeat = () => {}; eval(region("async function run(text)", "async function runShell") + "; globalThis.__run = run;"); const r = globalThis.__run("hello"); await tick(); pend[0].rej(new Error("503")); await r; check("a failed prompt send shows an error and keeps the draft", toasts.some((t) => /503/.test(t)) && cleared === 0 && sending === false, [toasts, cleared]); // file save finishing after the sheet moved on toasts.length = 0; pend = []; let fileEditing = true, fileSession = "s", fileAsked = "a.txt", shown = 0; let currentFile = { path: "a.txt", name: "a.txt", text: "old" }; const btn = { disabled: false }; const $ = (q) => (q === "#fileSave" ? btn : { value: "new", hidden: false }); const showFile = () => { shown++; }; eval(region("let fileSaveSeq", "function closeFileSheet") + "; globalThis.__sf = saveFileEdit; globalThis.__swap = () => { currentFile = { path: 'b.txt', name: 'b.txt', text: 'B' }; }; globalThis.__cf = () => currentFile;"); const f = globalThis.__sf(); await tick(); globalThis.__swap(); pend[0].res({}); await f; check("a late file save does not repaint another file", shown === 0 && globalThis.__cf().text === "B", [shown, globalThis.__cf()]); // two overlapping saves: the first finishing must not free the button pend = []; currentFile = null; globalThis.__swap(); fileEditing = true; const f1 = globalThis.__sf(); const f2 = globalThis.__sf(); await tick(); pend[0].res({}); await f1; check("an older save finishing leaves Save disabled for the newer one", btn.disabled === true); pend[1].res({}); await f2; check("and the newest finishing re-enables it", btn.disabled === false); // runShell failing after it switched tabs goes back with the command kept toasts.length = 0; pend = []; let act = "src", boxv = "", saved = []; const box = { get value() { return boxv; }, set value(v) { boxv = v; } }; const $2 = () => box; const growPrompt = () => {}, refresh = async () => {}; const openSession = async (id) => { act = id; boxv = id === "src" ? "" : "shell draft"; }; const sessionX = (id) => ({ id, cwd: "/w", folder: "f", cli: "x" }); const stateX = { sessions: [{ id: "sh", cli: "shell", cwd: "/w", alive: true }] }; const rs = new Function("session", "state", "activeId", "reviewLockedOf", "hintReviewLocked", "okToSend", "api", "openSession", "toast", "clearPrompt", "saveDraft", "$", "growPrompt", "refresh", "sending", "clearSent", "return (" + region("async function runShell", "function setRepeat").replace(/^async function runShell/, "async function") + ")")( sessionX, stateX, "src", () => false, () => {}, async () => true, () => new Promise((res, rej) => pend.push({ res, rej })), openSession, (t) => toasts.push(t), () => {}, (n) => saved.push(boxv), $2, growPrompt, refresh, false, (id, text) => clearedFor.push(id + ":" + text)); const clearedFor = []; const p = rs("ls -la"); await tick(); pend[0].rej(new Error("boom")); await p; check("a failed shell send returns to the source tab with the command in the draft", act === "src" && boxv === "ls -la" && toasts.some((t) => /boom/.test(t)), [act, boxv, toasts]); pend = []; act = "src"; boxv = "pwd"; const ok = rs("pwd"); await tick(); pend[0].res({}); await ok; check("a sent shell command clears the source tab's draft, not the shell's", clearedFor.join() === "src:pwd" && boxv === "shell draft", [clearedFor, boxv]); })().then(() => { // hidden tabs do not poll let fn = null, calls2 = 0; const setInterval = (f) => { if (!fn) fn = f; }; const document = { hidden: true }; const refresh = () => { calls2++; }; const pollArtifacts = () => {}, ART_POLL_MS = 1, renderClock = () => {}, activeId = null; eval(region("setInterval(() => { if (!document.hidden) refresh", "// Slower than the sidebar")); fn(); check("a hidden tab does not poll state", calls2 === 0, calls2); document.hidden = false; fn(); check("a visible tab does", calls2 === 1, calls2); const listeners = {}; let n3 = 0; const doc = { hidden: false, addEventListener: (e, f) => { listeners[e] = f; } }; const mk = (document, refresh) => new Function("document", "refresh", "pollArtifacts", "scheduleWake", region('document.addEventListener("visibilitychange", () => {\n if (!document.hidden) {\n refresh', 'addEventListener("focus"') .replace(/\naddEventListener\("focus"[\s\S]*$/, "") + ""); mk(doc, () => { n3++; })(doc, () => { n3++; }, () => {}, () => {}); listeners.visibilitychange(); check("coming back to the tab refreshes (listener is on document)", typeof listeners.visibilitychange === "function" && n3 === 1, n3); }); globalThis.__pre = pre; } /* Drafts: the shipped loadDraft/saveDraft region, fake timers, a hand-driven * server. Issue #32: typing in B used to cancel A's pending save, and a flush * on hide sent nothing because the optimistic copy already "matched". */ globalThis.__pre = globalThis.__pre.then(async () => { console.log("drafts survive tab switches, hiding, slow and failed saves"); const code = region("function loadDraft(id)", "/* Move a half-typed thought"); let timers = [], reqs = [], toasts = []; const box = { value: "", style: {}, scrollHeight: 0 }; const state = { sessions: [{ id: "A", draft: "" }, { id: "B", draft: "" }] }; const f = new Function("$", "session", "showDraftMove", "growPrompt", "api", "toast", "setTimeout", "clearTimeout", "state", "let draftFor = null, draftTimer = null;\n" + code + "\nreturn { loadDraft, saveDraft, flush: typeof flushDrafts === 'function' ? flushDrafts : () => saveDraft(true) };")( () => box, (id) => state.sessions.find((s) => s.id === id), () => {}, () => {}, (path, opts) => new Promise((res, rej) => reqs.push({ id: path.split("/").pop(), draft: JSON.parse(opts.body).draft, rev: JSON.parse(opts.body).draft_rev, keepalive: opts.keepalive, res, rej })), (t) => toasts.push(t), (fn) => { const t = { fn, live: true }; timers.push(t); return t; }, (t) => { if (t) t.live = false; }, state); const fire = () => { const due = timers.filter((t) => t.live); timers = []; due.forEach((t) => { t.live = false; t.fn(); }); }; const settle = () => new Promise((r) => setImmediate(r)); const type = (text) => { box.value = text; f.saveDraft(); }; const sent = () => reqs.map((r) => `${r.id}:${r.draft}`); // rapid A -> B: the shipped switch path is saveDraft(true) then loadDraft f.loadDraft("A"); type("for A"); f.saveDraft(true); f.loadDraft("B"); type("for B"); fire(); await settle(); check("switching A to B mid-debounce still saves A's draft", sent().includes("A:for A"), sent()); reqs.forEach((r) => r.res({})); await settle(); reqs = []; // hide / pagehide during the debounce type("for B, more"); f.flush(); await settle(); check("a flush on hide sends the pending text at once", sent().includes("B:for B, more"), sent()); check("and asks for keepalive so a closing page does not abort it", reqs.length > 0 && reqs.every((r) => r.keepalive), reqs.map((r) => r.keepalive)); reqs.forEach((r) => r.res({})); await settle(); reqs = []; fire(); // delayed response: a newer draft waits for the older write, never races it type("v1"); fire(); await settle(); type("v2"); fire(); await settle(); check("only one write per session is in flight", reqs.length === 1 && reqs[0].draft === "v1", sent()); reqs.shift().res({}); await settle(); check("the newer draft goes after the older reply", reqs.length === 1 && reqs[0].draft === "v2", sent()); reqs.shift().res({}); await settle(); fire(); check("and nothing else is sent once they match", reqs.length === 0, sent()); // page closes while a save is in flight: the exit write cannot wait, so it // must carry a later rev from the same writer for the server to keep it type("older"); fire(); await settle(); type("latest"); f.flush(); await settle(); const [older, latest] = reqs; check("an exit save goes out even with one in flight", reqs.length === 2 && latest.draft === "latest", sent()); check("and outranks it, so the server drops the older if it lands second", older && latest && older.rev && latest.rev && older.rev[0] === latest.rev[0] && latest.rev[1] > older.rev[1], reqs.map((r) => r.rev)); reqs.forEach((r) => r.res({})); await settle(); reqs.forEach((r) => r.res({})); await settle(); reqs = []; fire(); // cleared while a save is in flight, then the page closes: the clear must // still go, or the old text comes back on reload type(""); fire(); await settle(); reqs.forEach((r) => r.res({})); await settle(); reqs = []; check("starting from a saved empty draft", reqs.length === 0 && timers.every((t) => !t.live), sent()); type("hello"); fire(); await settle(); type(""); f.flush(); await settle(); check("clearing during a save and then closing still sends the clear", reqs.length === 2 && reqs[1].draft === "" && reqs[1].rev[1] > reqs[0].rev[1], sent()); reqs.forEach((r) => r.res({})); await settle(); reqs.forEach((r) => r.res({})); await settle(); reqs = []; fire(); // a poll replaces A's object with a stale copy while A's save is in flight: // after the save lands, coming back to A must show the saved text, and the // next switch must not write the stale copy over it f.loadDraft("A"); type("keep A"); f.saveDraft(true); f.loadDraft("B"); await settle(); state.sessions = state.sessions.map((x) => ({ ...x, draft: x.id === "A" ? "" : x.draft })); reqs.forEach((r) => r.res({})); await settle(); reqs = []; f.saveDraft(true); f.loadDraft("A"); check("a stale poll during A's save does not empty A's box", box.value === "keep A", box.value); f.saveDraft(true); f.loadDraft("B"); await settle(); check("and switching away does not write the stale copy back", !reqs.some((r) => r.id === "A"), sent()); reqs.forEach((r) => r.res({})); await settle(); reqs = []; fire(); // failed save: visible, retried, not forgotten type("keep me"); fire(); await settle(); reqs.shift().rej(new Error("offline")); await settle(); check("a failed draft save is shown", toasts.some((t) => /not saved/i.test(t)), toasts); fire(); await settle(); check("and retried", reqs.length === 1 && reqs[0].draft === "keep me", sent()); reqs.shift().res({}); await settle(); f.loadDraft("A"); f.loadDraft("B"); check("the retried text is what the box reloads", box.value === "keep me", box.value); type("orphan"); fire(); await settle(); state.sessions = state.sessions.filter((s) => s.id !== "B"); reqs.shift().rej(new Error("gone")); await settle(); fire(); await settle(); check("a removed session's draft is not retried forever", reqs.length === 0, sent()); const src = region('document.addEventListener("visibilitychange", () => {\n if (document.visibilityState === "hidden")', '$("#palQ").oninput'); check("hide and pagehide both flush every session's drafts", (src.match(/flushDrafts\(\)/g) || []).length === 2, src); }); /* Issue #33: a send awaits, and the tab in front or the text in the box can * change before it returns. Every send and every follow-up must stay with the * session it was started from. Runs the shipped clearPrompt .. run region. */ globalThis.__pre = globalThis.__pre.then(async () => { console.log("a prompt send stays bound to the session it started in"); const code = region("function clearPrompt(sent)", "async function runShell"); const settle = () => new Promise((r) => setImmediate(r)); const mk = (repeat = 1) => { const w = { activeId: "a", repeat, draftFor: "a", reqs: [], titled: [], writes: [], toasts: [], repeatSet: [], drafts: { a: "", b: "" }, box: { value: "", style: {}, blur() {}, focus() {} } }; w.run = new Function("w", ` let repeat = w.repeat, activeId = w.activeId; const $ = () => w.box, handheld = () => false, document = {}, growPrompt = () => {}, showDraftMove = () => {}; const requestAnimationFrame = (f) => f(); const reviewLockedOf = () => false, hintReviewLocked = () => {}, okToSend = async () => true; const session = (id) => ({ id, name: id }); const control = () => false; const api = (path) => new Promise((res, rej) => w.reqs.push({ path, res, rej })); const maybeAutoTitle = (id) => w.titled.push(id); const toast = (t) => w.toasts.push(t); const setRepeat = (v) => { repeat = v; w.repeatSet.push(v); }; const draftOf = (id) => w.drafts[id]; const writeDraft = (id, text) => { w.drafts[id] = text; w.writes.push(id + ":" + text); }; const saveDraft = () => { w.drafts[draftFor] = w.box.value; w.writes.push(draftFor + ":" + w.box.value); }; let draftFor = w.draftFor; w.switchTo = (id) => { saveDraft(); activeId = id; draftFor = id; w.box.value = w.drafts[id]; }; ${code} return run;`)(w); return w; }; const sent = (w) => w.reqs.map((r) => r.path); // the reported case: repeat 2, HTTP fallback, switch to B while the first is held let w = mk(2); w.box.value = "prompt for A"; w.drafts.b = "B's own words"; let p = w.run("prompt for A"); await settle(); w.switchTo("b"); w.reqs[0].res({}); await settle(); w.reqs[1] && w.reqs[1].res({}); await p; check("every repeat goes to the session it was sent from", sent(w).length === 2 && sent(w).every((x) => x === "api/sessions/a/send"), sent(w)); check("the follow-up title is for that session, not the one in front", w.titled.join() === "a", w.titled); check("text composed in the other tab is left alone", w.box.value === "B's own words" && w.drafts.b === "B's own words", [w.box.value, w.drafts]); check("and the source session's sent draft is cleared", w.drafts.a === "", w.drafts); // repeat 1, still on A, but a newer draft was typed while in flight w = mk(); w.box.value = "first"; p = w.run("first"); await settle(); w.box.value = "second thought"; w.reqs[0].res({}); await p; check("a newer draft typed while sending is not cleared", w.box.value === "second thought", w.box.value); check("and it is not overwritten with an empty draft", !w.writes.includes("a:"), w.writes); // repeat 1, nothing changed: the ordinary send still empties the box w = mk(); w.box.value = "plain"; p = w.run("plain"); await settle(); w.reqs[0].res({}); await p; check("an ordinary send still empties the box and the draft", w.box.value === "" && w.drafts.a === "", [w.box.value, w.drafts]); check("and resets repeat", w.repeatSet.join() === "1", w.repeatSet); // a quick command sent while the box holds something else w = mk(); w.box.value = "half a sentence"; w.drafts.a = "half a sentence"; p = w.run("git status"); await settle(); w.reqs[0].res({}); await p; check("a quick command does not wipe the half-typed box", w.box.value === "half a sentence", w.box.value); // failed send after switching tabs: nothing cleared anywhere w = mk(2); w.box.value = "doomed"; p = w.run("doomed"); await settle(); w.switchTo("b"); w.reqs[0].rej(new Error("503")); await p; check("a failed send keeps the source draft and touches nothing else", w.drafts.a === "doomed" && w.box.value === "" && w.titled.length === 0 && w.toasts.some((t) => /503/.test(t)), [w.drafts, w.box.value, w.titled, w.toasts]); }); console.log("stale polls, artifact races, vanished sessions"); { const code = region("let refreshSeq", " if (!workspaceRestored) restoreWorkspace();") + " return true;\n}\n" + region("async function pollArtifacts", "function setArtifacts"); let state = { sessions: [] }, openTabs = [], activeId = "a", pollFailures = 0, artItems = []; const terms = new Map(), closed = [], set = []; const session = (id) => state.sessions.find((x) => x.id === id); const closeTab = (id) => { closed.push(id); terms.delete(id); }; const setArtifacts = (i) => set.push(i); const $ = () => ({}); const document = { hidden: false }; let pending = []; const api = () => new Promise((res) => pending.push(res)); eval(code + "; globalThis.__t = { refresh, pollArtifacts, setActive: (v) => { activeId = v; }, st: () => state };"); const t = globalThis.__t; (async () => { await globalThis.__pre; const r1 = t.refresh(), r2 = t.refresh(); pending[1]({ sessions: [{ id: "a" }], v: "new" }); await r2; pending[0]({ sessions: [], v: "old" }); await r1; check("an older poll finishing late does not replace newer state", t.st().v === "new", t.st().v); pending = []; state = { sessions: [{ id: "a" }], settings: {} }; terms.set("gone", {}); const r3 = t.refresh(); pending[0]({ sessions: [{ id: "a" }], settings: {} }); await r3; check("a terminal whose session vanished is torn down", closed.includes("gone") && !terms.has("gone"), closed); pending = []; const p = t.pollArtifacts(); t.setActive("b"); pending[0]([{ rel: "x" }]); await p; check("artifacts for a tab we left are dropped", set.length === 0, set); })().then(() => { console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0); }); return; } console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0);