anvilsign in

collin/browser-terminal-extension

1// Run with: node --test "extension/lib/*.test.js"
2//
3// What matters here is precedence. Four rules can answer "where does this tab
4// send the terminal" — a tab pin, an origin pin, a veto and a portless guess —
5// and getting their order wrong means the panel moves somewhere the user did
6// not ask for, which is the one failure this feature cannot afford.
7
8const test = require("node:test");
9const assert = require("node:assert");
10
11const Tabpin = require("./tabpin.js");
12const Portless = require("./portless.js");
13
14/** @param {string} name */
15const session = (name, path, windows = []) => ({
16 id: `$${name}`,
17 name,
18 attached: false,
19 path,
20 windows: windows.map((id, i) => ({
21 id,
22 index: i + 1,
23 name: id,
24 active: i === 0,
25 panes: 1,
26 activity: false,
27 })),
28});
29
30const SESSIONS = [
31 session("web", "/home/u/Code/browser-terminal-extension", ["@1", "@2"]),
32 session("dots", "/home/u/Code/dotfiles", ["@7"]),
33];
34
35/** The origin portless gives the first session's directory. */
36const WEB_ORIGIN = "https://browser-terminal-extension.localhost";
37
38const store = (over = {}) => ({ ...Tabpin.emptyStore(), ...over });
39const resolve = (s, tab, sessions = SESSIONS) => Tabpin.resolve(s, tab, sessions, Portless);
40
41test("an unknown tab moves nothing", () => {
42 assert.equal(resolve(store(), { id: 1, url: "https://example.com/" }), null);
43});
44
45test("an origin pin matches any tab on that origin", () => {
46 const s = store({ byOrigin: { "https://example.com": { session: "web", window: null } } });
47 // Path and query are not part of the key.
48 const hit = resolve(s, { id: 9, url: "https://example.com/deep/page?q=1" });
49 assert.deepEqual(hit, { target: { session: "web", window: null }, source: "origin" });
50 // A different port is a different origin, and so not a match.
51 assert.equal(resolve(s, { id: 9, url: "https://example.com:8443/" }), null);
52});
53
54test("a tab pin outranks the origin pin under it", () => {
55 const s = store({
56 byTab: { 5: { session: "dots", window: "@7" } },
57 byOrigin: { "https://example.com": { session: "web", window: null } },
58 });
59 assert.equal(resolve(s, { id: 5, url: "https://example.com/" }).source, "tab");
60 assert.equal(resolve(s, { id: 6, url: "https://example.com/" }).source, "origin");
61});
62
63test("a null entry is a veto and stops the search", () => {
64 const s = store({
65 byTab: { 5: null },
66 byOrigin: { [WEB_ORIGIN]: { session: "web", window: null } },
67 });
68 // The origin pin below it would have matched; the veto is why it does not.
69 assert.equal(resolve(s, { id: 5, url: `${WEB_ORIGIN}/` }), null);
70 // And an origin veto is what turns a portless guess off for good.
71 const vetoed = store({ byOrigin: { [WEB_ORIGIN]: null } });
72 assert.equal(resolve(vetoed, { id: 5, url: `${WEB_ORIGIN}/` }), null);
73});
74
75test("a portless host finds its session with nothing stored", () => {
76 const hit = resolve(store(), { id: 1, url: `${WEB_ORIGIN}/app` });
77 assert.deepEqual(hit, { target: { session: "web", window: null }, source: "detect" });
78 // The port portless proxies on is not part of the answer.
79 assert.equal(resolve(store(), { id: 1, url: `${WEB_ORIGIN}:8443/` }).source, "detect");
80});
81
82test("detection is local, named and unambiguous or it does not fire", () => {
83 const off = store({ detect: false });
84 assert.equal(resolve(off, { id: 1, url: `${WEB_ORIGIN}/` }), null);
85 // The same name on a real domain says nothing about your projects.
86 assert.equal(resolve(store(), { id: 1, url: "https://browser-terminal-extension.com/" }), null);
87 // A dev server nobody put behind portless carries no name to match.
88 assert.equal(resolve(store(), { id: 1, url: "http://localhost:3000/" }), null);
89 // Two sessions answering to one name is a collision, and a coin toss is worse
90 // than doing nothing.
91 const twins = [...SESSIONS, session("web2", "/elsewhere/browser-terminal-extension", ["@9"])];
92 assert.equal(resolve(store(), { id: 1, url: `${WEB_ORIGIN}/` }, twins), null);
93});
94
95test("the session name is a candidate too, not just its directory", () => {
96 const named = [session("myapp", "/home/u/somewhere/else", ["@1"])];
97 assert.equal(resolve(store(), { id: 1, url: "https://myapp.localhost/" }, named).source, "detect");
98});
99
100test("a worktree host goes to the worktree's own session", () => {
101 const trees = [
102 session("web", "/home/u/Code/browser-terminal-extension", ["@1"]),
103 session("feat", "/home/u/Code/worktrees/feat-x", ["@3"]),
104 ];
105 const hit = resolve(store(), { id: 1, url: "https://feat-x.browser-terminal-extension.localhost/" }, trees);
106 assert.deepEqual(hit, { target: { session: "feat", window: null }, source: "detect" });
107 // And the main checkout keeps its own host to itself.
108 assert.equal(resolve(store(), { id: 1, url: `${WEB_ORIGIN}/` }, trees).target.session, "web");
109});
110
111test("the whole feature can be switched off without losing pins", () => {
112 const s = store({
113 enabled: false,
114 byOrigin: { "https://example.com": { session: "web", window: null } },
115 });
116 assert.equal(resolve(s, { id: 1, url: "https://example.com/" }), null);
117 assert.equal(resolve({ ...s, enabled: true }, { id: 1, url: "https://example.com/" }).source, "origin");
118});
119
120test("only http and https can carry an origin pin", () => {
121 for (const url of ["file:///tmp/x.html", "about:blank", "chrome://extensions", undefined]) {
122 assert.equal(Tabpin.originOf(url), "");
123 }
124 assert.equal(Tabpin.originOf("http://localhost:26210/x"), "http://localhost:26210");
125});
126
127test("locate falls back to the session when the window has closed", () => {
128 assert.deepEqual(Tabpin.locate({ session: "web", window: "@2" }, SESSIONS).window.id, "@2");
129 // Pinned to a window that has since been killed: still the right session.
130 const gone = Tabpin.locate({ session: "web", window: "@99" }, SESSIONS);
131 assert.deepEqual({ session: gone.session, window: gone.window }, { session: "web", window: null });
132 // Session gone entirely: nothing to act on.
133 assert.equal(Tabpin.locate({ session: "ghost", window: null }, SESSIONS), null);
134});
135
136test("loadStore drops anything that is not a pin", () => {
137 const s = Tabpin.loadStore({
138 enabled: false,
139 byTab: { 1: { session: "web", window: "@1" }, 2: { window: "@2" }, 3: "web", 4: null },
140 byOrigin: "not a map",
141 });
142 assert.equal(s.enabled, false);
143 // detect was absent, and absent is not off.
144 assert.equal(s.detect, true);
145 assert.deepEqual(s.byTab, { 1: { session: "web", window: "@1" }, 4: null });
146 assert.deepEqual(s.byOrigin, {});
147 // Junk in, empty out — never a throw, because this is storage a past version
148 // of the panel wrote.
149 assert.deepEqual(Tabpin.loadStore(null), Tabpin.emptyStore());
150 assert.deepEqual(Tabpin.loadStore("x"), Tabpin.emptyStore());
151});
152
153test("put does not mutate the store it was given", () => {
154 const before = store();
155 const after = Tabpin.put(before, "origin", "https://example.com", { session: "web" });
156 assert.deepEqual(before.byOrigin, {});
157 assert.deepEqual(after.byOrigin, { "https://example.com": { session: "web" } });
158 assert.deepEqual(Tabpin.put(after, "origin", "https://example.com", undefined).byOrigin, {});
159 assert.deepEqual(Tabpin.put(after, "origin", "https://example.com", null).byOrigin, {
160 "https://example.com": null,
161 });
162});
163
164test("pruneTabs drops closed tabs and leaves origins alone", () => {
165 const s = store({
166 byTab: { 1: { session: "web" }, 2: { session: "dots" } },
167 byOrigin: { "https://example.com": { session: "web" } },
168 });
169 assert.equal(Tabpin.pruneTabs(s, [1, 2]), null);
170 const pruned = Tabpin.pruneTabs(s, [2]);
171 assert.deepEqual(Object.keys(pruned.byTab), ["2"]);
172 assert.deepEqual(pruned.byOrigin, s.byOrigin);
173});
174
175// --- the excursion ----------------------------------------------------------
176//
177// A pin borrows the terminal; leaving the pinned tab gives it back. These are
178// the rules that decide when it is owed and when the debt has lapsed.
179
180const AT = { session: "home", window: "@1" };
181const PIN = { session: "web", window: "@2" };
182
183test("a pin borrows the terminal and an unpinned tab hands it back", () => {
184 const first = Tabpin.step(null, PIN, AT);
185 assert.deepEqual(first.go, PIN);
186 assert.deepEqual(first.owed, { from: AT, to: PIN });
187
188 // Now sitting where the pin put us, and the browser moves to a plain tab.
189 const back = Tabpin.step(first.owed, null, PIN);
190 assert.deepEqual(back.go, AT);
191 assert.equal(back.owed, null);
192});
193
194test("flicking back and forth flicks the terminal with it", () => {
195 let owed = null;
196 let at = AT;
197 for (let i = 0; i < 3; i++) {
198 ({ go: at, owed } = trip(owed, PIN, at));
199 assert.deepEqual(at, PIN, `to the pinned tab, round ${i}`);
200 ({ go: at, owed } = trip(owed, null, at));
201 assert.deepEqual(at, AT, `and back, round ${i}`);
202 }
203 // `go` null means "stay", so the walk needs the previous place carried over.
204 function trip(o, target, from) {
205 const r = Tabpin.step(o, target, from);
206 return { go: r.go ?? from, owed: r.owed };
207 }
208});
209
210test("a run of pinned tabs returns to where the run started", () => {
211 const one = Tabpin.step(null, PIN, AT);
212 const other = { session: "dots", window: "@7" };
213 const two = Tabpin.step(one.owed, other, PIN);
214 assert.deepEqual(two.go, other);
215 // The middle of the excursion is not somewhere the user chose to be.
216 assert.deepEqual(two.owed, { from: AT, to: other });
217 assert.deepEqual(Tabpin.step(two.owed, null, other).go, AT);
218});
219
220test("a pin that had nothing to do owes nothing", () => {
221 // Landing on a tab pinned to where you already are.
222 const r = Tabpin.step(null, PIN, PIN);
223 assert.equal(r.go, null);
224 assert.equal(r.owed, null);
225 // So leaving it moves nothing either.
226 assert.deepEqual(Tabpin.step(r.owed, null, PIN), { go: null, owed: null });
227});
228
229test("a switch made by hand cancels the return rather than being undone", () => {
230 const { owed } = Tabpin.step(null, PIN, AT);
231 // The user steered somewhere else while sitting on the pinned tab.
232 const steered = { session: "web", window: "@9" };
233 const r = Tabpin.step(owed, null, steered);
234 assert.equal(r.go, null, "the panel does not overrule a by-hand switch");
235 assert.equal(r.owed, null, "and the debt lapses rather than lingering");
236});
237
238test("a session-level pin is satisfied by any window of that session", () => {
239 const session = { session: "web", window: null };
240 // Already in the session, on some window of it: nothing to do.
241 assert.deepEqual(Tabpin.step(null, session, { session: "web", window: "@5" }), {
242 go: null,
243 owed: null,
244 });
245 // And the return still fires after moving around inside it.
246 const { owed } = Tabpin.step(null, session, AT);
247 assert.deepEqual(Tabpin.step(owed, null, { session: "web", window: "@5" }).go, AT);
248});
249
250test("sameSpot compares by session, and by window only when both name one", () => {
251 assert.ok(Tabpin.sameSpot({ session: "a", window: "@1" }, { session: "a", window: "@1" }));
252 assert.ok(Tabpin.sameSpot({ session: "a", window: "@1" }, { session: "a", window: null }));
253 assert.ok(!Tabpin.sameSpot({ session: "a", window: "@1" }, { session: "a", window: "@2" }));
254 assert.ok(!Tabpin.sameSpot({ session: "a" }, { session: "b" }));
255 assert.ok(!Tabpin.sameSpot(null, { session: "a" }));
256});
257
258test("baseName reads the directory portless would have named the app after", () => {
259 assert.equal(Tabpin.baseName("/home/u/Code/thing"), "thing");
260 assert.equal(Tabpin.baseName("/home/u/Code/thing/"), "thing");
261 assert.equal(Tabpin.baseName(null), "");
262});
263
264// --- the other direction ----------------------------------------------------
265//
266// `reverse` is the same rules read from the far end, so what these check is
267// mostly that it agrees with `resolve`: the tab a pin would have chosen going
268// one way is the tab it chooses coming back, vetoes still veto, and the more
269// specific rule still wins. Plus the one rule that is only its own — a browser
270// already showing a tab that points here is left alone, which is what keeps the
271// two directions from chasing each other.
272
273const rev = (s, spot, tabs, sessions = SESSIONS) => Tabpin.reverse(s, spot, tabs, sessions, Portless);
274
275test("reverse finds the tab an origin pin names", () => {
276 const s = store({ byOrigin: { "https://example.com": { session: "web", window: null } } });
277 const tabs = [
278 { id: 1, url: "https://other.test/", active: true },
279 { id: 2, url: "https://example.com/deep" },
280 ];
281 assert.deepEqual(rev(s, { session: "web", window: "@1" }, tabs), { tabId: 2, source: "origin" });
282 // Nothing points at the other session, so nothing moves.
283 assert.equal(rev(s, { session: "dots", window: "@7" }, tabs), null);
284});
285
286test("reverse does nothing when the showing tab already points here", () => {
287 const s = store({ byOrigin: { "https://example.com": { session: "web", window: null } } });
288 const tabs = [
289 { id: 2, url: "https://example.com/", active: true },
290 { id: 3, url: "https://example.com/other" },
291 ];
292 // Two tabs match and one of them is showing: that one wins outright, so the
293 // browser is left where it is rather than being dragged to the other.
294 assert.equal(rev(s, { session: "web", window: "@1" }, tabs), null);
295});
296
297test("reverse prefers the more specific rule, then the more recent tab", () => {
298 const s = store({
299 byTab: { 9: { session: "web", window: null } },
300 byOrigin: { "https://example.com": { session: "web", window: null } },
301 });
302 const tabs = [
303 { id: 4, url: "https://example.com/" },
304 { id: 9, url: "https://elsewhere.test/" },
305 ];
306 // The tab pin outranks the origin pin even though its tab is later in the
307 // list, exactly as it does going forward.
308 assert.deepEqual(rev(s, { session: "web" }, tabs), { tabId: 9, source: "tab" });
309 // Between two tabs answering by the same rule, the caller's order decides —
310 // it sorts by last use, so this is the one you were reading.
311 const origins = store({ byOrigin: { "https://example.com": { session: "web", window: null } } });
312 assert.equal(rev(origins, { session: "web" }, tabs).tabId, 4);
313});
314
315test("reverse honours a veto and the off switches", () => {
316 const tabs = [{ id: 5, url: `${WEB_ORIGIN}/` }];
317 const spot = { session: "web", window: "@1" };
318 // The portless guess would have matched this tab.
319 assert.equal(rev(store(), spot, tabs).source, "detect");
320 // An origin veto turns it off from this end too.
321 assert.equal(rev(store({ byOrigin: { [WEB_ORIGIN]: null } }), spot, tabs), null);
322 // As does either switch.
323 assert.equal(rev(store({ reverse: false }), spot, tabs), null);
324 assert.equal(rev(store({ enabled: false }), spot, tabs), null);
325 assert.equal(rev(store({ detect: false }), spot, tabs), null);
326});
327
328test("reverse follows a window pin whose window has closed to its session", () => {
329 const s = store({ byOrigin: { "https://example.com": { session: "web", window: "@404" } } });
330 const tabs = [{ id: 2, url: "https://example.com/" }];
331 // The pin names a window that is gone, so it stands for its session — the
332 // same fallback `locate` makes going forward — and any window of it matches.
333 assert.deepEqual(rev(s, { session: "web", window: "@2" }, tabs), { tabId: 2, source: "origin" });
334 // A pin to a session that is not running at all still resolves to nothing.
335 const dead = store({ byOrigin: { "https://example.com": { session: "gone", window: null } } });
336 assert.equal(rev(dead, { session: "gone", window: null }, tabs), null);
337});