| 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 | |
| 8 | const test = require("node:test"); |
| 9 | const assert = require("node:assert"); |
| 10 | |
| 11 | const Tabpin = require("./tabpin.js"); |
| 12 | const Portless = require("./portless.js"); |
| 13 | |
| 14 | /** @param {string} name */ |
| 15 | const 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 | |
| 30 | const 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. */ |
| 36 | const WEB_ORIGIN = "https://browser-terminal-extension.localhost"; |
| 37 | |
| 38 | const store = (over = {}) => ({ ...Tabpin.emptyStore(), ...over }); |
| 39 | const resolve = (s, tab, sessions = SESSIONS) => Tabpin.resolve(s, tab, sessions, Portless); |
| 40 | |
| 41 | test("an unknown tab moves nothing", () => { |
| 42 | assert.equal(resolve(store(), { id: 1, url: "https://example.com/" }), null); |
| 43 | }); |
| 44 | |
| 45 | test("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 | |
| 54 | test("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 | |
| 63 | test("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 | |
| 75 | test("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 | |
| 82 | test("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 | |
| 95 | test("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 | |
| 100 | test("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 | |
| 111 | test("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 | |
| 120 | test("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 | |
| 127 | test("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 | |
| 136 | test("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 | |
| 153 | test("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 | |
| 164 | test("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 | |
| 180 | const AT = { session: "home", window: "@1" }; |
| 181 | const PIN = { session: "web", window: "@2" }; |
| 182 | |
| 183 | test("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 | |
| 194 | test("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 | |
| 210 | test("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 | |
| 220 | test("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 | |
| 229 | test("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 | |
| 238 | test("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 | |
| 250 | test("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 | |
| 258 | test("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 | |
| 273 | const rev = (s, spot, tabs, sessions = SESSIONS) => Tabpin.reverse(s, spot, tabs, sessions, Portless); |
| 274 | |
| 275 | test("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 | |
| 286 | test("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 | |
| 297 | test("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 | |
| 315 | test("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 | |
| 328 | test("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 | }); |