| 1 | // Run with: node --test "extension/lib/*.test.js" |
| 2 | // |
| 3 | // Chrome's split view is two visible tabs and one `active` flag, so the picker |
| 4 | // runs in both halves at once. What is checked here is the race that follows: |
| 5 | // who wins, who gets torn down, and which failures are allowed to end it. |
| 6 | |
| 7 | const test = require("node:test"); |
| 8 | const assert = require("node:assert"); |
| 9 | |
| 10 | const Split = require("./split.js"); |
| 11 | |
| 12 | /** Tabs whose injected picker is still open, keyed by tab id. */ |
| 13 | const pending = new Map(); |
| 14 | |
| 15 | /** |
| 16 | * A stand-in for the scripting API. `pick` decides what each tab's injected |
| 17 | * picker does: a value resolves the pick, null cancels it, an Error rejects the |
| 18 | * injection, and a promise is awaited so a test can control who wins the race. |
| 19 | */ |
| 20 | function fakeApi(pick, log = []) { |
| 21 | return { |
| 22 | api: { |
| 23 | scripting: { |
| 24 | executeScript({ target, func }) { |
| 25 | // The cancel injection is a different function than the picker; it is |
| 26 | // the only one that takes no part in the race. |
| 27 | if (func.name === "tbCancelPicker") { |
| 28 | log.push(`cancel:${target.tabId}`); |
| 29 | const cancelled = pending.get(target.tabId); |
| 30 | if (cancelled) cancelled(null); |
| 31 | return Promise.resolve([{ result: undefined }]); |
| 32 | } |
| 33 | log.push(`pick:${target.tabId}`); |
| 34 | const outcome = pick(target.tabId); |
| 35 | if (outcome instanceof Error) return Promise.reject(outcome); |
| 36 | if (outcome === "wait") { |
| 37 | return new Promise((resolve) => { |
| 38 | pending.set(target.tabId, (v) => resolve([{ result: v }])); |
| 39 | }); |
| 40 | } |
| 41 | return Promise.resolve([{ result: outcome }]); |
| 42 | }, |
| 43 | }, |
| 44 | tabs: { |
| 45 | query: () => Promise.resolve([]), |
| 46 | }, |
| 47 | }, |
| 48 | log, |
| 49 | }; |
| 50 | } |
| 51 | |
| 52 | test("a pick in the non-active half of a split wins the race", async () => { |
| 53 | pending.clear(); |
| 54 | const { api, log } = fakeApi((id) => (id === 2 ? { xpath: "/html/body" } : "wait")); |
| 55 | const out = await Split.racePick(api, [{ id: 1 }, { id: 2 }], function tbPickElement() {}); |
| 56 | assert.equal(out.tabId, 2); |
| 57 | assert.deepEqual(out.value, { xpath: "/html/body" }); |
| 58 | assert.equal(out.error, ""); |
| 59 | // The loser's overlay is torn down rather than left live in the other pane. |
| 60 | assert.ok(log.includes("cancel:1")); |
| 61 | }); |
| 62 | |
| 63 | test("an injection that fails does not end a pick the other half is serving", async () => { |
| 64 | pending.clear(); |
| 65 | const { api } = fakeApi((id) => |
| 66 | id === 1 ? new Error("Cannot access contents of the page") : { xpath: "/html" }, |
| 67 | ); |
| 68 | const out = await Split.racePick(api, [{ id: 1 }, { id: 2 }], function tbPickElement() {}); |
| 69 | assert.equal(out.tabId, 2); |
| 70 | assert.deepEqual(out.value, { xpath: "/html" }); |
| 71 | }); |
| 72 | |
| 73 | test("every injection failing reports the first reason against the active tab", async () => { |
| 74 | pending.clear(); |
| 75 | const { api } = fakeApi(() => new Error("Cannot access contents of the page")); |
| 76 | const out = await Split.racePick(api, [{ id: 1 }, { id: 2 }], function tbPickElement() {}); |
| 77 | assert.equal(out.tabId, 1); |
| 78 | assert.equal(out.value, null); |
| 79 | assert.match(out.error, /Cannot access/); |
| 80 | }); |
| 81 | |
| 82 | test("a cancel in one half cancels the other", async () => { |
| 83 | pending.clear(); |
| 84 | const { api, log } = fakeApi((id) => (id === 1 ? null : "wait")); |
| 85 | const out = await Split.racePick(api, [{ id: 1 }, { id: 2 }], function tbPickElement() {}); |
| 86 | assert.equal(out.value, null); |
| 87 | assert.equal(out.error, ""); |
| 88 | assert.ok(log.includes("cancel:2")); |
| 89 | }); |
| 90 | |
| 91 | test("no pickable tab is a message, not a throw", async () => { |
| 92 | const { api } = fakeApi(() => null); |
| 93 | const out = await Split.racePick(api, [], function tbPickElement() {}); |
| 94 | assert.equal(out.value, null); |
| 95 | assert.match(out.error, /no page/); |
| 96 | }); |
| 97 | |
| 98 | test("a tab outside a split is the only target", async () => { |
| 99 | const { api } = fakeApi(() => null); |
| 100 | const targets = await Split.pickTargets(api, { id: 7, url: "https://example.com/" }); |
| 101 | assert.deepEqual(targets, [{ id: 7, url: "https://example.com/" }]); |
| 102 | }); |
| 103 | |
| 104 | test("a split's other half comes back too, with the active tab first", async () => { |
| 105 | const api = { |
| 106 | scripting: { executeScript: () => Promise.resolve([]) }, |
| 107 | tabs: { query: () => Promise.resolve([{ id: 3 }, { id: 9 }]) }, |
| 108 | }; |
| 109 | const targets = await Split.pickTargets(api, { id: 9, splitViewId: 4 }); |
| 110 | assert.deepEqual( |
| 111 | targets.map((t) => t.id), |
| 112 | [9, 3], |
| 113 | ); |
| 114 | }); |
| 115 | |
| 116 | test("a browser that rejects the splitViewId query falls back to the active tab", async () => { |
| 117 | const api = { |
| 118 | scripting: { executeScript: () => Promise.resolve([]) }, |
| 119 | tabs: { |
| 120 | query: () => Promise.reject(new Error("Unexpected property: 'splitViewId'")), |
| 121 | }, |
| 122 | }; |
| 123 | const targets = await Split.pickTargets(api, { id: 9, splitViewId: 4 }); |
| 124 | assert.deepEqual( |
| 125 | targets.map((t) => t.id), |
| 126 | [9], |
| 127 | ); |
| 128 | }); |