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 devport 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 Devport = require("./devport.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 port devport hands the first session's directory. */
36const WEB_PORT = Devport.portOf("browser-terminal-extension");
37
38const store = (over = {}) => ({ ...Tabpin.emptyStore(), ...over });
39const resolve = (s, tab, sessions = SESSIONS) => Tabpin.resolve(s, tab, sessions, Devport);
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: { [`http://localhost:${WEB_PORT}`]: { 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: `http://localhost:${WEB_PORT}/` }), null);
70 // And an origin veto is what turns a devport guess off for good.
71 const vetoed = store({ byOrigin: { [`http://localhost:${WEB_PORT}`]: null } });
72 assert.equal(resolve(vetoed, { id: 5, url: `http://localhost:${WEB_PORT}/` }), null);
73});
74
75test("a localhost devport port finds its session with nothing stored", () => {
76 const hit = resolve(store(), { id: 1, url: `http://localhost:${WEB_PORT}/app` });
77 assert.deepEqual(hit, { target: { session: "web", window: null }, source: "detect" });
78 // Every port in the block, not just the base one.
79 assert.equal(resolve(store(), { id: 1, url: `http://127.0.0.1:${WEB_PORT + 3}/` }).source, "detect");
80});
81
82test("detection is local, in-range and unambiguous or it does not fire", () => {
83 const off = store({ detect: false });
84 assert.equal(resolve(off, { id: 1, url: `http://localhost:${WEB_PORT}/` }), null);
85 // A remote host on the same port says nothing about your projects.
86 assert.equal(resolve(store(), { id: 1, url: `http://example.com:${WEB_PORT}/` }), null);
87 // A hand-picked port is in nobody's block.
88 assert.equal(resolve(store(), { id: 1, url: "http://localhost:3000/" }), null);
89 // Two sessions in the same block 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: `http://localhost:${WEB_PORT}/` }, 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 const port = Devport.portOf("myapp");
98 assert.equal(resolve(store(), { id: 1, url: `http://localhost:${port}/` }, named).source, "detect");
99});
100
101test("the whole feature can be switched off without losing pins", () => {
102 const s = store({
103 enabled: false,
104 byOrigin: { "https://example.com": { session: "web", window: null } },
105 });
106 assert.equal(resolve(s, { id: 1, url: "https://example.com/" }), null);
107 assert.equal(resolve({ ...s, enabled: true }, { id: 1, url: "https://example.com/" }).source, "origin");
108});
109
110test("only http and https can carry an origin pin", () => {
111 for (const url of ["file:///tmp/x.html", "about:blank", "chrome://extensions", undefined]) {
112 assert.equal(Tabpin.originOf(url), "");
113 }
114 assert.equal(Tabpin.originOf("http://localhost:26210/x"), "http://localhost:26210");
115});
116
117test("locate falls back to the session when the window has closed", () => {
118 assert.deepEqual(Tabpin.locate({ session: "web", window: "@2" }, SESSIONS).window.id, "@2");
119 // Pinned to a window that has since been killed: still the right session.
120 const gone = Tabpin.locate({ session: "web", window: "@99" }, SESSIONS);
121 assert.deepEqual({ session: gone.session, window: gone.window }, { session: "web", window: null });
122 // Session gone entirely: nothing to act on.
123 assert.equal(Tabpin.locate({ session: "ghost", window: null }, SESSIONS), null);
124});
125
126test("loadStore drops anything that is not a pin", () => {
127 const s = Tabpin.loadStore({
128 enabled: false,
129 byTab: { 1: { session: "web", window: "@1" }, 2: { window: "@2" }, 3: "web", 4: null },
130 byOrigin: "not a map",
131 });
132 assert.equal(s.enabled, false);
133 // detect was absent, and absent is not off.
134 assert.equal(s.detect, true);
135 assert.deepEqual(s.byTab, { 1: { session: "web", window: "@1" }, 4: null });
136 assert.deepEqual(s.byOrigin, {});
137 // Junk in, empty out — never a throw, because this is storage a past version
138 // of the panel wrote.
139 assert.deepEqual(Tabpin.loadStore(null), Tabpin.emptyStore());
140 assert.deepEqual(Tabpin.loadStore("x"), Tabpin.emptyStore());
141});
142
143test("put does not mutate the store it was given", () => {
144 const before = store();
145 const after = Tabpin.put(before, "origin", "https://example.com", { session: "web" });
146 assert.deepEqual(before.byOrigin, {});
147 assert.deepEqual(after.byOrigin, { "https://example.com": { session: "web" } });
148 assert.deepEqual(Tabpin.put(after, "origin", "https://example.com", undefined).byOrigin, {});
149 assert.deepEqual(Tabpin.put(after, "origin", "https://example.com", null).byOrigin, {
150 "https://example.com": null,
151 });
152});
153
154test("pruneTabs drops closed tabs and leaves origins alone", () => {
155 const s = store({
156 byTab: { 1: { session: "web" }, 2: { session: "dots" } },
157 byOrigin: { "https://example.com": { session: "web" } },
158 });
159 assert.equal(Tabpin.pruneTabs(s, [1, 2]), null);
160 const pruned = Tabpin.pruneTabs(s, [2]);
161 assert.deepEqual(Object.keys(pruned.byTab), ["2"]);
162 assert.deepEqual(pruned.byOrigin, s.byOrigin);
163});
164
165// --- the excursion ----------------------------------------------------------
166//
167// A pin borrows the terminal; leaving the pinned tab gives it back. These are
168// the rules that decide when it is owed and when the debt has lapsed.
169
170const AT = { session: "home", window: "@1" };
171const PIN = { session: "web", window: "@2" };
172
173test("a pin borrows the terminal and an unpinned tab hands it back", () => {
174 const first = Tabpin.step(null, PIN, AT);
175 assert.deepEqual(first.go, PIN);
176 assert.deepEqual(first.owed, { from: AT, to: PIN });
177
178 // Now sitting where the pin put us, and the browser moves to a plain tab.
179 const back = Tabpin.step(first.owed, null, PIN);
180 assert.deepEqual(back.go, AT);
181 assert.equal(back.owed, null);
182});
183
184test("flicking back and forth flicks the terminal with it", () => {
185 let owed = null;
186 let at = AT;
187 for (let i = 0; i < 3; i++) {
188 ({ go: at, owed } = trip(owed, PIN, at));
189 assert.deepEqual(at, PIN, `to the pinned tab, round ${i}`);
190 ({ go: at, owed } = trip(owed, null, at));
191 assert.deepEqual(at, AT, `and back, round ${i}`);
192 }
193 // `go` null means "stay", so the walk needs the previous place carried over.
194 function trip(o, target, from) {
195 const r = Tabpin.step(o, target, from);
196 return { go: r.go ?? from, owed: r.owed };
197 }
198});
199
200test("a run of pinned tabs returns to where the run started", () => {
201 const one = Tabpin.step(null, PIN, AT);
202 const other = { session: "dots", window: "@7" };
203 const two = Tabpin.step(one.owed, other, PIN);
204 assert.deepEqual(two.go, other);
205 // The middle of the excursion is not somewhere the user chose to be.
206 assert.deepEqual(two.owed, { from: AT, to: other });
207 assert.deepEqual(Tabpin.step(two.owed, null, other).go, AT);
208});
209
210test("a pin that had nothing to do owes nothing", () => {
211 // Landing on a tab pinned to where you already are.
212 const r = Tabpin.step(null, PIN, PIN);
213 assert.equal(r.go, null);
214 assert.equal(r.owed, null);
215 // So leaving it moves nothing either.
216 assert.deepEqual(Tabpin.step(r.owed, null, PIN), { go: null, owed: null });
217});
218
219test("a switch made by hand cancels the return rather than being undone", () => {
220 const { owed } = Tabpin.step(null, PIN, AT);
221 // The user steered somewhere else while sitting on the pinned tab.
222 const steered = { session: "web", window: "@9" };
223 const r = Tabpin.step(owed, null, steered);
224 assert.equal(r.go, null, "the panel does not overrule a by-hand switch");
225 assert.equal(r.owed, null, "and the debt lapses rather than lingering");
226});
227
228test("a session-level pin is satisfied by any window of that session", () => {
229 const session = { session: "web", window: null };
230 // Already in the session, on some window of it: nothing to do.
231 assert.deepEqual(Tabpin.step(null, session, { session: "web", window: "@5" }), {
232 go: null,
233 owed: null,
234 });
235 // And the return still fires after moving around inside it.
236 const { owed } = Tabpin.step(null, session, AT);
237 assert.deepEqual(Tabpin.step(owed, null, { session: "web", window: "@5" }).go, AT);
238});
239
240test("sameSpot compares by session, and by window only when both name one", () => {
241 assert.ok(Tabpin.sameSpot({ session: "a", window: "@1" }, { session: "a", window: "@1" }));
242 assert.ok(Tabpin.sameSpot({ session: "a", window: "@1" }, { session: "a", window: null }));
243 assert.ok(!Tabpin.sameSpot({ session: "a", window: "@1" }, { session: "a", window: "@2" }));
244 assert.ok(!Tabpin.sameSpot({ session: "a" }, { session: "b" }));
245 assert.ok(!Tabpin.sameSpot(null, { session: "a" }));
246});
247
248test("baseName reads the directory devport would have hashed", () => {
249 assert.equal(Tabpin.baseName("/home/u/Code/thing"), "thing");
250 assert.equal(Tabpin.baseName("/home/u/Code/thing/"), "thing");
251 assert.equal(Tabpin.baseName(null), "");
252});
253
254// --- the other direction ----------------------------------------------------
255//
256// `reverse` is the same rules read from the far end, so what these check is
257// mostly that it agrees with `resolve`: the tab a pin would have chosen going
258// one way is the tab it chooses coming back, vetoes still veto, and the more
259// specific rule still wins. Plus the one rule that is only its own — a browser
260// already showing a tab that points here is left alone, which is what keeps the
261// two directions from chasing each other.
262
263const rev = (s, spot, tabs, sessions = SESSIONS) => Tabpin.reverse(s, spot, tabs, sessions, Devport);
264
265test("reverse finds the tab an origin pin names", () => {
266 const s = store({ byOrigin: { "https://example.com": { session: "web", window: null } } });
267 const tabs = [
268 { id: 1, url: "https://other.test/", active: true },
269 { id: 2, url: "https://example.com/deep" },
270 ];
271 assert.deepEqual(rev(s, { session: "web", window: "@1" }, tabs), { tabId: 2, source: "origin" });
272 // Nothing points at the other session, so nothing moves.
273 assert.equal(rev(s, { session: "dots", window: "@7" }, tabs), null);
274});
275
276test("reverse does nothing when the showing tab already points here", () => {
277 const s = store({ byOrigin: { "https://example.com": { session: "web", window: null } } });
278 const tabs = [
279 { id: 2, url: "https://example.com/", active: true },
280 { id: 3, url: "https://example.com/other" },
281 ];
282 // Two tabs match and one of them is showing: that one wins outright, so the
283 // browser is left where it is rather than being dragged to the other.
284 assert.equal(rev(s, { session: "web", window: "@1" }, tabs), null);
285});
286
287test("reverse prefers the more specific rule, then the more recent tab", () => {
288 const s = store({
289 byTab: { 9: { session: "web", window: null } },
290 byOrigin: { "https://example.com": { session: "web", window: null } },
291 });
292 const tabs = [
293 { id: 4, url: "https://example.com/" },
294 { id: 9, url: "https://elsewhere.test/" },
295 ];
296 // The tab pin outranks the origin pin even though its tab is later in the
297 // list, exactly as it does going forward.
298 assert.deepEqual(rev(s, { session: "web" }, tabs), { tabId: 9, source: "tab" });
299 // Between two tabs answering by the same rule, the caller's order decides —
300 // it sorts by last use, so this is the one you were reading.
301 const origins = store({ byOrigin: { "https://example.com": { session: "web", window: null } } });
302 assert.equal(rev(origins, { session: "web" }, tabs).tabId, 4);
303});
304
305test("reverse honours a veto and the off switches", () => {
306 const tabs = [{ id: 5, url: `http://localhost:${WEB_PORT}/` }];
307 const spot = { session: "web", window: "@1" };
308 // The devport guess would have matched this tab.
309 assert.equal(rev(store(), spot, tabs).source, "detect");
310 // An origin veto turns it off from this end too.
311 assert.equal(rev(store({ byOrigin: { [`http://localhost:${WEB_PORT}`]: null } }), spot, tabs), null);
312 // As does either switch.
313 assert.equal(rev(store({ reverse: false }), spot, tabs), null);
314 assert.equal(rev(store({ enabled: false }), spot, tabs), null);
315 assert.equal(rev(store({ detect: false }), spot, tabs), null);
316});
317
318test("reverse follows a window pin whose window has closed to its session", () => {
319 const s = store({ byOrigin: { "https://example.com": { session: "web", window: "@404" } } });
320 const tabs = [{ id: 2, url: "https://example.com/" }];
321 // The pin names a window that is gone, so it stands for its session — the
322 // same fallback `locate` makes going forward — and any window of it matches.
323 assert.deepEqual(rev(s, { session: "web", window: "@2" }, tabs), { tabId: 2, source: "origin" });
324 // A pin to a session that is not running at all still resolves to nothing.
325 const dead = store({ byOrigin: { "https://example.com": { session: "gone", window: null } } });
326 assert.equal(rev(dead, { session: "gone", window: null }, tabs), null);
327});