Public: anyone can read this; the crew can change it.
The pill joins the other side's origin to this page's path, so a `//` path stays on the app #6278
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6269-the-pill-joins-the-other-side-s-origin-t → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +110
−3
src/apps/toolbar.ts +19 −3
| @@ -72,10 +72,11 @@ export const toolbarJs = `(function () { | ||
| 72 | 72 | if (d.env === "production") line("Production"); |
| 73 | 73 | else if (d.pull) line("Preview of pull #" + d.pull); |
| 74 | 74 | if (d.title) line(d.title); |
| 75 | var LS = "color:#8ab4ff;text-decoration:none;display:flex;align-items:center;min-height:44px"; | |
| 75 | 76 | var link = document.createElement("a"); |
| 76 | 77 | link.textContent = "Home"; |
| 77 | 78 | link.rel = "noopener"; |
| 78 | link.style.cssText = "color:#8ab4ff;text-decoration:none;display:flex;align-items:center;min-height:44px"; | |
| 79 | link.style.cssText = LS; | |
| 79 | 80 | try { |
| 80 | 81 | var target = new URL(home); |
| 81 | 82 | if (target.origin === new URL(me.src).origin) link.href = target.href; |
| @@ -247,10 +248,25 @@ export const toolbarJs = `(function () { | ||
| 247 | 248 | ta.target = "_blank"; |
| 248 | 249 | ta.rel = "noopener"; |
| 249 | 250 | ta.href = t.href; |
| 250 | ta.style.cssText = "color:#8ab4ff;text-decoration:none;display:flex;align-items:center;min-height:44px"; | |
| 251 | ta.style.cssText = LS; | |
| 251 | 252 | crew.appendChild(ta); |
| 252 | 253 | } |
| 253 | 254 | } |
| 255 | // The other side: origin plus this path as text, so "//" cannot leave it. | |
| 256 | var sd = c.side; | |
| 257 | if (sd && typeof sd.label === "string" && typeof sd.url === "string") { | |
| 258 | try { | |
| 259 | var so = new URL(sd.url); | |
| 260 | if (so.protocol === "https:" || so.protocol === location.protocol) { | |
| 261 | var sa = document.createElement("a"); | |
| 262 | sa.textContent = sd.label; | |
| 263 | sa.rel = "noopener"; | |
| 264 | sa.href = so.origin + location.pathname + location.search; | |
| 265 | sa.style.cssText = LS; | |
| 266 | crew.appendChild(sa); | |
| 267 | } | |
| 268 | } catch (e) {} | |
| 269 | } | |
| 254 | 270 | if (typeof c.who !== "string" || typeof c.url !== "string") return; |
| 255 | 271 | var u = new URL(c.url); |
| 256 | 272 | if (u.origin !== forge) return; |
| @@ -258,7 +274,7 @@ export const toolbarJs = `(function () { | ||
| 258 | 274 | a.textContent = c.who + " is working on this"; |
| 259 | 275 | a.rel = "noopener"; |
| 260 | 276 | a.href = u.href; |
| 261 | a.style.cssText = "color:#8ab4ff;text-decoration:none;display:flex;align-items:center;min-height:44px"; | |
| 277 | a.style.cssText = LS; | |
| 262 | 278 | crew.appendChild(a); |
| 263 | 279 | }) |
| 264 | 280 | .catch(function () {}); |
test/apps-pill-side.test.ts added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | /** | |
| 2 | * The pill's link to the other side (#6269): its origin joined to this page's | |
| 3 | * path and query as text, so a `//` path stays on the other side; drawn only | |
| 4 | * for an https `side.url` or one on the page's own scheme; nothing without a side. | |
| 5 | */ | |
| 6 | ||
| 7 | import { describe, expect, test } from "bun:test"; | |
| 8 | import { toolbarJs } from "../src/apps/toolbar.ts"; | |
| 9 | ||
| 10 | type El = { | |
| 11 | tag: string; | |
| 12 | children: El[]; | |
| 13 | style: Record<string, string>; | |
| 14 | dataset?: Record<string, string>; | |
| 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| 16 | [k: string]: any; | |
| 17 | }; | |
| 18 | ||
| 19 | function el(tag: string): El { | |
| 20 | const e: El = { | |
| 21 | tag, | |
| 22 | children: [], | |
| 23 | style: {}, | |
| 24 | appendChild(c: El) { e.children.push(c); return c; }, | |
| 25 | setAttribute() {}, | |
| 26 | addEventListener() {}, | |
| 27 | attachShadow() { return e.appendChild(el("root")); }, | |
| 28 | }; | |
| 29 | return e; | |
| 30 | } | |
| 31 | ||
| 32 | function walk(e: El, out: El[] = []): El[] { | |
| 33 | out.push(e); | |
| 34 | for (const c of e.children) walk(c, out); | |
| 35 | return out; | |
| 36 | } | |
| 37 | ||
| 38 | async function run(side: unknown, loc: { protocol: string; pathname: string; search: string }) { | |
| 39 | const doc = { | |
| 40 | ...el("document"), | |
| 41 | currentScript: { | |
| 42 | src: "https://forge.test/assets/toolbar.js", | |
| 43 | dataset: { app: "lab", env: "production" }, | |
| 44 | removeAttribute() {}, | |
| 45 | }, | |
| 46 | createElement: el, | |
| 47 | body: el("body"), | |
| 48 | } as unknown as El; | |
| 49 | const g = globalThis as Record<string, unknown>; | |
| 50 | const saved = { document: g.document, location: g.location, fetch: g.fetch, window: g.window }; | |
| 51 | const win = { innerWidth: 800, innerHeight: 600, localStorage: { getItem: () => null, setItem() {} } }; | |
| 52 | g.window = win; | |
| 53 | g.document = doc; | |
| 54 | g.location = { ...loc, origin: "https://app.test", href: "https://app.test" + loc.pathname + loc.search }; | |
| 55 | g.fetch = async () => ({ ok: true, json: async () => ({ side }) }); | |
| 56 | try { | |
| 57 | new Function("window", "document", "location", "fetch", toolbarJs)(win, doc, g.location, g.fetch); | |
| 58 | await new Promise((r) => setTimeout(r, 5)); | |
| 59 | return walk(doc.body) | |
| 60 | .filter((e) => e.tag === "a" && e.href && e.textContent === "Other") | |
| 61 | .map((e) => e.href as string); | |
| 62 | } finally { | |
| 63 | Object.assign(g, saved); | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | const https = { protocol: "https:", pathname: "/a/b", search: "?x=1" }; | |
| 68 | ||
| 69 | describe("the pill's other side", () => { | |
| 70 | test("joins the other origin to this path and query", async () => { | |
| 71 | expect(await run({ label: "Other", url: "https://other.test/ignored?y=2" }, https)).toEqual([ | |
| 72 | "https://other.test/a/b?x=1", | |
| 73 | ]); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a // path stays on the other side's origin", async () => { | |
| 77 | const [href] = await run({ label: "Other", url: "https://other.test/" }, { ...https, pathname: "//evil.test/x", search: "" }); | |
| 78 | expect(new URL(href!).origin).toBe("https://other.test"); | |
| 79 | }); | |
| 80 | ||
| 81 | test("no side, no link", async () => { | |
| 82 | expect(await run(undefined, https)).toEqual([]); | |
| 83 | }); | |
| 84 | ||
| 85 | test("http is drawn only on an http page", async () => { | |
| 86 | expect(await run({ label: "Other", url: "http://other.test" }, https)).toEqual([]); | |
| 87 | expect(await run({ label: "Other", url: "http://other.test" }, { ...https, protocol: "http:" })).toEqual([ | |
| 88 | "http://other.test/a/b?x=1", | |
| 89 | ]); | |
| 90 | }); | |
| 91 | }); |
new above
offline
Fine as is. Consider adding aria-label (label + " (other side)") so screen-reader users know it leaves this origin.