diff --git a/js/terminal-ext.js b/js/terminal-ext.js index 3ecac058..faa0ac5b 100644 --- a/js/terminal-ext.js +++ b/js/terminal-ext.js @@ -263,6 +263,11 @@ const extend = (term) => { } }; + term.isReplayOnlyHistoryLine = (line) => { + const { cmd } = term.parseCommandLine(line); + return cmd === "apply" || cmd === "upgrade"; + }; + term.preloadCommandAssets = async (line) => { const parsed = term.parseCommandLine(line); const normalized = term.normalizeCommandForPreload(parsed.cmd, parsed.args); @@ -311,7 +316,7 @@ const extend = (term) => { term.history.push(parsed.line); } - exitStatus = term.command(parsed.line); + exitStatus = await term.command(parsed.line); if (settings.trackAnalytics) { window.dataLayer = window.dataLayer || []; @@ -347,19 +352,45 @@ const extend = (term) => { // reinitialize the terminal and replay the entire command history to restore // the visible output, then re-render the prompt at the bottom. term.resizeListener = () => { - term._initialized = false; - term.init(term.user, true); - if (typeof preloadASCIIArt === "function") { - window.scheduleIdleTask(() => preloadASCIIArt(), 1500); - } - term.runDeepLink({ replay: true }); - for (const c of term.history) { - term.prompt("\r\n", ` ${c}\r\n`); - term.command(c); + if (term._resizeReplayPromise) { + return term._resizeReplayPromise; } - term.prompt(); - term.scrollToBottom(); - term._initialized = true; + + term._resizeReplayPromise = (async () => { + const history = [...term.history]; + + term.busy = true; + term._initialized = false; + + try { + term.init(term.user, true); + if (typeof preloadASCIIArt === "function") { + window.scheduleIdleTask(() => preloadASCIIArt(), 1500); + } + + await term.runDeepLink({ replay: true }); + + for (const c of history) { + term.prompt("\r\n", ` ${c}\r\n`); + + if (term.isReplayOnlyHistoryLine(c)) { + continue; + } + + await term.command(c); + } + + term.prompt(); + term.scrollToBottom(); + } finally { + term.locked = false; + term.busy = false; + term._initialized = true; + term._resizeReplayPromise = null; + } + })(); + + return term._resizeReplayPromise; }; // Resets the terminal to its initial state. If VERSION < 4, shows an upgrade @@ -415,8 +446,9 @@ const extend = (term) => { // directly rather than executeCommandLine for exactly this reason. term.runDeepLink = ({ replay = false } = {}) => { if (term.deepLink != "") { - term.executeCommandLine(term.deepLink, { + return term.executeCommandLine(term.deepLink, { addToHistory: false, + manageBusy: !replay, promptAfter: false, showLeadingNewline: false, // Deep links are how visitors now reach a specific company or person, @@ -427,6 +459,8 @@ const extend = (term) => { console.error("Deep link failed", error); }); } + + return Promise.resolve(); }; // ── Interactive Input ────────────────────────────────────────────────────── diff --git a/tests/terminal-ext.test.js b/tests/terminal-ext.test.js index 3162b37f..1dc741a2 100644 --- a/tests/terminal-ext.test.js +++ b/tests/terminal-ext.test.js @@ -3,6 +3,21 @@ import { createBrowserEnv } from "./helpers/browser-env"; let env; +function createDeferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + function loadTerminalExt(globals = {}) { env = createBrowserEnv({ globals: { @@ -34,7 +49,10 @@ function createTerm(overrides = {}) { currentLine: "", focus: vi.fn(), history: [], + init: vi.fn(), loadAddon: vi.fn(), + locked: false, + onData: vi.fn(() => ({ dispose: vi.fn() })), open: vi.fn(), reset: vi.fn(), scrollToBottom: vi.fn(), @@ -117,20 +135,14 @@ describe("terminal-ext", () => { expect(term.executeCommandLine).toHaveBeenCalledWith("whois lee", { addToHistory: false, + manageBusy: true, promptAfter: false, showLeadingNewline: false, - // Deep links are the only way to address a specific company or person - // now, and a fragment fires no pageview of its own, so these arrivals - // would otherwise be invisible in analytics. trackAnalytics: true, }); }); it("does not re-count a deep link when a resize replays it", () => { - // xterm clears its buffer on resize, so resizeListener reruns the deep link - // to redraw the output. That is the same visit — counting it again inflates - // every deep-link arrival by one per resize, and mobile browsers fire - // resize just from showing and hiding the address bar. const { extend } = loadTerminalExt(); const term = createTerm(); @@ -141,7 +153,7 @@ describe("terminal-ext", () => { expect(term.executeCommandLine).toHaveBeenCalledWith( "whois lee", - expect.objectContaining({ trackAnalytics: false }) + expect.objectContaining({ manageBusy: false, trackAnalytics: false }) ); }); @@ -149,9 +161,6 @@ describe("terminal-ext", () => { ["#jobs", "jobs"], ["#whois-root", "whois root"], ["#tldr-chargelab", "tldr chargelab"], - // The one that used to break: splitting on every hyphen turned a - // hyphenated slug into two arguments, so the company's own deep link - // missed. Only the first hyphen separates command from argument. ["#tldr-vibe-robotics", "tldr vibe-robotics"], ["", ""], ])("parses %s into the command %s", (hash, expected) => { @@ -179,4 +188,334 @@ describe("terminal-ext", () => { expect(term.writeln).toHaveBeenCalledWith("\r\nASCII\r\n"); expect(env.window.ensureASCIIArt).toHaveBeenCalledWith("rootvc-square"); }); + + it("replays deep links before the first history entry", async () => { + const { extend } = loadTerminalExt(); + const term = createTerm(); + const events = []; + const deepLinkGate = createDeferred(); + + extend(term); + env.window.dataLayer = []; + term.history = ["help"]; + term.deepLink = "whois lee"; + term.init = vi.fn(() => { + events.push("init"); + }); + term.prompt = vi.fn((prefix = "\r\n", suffix = " ") => { + events.push(`prompt:${prefix}:${suffix}`); + }); + term.scrollToBottom = vi.fn(() => { + events.push("scroll"); + }); + term.executeCommandLine = vi.fn(() => { + events.push("deep-link:start"); + return deepLinkGate.promise.then(() => { + events.push("deep-link:end"); + }); + }); + term.command = vi.fn(async (line) => { + events.push(`command:${line}`); + return 0; + }); + + const replay = term.resizeListener(); + + expect(term.busy).toBe(true); + expect(events).toEqual(["init", "deep-link:start"]); + + deepLinkGate.resolve(); + await replay; + + expect(events).toEqual([ + "init", + "deep-link:start", + "deep-link:end", + "prompt:\r\n: help\r\n", + "command:help", + "prompt:\r\n: ", + "scroll", + ]); + expect(term.history).toEqual(["help"]); + expect(env.window.dataLayer).toEqual([]); + expect(term.busy).toBe(false); + }); + + it("waits for async history output before replaying the next entry prompt and final prompt", async () => { + const { extend } = loadTerminalExt(); + const term = createTerm(); + const events = []; + const helpGate = createDeferred(); + + extend(term); + term.history = ["help", "jobs"]; + term.init = vi.fn(() => { + events.push("init"); + }); + term.runDeepLink = vi.fn(() => Promise.resolve()); + term.prompt = vi.fn((prefix = "\r\n", suffix = " ") => { + events.push(`prompt:${prefix}:${suffix}`); + }); + term.scrollToBottom = vi.fn(() => { + events.push("scroll"); + }); + term.command = vi.fn((line) => { + if (line === "help") { + events.push("command:help:start"); + return helpGate.promise.then(() => { + events.push("output:help"); + return 0; + }); + } + + events.push(`command:${line}`); + return Promise.resolve(0); + }); + + const replay = term.resizeListener(); + await flushMicrotasks(); + + expect(events).toEqual([ + "init", + "prompt:\r\n: help\r\n", + "command:help:start", + ]); + expect(term.prompt).toHaveBeenCalledTimes(1); + expect(term.busy).toBe(true); + + helpGate.resolve(); + await replay; + + expect(events).toEqual([ + "init", + "prompt:\r\n: help\r\n", + "command:help:start", + "output:help", + "prompt:\r\n: jobs\r\n", + "command:jobs", + "prompt:\r\n: ", + "scroll", + ]); + expect(term.prompt).toHaveBeenCalledTimes(3); + expect(term.busy).toBe(false); + }); + + it("renders upgrade history without replaying its side effects and writes the final prompt last", async () => { + const { extend } = loadTerminalExt(); + const term = createTerm(); + const events = []; + const deepLinkGate = createDeferred(); + + extend(term); + term.history = ["upgrade", "help"]; + term.init = vi.fn(() => { + events.push("init"); + }); + term.prompt = vi.fn((prefix = "\r\n", suffix = " ") => { + events.push(`prompt:${prefix}:${suffix}`); + }); + term.scrollToBottom = vi.fn(() => { + events.push("scroll"); + }); + term.runDeepLink = vi.fn(() => deepLinkGate.promise); + term.command = vi.fn((line) => { + events.push(`command:${line}:start`); + events.push(`command:${line}:end`); + return Promise.resolve(0); + }); + + const replay = term.resizeListener(); + + expect(term.busy).toBe(true); + + deepLinkGate.resolve(); + await flushMicrotasks(); + + expect(events).toEqual([ + "init", + "prompt:\r\n: upgrade\r\n", + "prompt:\r\n: help\r\n", + "command:help:start", + "command:help:end", + ]); + + await replay; + + expect(events).toEqual([ + "init", + "prompt:\r\n: upgrade\r\n", + "prompt:\r\n: help\r\n", + "command:help:start", + "command:help:end", + "prompt:\r\n: ", + "scroll", + ]); + expect(term.history).toEqual(["upgrade", "help"]); + expect(term.command).toHaveBeenCalledTimes(1); + expect(term.command).toHaveBeenCalledWith("help"); + expect(term.busy).toBe(false); + }); + + it("renders upgrade history without dispatching it or clearing surviving history", async () => { + const upgradeCommand = vi.fn(() => { + throw new Error("upgrade should not run during replay"); + }); + const { extend } = loadTerminalExt({ + commands: { upgrade: upgradeCommand, help: vi.fn() }, + }); + const term = createTerm(); + + extend(term); + term.history = ["upgrade", "help"]; + term.init = vi.fn(); + term.runDeepLink = vi.fn(() => Promise.resolve()); + term.prompt = vi.fn(); + term.command = vi.fn((line) => { + const fn = env.window.commands[term.parseCommandLine(line).cmd]; + return fn ? fn(term.parseCommandLine(line).args) : 0; + }); + + await term.resizeListener(); + + expect(term.prompt).toHaveBeenNthCalledWith(1, "\r\n", " upgrade\r\n"); + expect(term.prompt).toHaveBeenNthCalledWith(2, "\r\n", " help\r\n"); + expect(term.command).toHaveBeenCalledTimes(1); + expect(term.command).toHaveBeenCalledWith("help"); + expect(upgradeCommand).not.toHaveBeenCalled(); + expect(term.history).toEqual(["upgrade", "help"]); + }); + + it("renders apply history without reopening interactive input and finishes unlocked", async () => { + const applyCommand = vi.fn(async () => { + throw new Error("apply should not run during replay"); + }); + const { extend } = loadTerminalExt({ + commands: { apply: applyCommand, help: vi.fn() }, + }); + const term = createTerm(); + + extend(term); + term.history = ["apply job-123", "help"]; + term.init = vi.fn(); + term.runDeepLink = vi.fn(() => Promise.resolve()); + term.prompt = vi.fn(); + term.command = vi.fn((line) => { + const fn = env.window.commands[term.parseCommandLine(line).cmd]; + return fn ? fn(term.parseCommandLine(line).args) : 0; + }); + + await term.resizeListener(); + + expect(term.prompt).toHaveBeenNthCalledWith(1, "\r\n", " apply job-123\r\n"); + expect(term.prompt).toHaveBeenNthCalledWith(2, "\r\n", " help\r\n"); + expect(term.command).toHaveBeenCalledTimes(1); + expect(term.command).toHaveBeenCalledWith("help"); + expect(applyCommand).not.toHaveBeenCalled(); + expect(term.onData).not.toHaveBeenCalled(); + expect(term.locked).toBe(false); + expect(term.busy).toBe(false); + }); + + it("replays open history without opening a new window", async () => { + let term; + const { extend } = loadTerminalExt({ + commands: { + open: vi.fn(([url]) => term.openURL(url)), + }, + }); + term = createTerm(); + + extend(term); + env.window.open = vi.fn(); + term.history = ["open https://root.vc"]; + term.init = vi.fn(); + term.runDeepLink = vi.fn(() => Promise.resolve()); + term.prompt = vi.fn(); + term.command = vi.fn((line) => { + const parsed = term.parseCommandLine(line); + const fn = env.window.commands[parsed.cmd]; + return fn ? fn(parsed.args) : 0; + }); + + await term.resizeListener(); + + expect(term.command).toHaveBeenCalledWith("open https://root.vc"); + expect(env.window.open).not.toHaveBeenCalled(); + expect(term._initialized).toBe(true); + }); + + it("coalesces concurrent resize replays behind one busy-owned promise", async () => { + const { extend } = loadTerminalExt(); + const deepLinkGate = createDeferred(); + const gate = createDeferred(); + const term = createTerm(); + + extend(term); + term.history = ["help"]; + term.init = vi.fn(); + term.runDeepLink = vi.fn(() => deepLinkGate.promise); + term.prompt = vi.fn(); + term.scrollToBottom = vi.fn(); + term.command = vi.fn(() => gate.promise); + + const firstReplay = term.resizeListener(); + const secondReplay = term.resizeListener(); + + expect(secondReplay).toBe(firstReplay); + expect(term.init).toHaveBeenCalledTimes(1); + expect(term.command).not.toHaveBeenCalled(); + expect(term.busy).toBe(true); + + deepLinkGate.resolve(); + await flushMicrotasks(); + + expect(term.command).toHaveBeenCalledTimes(1); + + gate.resolve(0); + await firstReplay; + + expect(term.busy).toBe(false); + expect(term._resizeReplayPromise).toBe(null); + }); + + it("clears busy, locked, and replay ownership when a replayed history command fails", async () => { + const { extend } = loadTerminalExt(); + const term = createTerm({ locked: true }); + const replayError = new Error("replay failed"); + const events = []; + + extend(term); + term.history = ["help"]; + term.init = vi.fn(() => { + events.push("init"); + }); + term.runDeepLink = vi.fn(() => Promise.resolve()); + term.prompt = vi.fn((prefix = "\r\n", suffix = " ") => { + events.push(`prompt:${prefix}:${suffix}`); + }); + term.scrollToBottom = vi.fn(() => { + events.push("scroll"); + }); + term.command = vi.fn(() => { + events.push("command:help"); + return Promise.reject(replayError); + }); + + const replay = term.resizeListener(); + + expect(term.busy).toBe(true); + await expect(replay).rejects.toBe(replayError); + + expect(events).toEqual([ + "init", + "prompt:\r\n: help\r\n", + "command:help", + ]); + expect(term.prompt).toHaveBeenCalledTimes(1); + expect(term.scrollToBottom).not.toHaveBeenCalled(); + expect(term.locked).toBe(false); + expect(term.busy).toBe(false); + expect(term._initialized).toBe(true); + expect(term._resizeReplayPromise).toBe(null); + }); });