tools/frontend_check.js
Rama main · réplica de lectura del repositorio original.
/* 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);