| 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 | |
| 8 | const test = require("node:test"); |
| 9 | const assert = require("node:assert"); |
| 10 | |
| 11 | const Tabpin = require("./tabpin.js"); |
| 12 | const Devport = require("./devport.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 port devport hands the first session's directory. */ |
| 36 | const WEB_PORT = Devport.portOf("browser-terminal-extension"); |
| 37 | |
| 38 | const store = (over = {}) => ({ ...Tabpin.emptyStore(), ...over }); |
| 39 | const resolve = (s, tab, sessions = SESSIONS) => Tabpin.resolve(s, tab, sessions, Devport); |
| 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: { [`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 | |
| 75 | test("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 | |
| 82 | test("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 | |
| 95 | test("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 | |
| 101 | test("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 | |
| 110 | test("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 | |
| 117 | test("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 | |
| 126 | test("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 | |
| 143 | test("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 | |
| 154 | test("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 | |
| 170 | const AT = { session: "home", window: "@1" }; |
| 171 | const PIN = { session: "web", window: "@2" }; |
| 172 | |
| 173 | test("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 | |
| 184 | test("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 | |
| 200 | test("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 | |
| 210 | test("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 | |
| 219 | test("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 | |
| 228 | test("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 | |
| 240 | test("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 | |
| 248 | test("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 | |
| 263 | const rev = (s, spot, tabs, sessions = SESSIONS) => Tabpin.reverse(s, spot, tabs, sessions, Devport); |
| 264 | |
| 265 | test("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 | |
| 276 | test("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 | |
| 287 | test("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 | |
| 305 | test("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 | |
| 318 | test("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 | }); |