diff --git a/Makefile b/Makefile index 41becc30..dbb9ef96 100644 --- a/Makefile +++ b/Makefile @@ -5,9 +5,10 @@ TARGETS = darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64 windo # The GUI links the platform webview through cgo, so it is built natively. # `nogui` builds the terminal-only magpie, which cross-compiles anywhere. +# macOS 12 is the oldest Go (1.25 and later) runs on; Info.plist says so too. ifeq ($(shell uname -s),Darwin) - export CGO_CFLAGS = -mmacosx-version-min=11.0 - export CGO_LDFLAGS = -mmacosx-version-min=11.0 + export CGO_CFLAGS = -mmacosx-version-min=12.0 + export CGO_LDFLAGS = -mmacosx-version-min=12.0 endif # On Linux, GTK 3 and WebKitGTK 4.1: older distributions have them, where # Wails' default GTK 4 and WebKitGTK 6 would leave them out. diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist index c7787209..62296b9c 100644 --- a/build/darwin/Info.plist +++ b/build/darwin/Info.plist @@ -11,7 +11,7 @@ CFBundlePackageTypeAPPL CFBundleShortVersionString@VERSION@ CFBundleVersion@VERSION@ - LSMinimumSystemVersion11.0 + LSMinimumSystemVersion12.0 LSUIElement NSHighResolutionCapable CFBundleURLTypes diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 44ad04c1..a7ffb1ff 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -278,11 +278,13 @@ body.window .row .ic { width: 22px; height: 22px; } body.window .ag-handle { width: 28px; height: 28px; } .ag-handle > .ic, .ag-handle > .grip { grid-area: 1 / 1; transition: opacity .15s, transform .2s cubic-bezier(.22, 1, .36, 1); } .ag-handle > .grip { display: grid; place-items: center; color: var(--muted); opacity: 0; transform: scale(.8); } -.ag-handle:hover, .ag-handle.open, .ag-handle:focus-visible { background: var(--pill); } -.ag-handle:focus-visible { outline: 1.5px solid var(--fg-2); outline-offset: 0; } +.ag-handle:hover, .ag-handle.open { background: var(--pill); } +.ag-handle:focus-visible { background: var(--pill); outline: 1.5px solid var(--fg-2); outline-offset: 0; } .agent-fold .ag-handle { cursor: default; } -#agents > .row .ag-handle:is(:hover, .open, :focus-visible) > .ic, #agents > .row.dragging .ag-handle > .ic { opacity: 0; transform: scale(.8); } -#agents > .row .ag-handle:is(:hover, .open, :focus-visible) > .grip, #agents > .row.dragging .ag-handle > .grip { opacity: 1; transform: none; } +#agents > .row .ag-handle:is(:hover, .open) > .ic, #agents > .row.dragging .ag-handle > .ic { opacity: 0; transform: scale(.8); } +#agents > .row .ag-handle:focus-visible > .ic { opacity: 0; transform: scale(.8); } +#agents > .row .ag-handle:is(:hover, .open) > .grip, #agents > .row.dragging .ag-handle > .grip { opacity: 1; transform: none; } +#agents > .row .ag-handle:focus-visible > .grip { opacity: 1; transform: none; } /* one hidden by hand reads as put away, with its way back beside its name */ .agent-fold .row.put-away .ag-handle > .ic, .agent-fold .row.put-away .name { opacity: .6; } .agent-fold .row.put-away .who { display: flex; align-items: center; gap: 8px; } @@ -804,7 +806,7 @@ body.panel .profiles { flex-wrap: wrap; align-items: center; gap: 8px; padding: body.panel .profiles > .label { display: none; } body.panel .profiles .chips { flex: 1 1 100%; flex-wrap: wrap; } body.panel .profiles .chips .hint { white-space: normal; line-height: 1.4; padding: 0 4px; color: var(--muted); } -body.panel .profiles:has(> .chip-input) .chips .hint { display: none; } +body.panel .profiles.naming .chips .hint { display: none; } body.panel .profiles #save, body.panel .profiles > .chip-input { margin: 0; } body.panel .profiles #save { height: 25px; } /* the field's: the line keeps its height as it opens */ @@ -1198,7 +1200,8 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .acc.in-use .dot { background: var(--fg); box-shadow: none; color: var(--card); } .acc .using { font-size: 11px; color: var(--fg-2); padding: 2px 8px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--line); } .acc .text.quiet { color: var(--muted); opacity: 0; transition: opacity .15s; } -.acc:hover .text.quiet, .acc .text.quiet:focus-visible { opacity: 1; } +.acc:hover .text.quiet { opacity: 1; } +.acc .text.quiet:focus-visible { opacity: 1; } .acc.new { animation: sub-in .5s cubic-bezier(.2, .8, .2, 1); } .acc.new .dot { animation: copy-pop .45s cubic-bezier(.2, .8, .2, 1); } @keyframes sub-in { from { opacity: 0; transform: translateY(-4px); background: var(--pill-hover); } } @@ -1228,7 +1231,8 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .acc .proto svg { opacity: .7; transition: transform .15s var(--ease); } .acc .proto.open svg { transform: rotate(180deg); } .acc .proto:not(.set) { opacity: 0; background: none; box-shadow: inset 0 0 0 1px var(--line); } -.acc:hover .proto, .acc .proto:focus-visible, .acc .proto.open, .acc.adding .proto { opacity: 1; } +.acc:hover .proto, .acc .proto.open, .acc.adding .proto { opacity: 1; } +.acc .proto:focus-visible { opacity: 1; } .acc .proto.set { color: var(--fg-2); } .acc .proto:hover, .acc .proto.open { color: var(--fg); background: var(--pill-hover); } .acc .n, .acc .rename { flex: 0 1 auto; } @@ -1242,13 +1246,13 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .pop.proto-menu { width: 262px; max-height: none; padding: 4px; border: 1px solid var(--line); display: grid; gap: 1px; } .proto-menu .pm-head { padding: 6px 8px 5px; font-size: 11px; color: var(--muted); } .proto-menu .pm-item { display: flex; align-items: flex-start; gap: 7px; width: 100%; padding: 6px 8px 7px; border: 0; border-radius: 7px; background: none; color: var(--fg); font: inherit; text-align: left; cursor: default; outline: none; } -.proto-menu .pm-item:focus-visible, .proto-menu .pm-item:hover, .proto-menu .pm-item:focus { background: var(--pill); } +.proto-menu .pm-item:hover, .proto-menu .pm-item:focus { background: var(--pill); } .proto-menu .pm-tick { width: 14px; height: 16px; flex: none; display: grid; place-items: center; color: var(--fg); } .proto-menu .pm-words { display: grid; gap: 1px; min-width: 0; } .proto-menu .pm-name { font-size: 12.5px; font-weight: 500; line-height: 16px; } .proto-menu .pm-note { font-size: 11px; color: var(--muted); line-height: 15px; } /* a subscription's allowance, under its name */ -.accts .acc:has(.aq) { flex-wrap: wrap; row-gap: 3px; padding-top: 7px; padding-bottom: 8px; } +.accts .acc.with-aq { flex-wrap: wrap; row-gap: 3px; padding-top: 7px; padding-bottom: 8px; } .acc .aq { flex-basis: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding-left: 25px; font-size: 10.5px; color: var(--muted); min-height: 14px; } .acc .aq-w { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } .acc .aq-track { width: 54px; height: 4px; border-radius: 2px; background: var(--pill); overflow: hidden; } @@ -1367,7 +1371,7 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } font-size: 12.5px; outline: none; width: 100%; min-width: 0; transition: border-color .12s, box-shadow .12s; } -.editor input:focus, .editor input:focus-visible, .editor .headers textarea.json:focus, .editor .headers textarea.json:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); outline: none; } +.editor input:focus, .editor .headers textarea.json:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); outline: none; } .editor input::placeholder { color: var(--faint); } .editor .pair { display: flex; gap: 8px; align-items: center; } .editor .pair input:first-child { flex: 1; } @@ -1729,3 +1733,12 @@ body.panel .modal { padding: 16px 12px; } .si-bar i { width: 100%; opacity: .5; } } .sess-indexing.known .si-bar i { animation: none; transform: none; opacity: 1; transition: width .4s ease-out; background: linear-gradient(90deg, #06b6d4, var(--accent), #ec4899); } +/* Safari before 16.2 (macOS 12's, #220) has no color-mix(): a declaration + with one is dropped, and where nothing else is under it the page would go + clear. The panel's paint (which tintPanel hands the system), and the + floating status, take a plain colour there instead. */ +@supports not (color: color-mix(in srgb, red 50%, blue)) { + body.panel { --panel-tint: var(--bg); } + .status.ok, .status.err, .status.warn { background: var(--bg); } + .status.lift { background: var(--card); } +} diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 01cd7297..e7acec3d 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -438,6 +438,7 @@ function renderProfiles() { const chips = $("#profiles"); chips.replaceChildren(); $(".profiles > .chip-input")?.remove(); // a name field open goes with the list it was for + $(".profiles").classList.remove("naming"); $("#save").textContent = t("+ Save current"); if (!state.profiles.length) chips.append(el("span", "hint", t("none yet · save the setup to switch back in one click"))); for (const p of state.profiles) { @@ -1629,7 +1630,7 @@ const saveCurrent = $("#save"); const saveField = () => $(".profiles > .chip-input"); const closeSave = (input) => { input.remove(); - if (!saveField()) saveCurrent.textContent = t("+ Save current"); + if (!saveField()) { saveCurrent.textContent = t("+ Save current"); $(".profiles").classList.remove("naming"); } }; saveCurrent.onmousedown = (e) => { if (saveField()) e.preventDefault(); }; // the field keeps focus saveCurrent.onclick = () => { @@ -1648,6 +1649,7 @@ saveCurrent.onclick = () => { }; input.onblur = () => setTimeout(() => closeSave(input), 100); saveCurrent.before(input); + $(".profiles").classList.add("naming"); // for the panel's hint: :has() came in Safari 15.4 (#220) saveCurrent.textContent = t("Save"); input.focus({ preventScroll: true }); }; @@ -2164,11 +2166,23 @@ const KEYWORDS = { function highlight(code, lang) { const re = lang === "node" ? /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\/\/.*)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)|(\s+|.)/g - : /("(?:[^"\\]|\\.)*"|'[^']*'|(?<==)\S+)|(#.*)|(\b\d+(?:\.\d+)?\b(?=[,\s\]}]))|(-{1,2}[A-Za-z][\w-]*)|([A-Z][A-Z0-9_]+)(?==)|([A-Za-z_][\w.]*)(?=\s*\()|([A-Za-z_][\w.]*)|(\\\n|`\n)|(\s+|.)/g; + : /("(?:[^"\\]|\\.)*"|'[^']*')|(#.*)|(\b\d+(?:\.\d+)?\b(?=[,\s\]}]))|(-{1,2}[A-Za-z][\w-]*)|([A-Z][A-Z0-9_]+)(?==)|([A-Za-z_][\w.]*)(?=\s*\()|([A-Za-z_][\w.]*)|(\\\n|`\n)|(\s+|.)/g; const out = document.createDocumentFragment(); const kw = KEYWORDS[lang] || KEYWORDS.shell; let m; while ((m = re.exec(code))) { + // an unquoted value after "=" is a string too; found here, as a + // lookbehind in the pattern is a syntax error before Safari 16.4 (#220) + if (lang !== "node" && !m[1] && code[m.index - 1] === "=") { + const v = /\S+/y; + v.lastIndex = m.index; + const s = v.exec(code); + if (s) { + out.append(el("span", "tk-s", s[0])); + re.lastIndex = m.index + s[0].length; + continue; + } + } let cls = ""; if (lang === "node") { if (m[1]) cls = "s"; else if (m[2]) cls = "c"; else if (m[3]) cls = "n"; @@ -4190,6 +4204,7 @@ function renderAccounts(a) { row.append(forget, use); } row.append(accountQuota(l.lapsed ? { [l.user]: { error: l.lapsed } } : quota, l.user)); + row.classList.add("with-aq"); // not :has(.aq), which Safari 15.0 lacks (#220) list.append(row); } if (a.agent === "codex" && providers?.codexDaemon) list.append(renderCodexDaemon(providers.codexDaemon)); diff --git a/internal/gui/assets/compat.js b/internal/gui/assets/compat.js new file mode 100644 index 00000000..841c4a5b --- /dev/null +++ b/internal/gui/assets/compat.js @@ -0,0 +1,35 @@ +// The page runs in the system's WebKit: on macOS 12 that can be Safari +// 15.0's (#220), older than what the rest of the code was written against. +// Loaded before every other script, this fills in the few built-ins they +// use that came later, each only where it is missing. The syntax the +// scripts may use is held by tests/old-webkit.test.cjs. + +// Safari 15.4 +if (!Array.prototype.at) { + Object.defineProperty(Array.prototype, "at", { + configurable: true, writable: true, + value(i) { i = Math.trunc(i) || 0; if (i < 0) i += this.length; return this[i]; }, + }); +} +if (!Array.prototype.findLast) { + Object.defineProperty(Array.prototype, "findLast", { + configurable: true, writable: true, + value(f, self) { for (let i = this.length - 1; i >= 0; i--) if (f.call(self, this[i], i, this)) return this[i]; }, + }); +} +if (!Array.prototype.findLastIndex) { + Object.defineProperty(Array.prototype, "findLastIndex", { + configurable: true, writable: true, + value(f, self) { for (let i = this.length - 1; i >= 0; i--) if (f.call(self, this[i], i, this)) return i; return -1; }, + }); +} +if (!Object.hasOwn) { + Object.defineProperty(Object, "hasOwn", { + configurable: true, writable: true, + value: (o, k) => Object.prototype.hasOwnProperty.call(o, k), + }); +} +// the page only copies plain data (what the API sent), so JSON is enough +if (!window.structuredClone) { + window.structuredClone = (v) => (v === undefined ? v : JSON.parse(JSON.stringify(v))); +} diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 3b9d918b..a15a7d3f 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -267,6 +267,7 @@ + diff --git a/internal/gui/assets/library.css b/internal/gui/assets/library.css index dcf1fe25..ab8cef08 100644 --- a/internal/gui/assets/library.css +++ b/internal/gui/assets/library.css @@ -377,3 +377,5 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col .lib-subhead .sub { margin: 0; font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums; } .lib-subhead .lib-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex: none; } .lib-row.lib-subhead:hover { background: color-mix(in srgb, var(--card-2) 70%, var(--pill)); } +/* no color-mix() before Safari 16.2 (#220): the sticky head stays opaque */ +@supports not (color: color-mix(in srgb, red 50%, blue)) { .lib-head { background: var(--bg); } } diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js index 4ad70587..8298c4e3 100644 --- a/internal/gui/assets/library.js +++ b/internal/gui/assets/library.js @@ -159,8 +159,11 @@ // filter, which is a sum over each colour (blending and gradients // mix colours in sRGB, as the filter does, so they come out the same) // and drawn as sharp as the drawing itself - const s = (await r.text()).replace(/(? k + eq + q + greyColour(v) + q) - .replace(/(? k + eq + greyColour(v)); + // (a name inside another, as data-fill, is left: checked by hand, as a + // lookbehind in the pattern is a syntax error before Safari 16.4, #220) + const inWord = (s, at) => /[\w-]/.test(s[at - 1] || ""); + const s = (await r.text()).replace(/(fill|stroke|stop-color|flood-color|lighting-color|color)(\s*=\s*)(["'])([^"']*)\3/g, (all, k, eq, q, v, at, s) => inWord(s, at) ? all : k + eq + q + greyColour(v) + q) + .replace(/(fill|stroke|stop-color|flood-color|lighting-color|color)(\s*:\s*)([^;"'}]+)/g, (all, k, eq, v, at, s) => inWord(s, at) ? all : k + eq + greyColour(v)); if (/<(image|feColorMatrix|feComponentTransfer|feTurbulence)\b/.test(s)) return null; // a colour the sum can't reach blob = new Blob([s], { type: "image/svg+xml" }); } else { diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css index bdee455b..e1a0447b 100644 --- a/internal/gui/assets/routing.css +++ b/internal/gui/assets/routing.css @@ -290,7 +290,8 @@ .rt-classifier .w { color: var(--muted); font-size: 12px; } .rt-classifier .hint { flex-basis: 100%; } /* the field is the pill: the editor's boxed input (its border and focus ring) stays off it */ -.rt-cond input, .editor .rt-cond input[type="text"], .editor .rt-cond input:focus, .editor .rt-cond input:focus-visible { box-shadow: none; border-radius: 0; } +.rt-cond input, .editor .rt-cond input[type="text"], .editor .rt-cond input:focus { box-shadow: none; border-radius: 0; } +.editor .rt-cond input:focus-visible { box-shadow: none; border-radius: 0; } .rt-cond:focus-within { border-style: solid; border-color: var(--accent); color: var(--fg); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); } .rt-cond input, .editor .rt-cond input[type="text"] { width: 7ch; height: 20px; padding: 0 2px; border: 0; background: transparent; color: inherit; font: inherit; font-variant-numeric: tabular-nums; outline: none; -moz-appearance: textfield; } .rt-cond input::-webkit-outer-spin-button, .rt-cond input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } @@ -388,3 +389,30 @@ .pr-to .swap { flex: none; max-width: 45%; overflow: hidden; text-overflow: ellipsis; font-size: 10.5px; line-height: 16px; padding: 0 5px; border-radius: 5px; color: var(--amber); background: var(--amber-soft); white-space: nowrap; } .pr-req .meta { justify-self: end; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; } @media (prefers-reduced-motion: reduce) { .pr-req.wait .pr-to > i, .ps-dst.wait > i, .ps-hub.busy, .ps-dot.wait { animation: none; } } +/* Safari before 16 (macOS 12's, #220) has neither container queries nor + subgrid: there the narrow layouts above go by the window's width (the + tray panel is narrower than any of them), and a request's row sets out + its own columns */ +@supports not (container-type: inline-size) { + @media (max-width: 560px) { + .rt-stage { grid-template-columns: 1fr 1fr; column-gap: 28px; row-gap: 26px; padding: 16px 0; } + .rt-accts { grid-column: 1 / -1; padding-left: 22px; } + .rt-accts li { grid-template-columns: 10px minmax(0, 1fr); } + .rt-accts em { grid-column: 2; white-space: normal; } + .rt-accts .bar { grid-column: 2; } + .rt-stats { gap: 14px; } + .rt-gsec .row-head .note { display: none; } + .rt-gedit { grid-template-columns: minmax(0, 1fr); gap: 4px; } + .rt-gedit label { text-align: left; padding-top: 8px; } + .rt-gedit .segs .opt { padding-left: 9px; padding-right: 9px; } + } +} +@supports not (grid-template-columns: subgrid) { + .rt-req { grid-template-columns: auto fit-content(36%) minmax(0, 1fr) auto; } + @media (max-width: 560px) { + .rt-reqs { grid-template-columns: auto minmax(0, 1fr); } + .rt-req { grid-template-columns: auto minmax(0, 1fr); row-gap: 2px; } + .rt-req .to { grid-column: 2; } + .rt-req .meta { grid-column: 2; text-align: left; } + } +} diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 7707468a..9c0ce972 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -117,6 +117,21 @@ kiro-cli's own sign-in behind one of magpie's, then alone and first: its Remove is there both times, its title saying magpie only hides it and it shows again when Kiro signs in anew, and it posts login/forget; magpie's own account in use first has no Remove; in English and Chinese. +`old-webkit.test.cjs` holds the page to Safari 15.0, the WebKit macOS 12 can +have (#220: a regex lookbehind in app.js, a syntax error before Safari 16.4, +left the panel with its headings and nothing working). Every script in +`assets/` is parsed (with the Babel parser Playwright bundles) and may have +no regex lookbehind, v flag or modifiers, class static block, decorator, +import attributes or `using`, in a literal or `new RegExp("…")`; no built-in +newer than 15.0 (`.at`, `findLast`, `structuredClone`, `Object.hasOwn`, +`toSorted`, Set methods, `Object.groupBy`, `Promise.withResolvers` …) unless +`compat.js`, loaded before the rest, fills it in; the styles no `:has()`, no +`:focus-visible` in a list with other selectors, no nesting or dvh units, and +container queries, `subgrid` columns and a `color-mix()` custom property +only with an `@supports not (…)` fallback in the same file. Then, in WebKit +with those built-ins deleted, the panel's tabs and the window's pages are +drawn with no page error. It cannot run an old engine, so syntax is judged +by the parse, not by running it. `usage-ledger.test.cjs` opens the Usage page's Requests, a row per request from a faked `/api/usage/requests`: the columns, the model asked for, the @@ -145,7 +160,7 @@ projects. With Node.js and Playwright available: ```sh -node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/routing-steady.test.cjs +node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs ``` If Playwright is installed outside the repository, set `NODE_PATH` to the diff --git a/internal/gui/tests/old-webkit.test.cjs b/internal/gui/tests/old-webkit.test.cjs new file mode 100644 index 00000000..75beeb67 --- /dev/null +++ b/internal/gui/tests/old-webkit.test.cjs @@ -0,0 +1,235 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// The page runs in the system's WebKit, which on macOS 12 can be Safari +// 15.0's (#220 uclort: on 12.7.6 the panel showed its tabs and headings and +// nothing else, no button working — a regex lookbehind in app.js is a +// syntax error before Safari 16.4, so the whole file never ran). What the +// scripts and styles may use is held to Safari 15.0 here: +// - every script parses, and none has syntax newer than it: a regex +// lookbehind (16.4) or v flag (17) or modifiers, a class static block +// (16.4), decorators, import attributes, `using`; +// - no built-in newer than it is used unless compat.js (loaded before the +// rest) fills it in; +// - the styles have no :has() (15.4), :focus-visible only in rules of its +// own (a list with it and more drops them all before 15.4), no nesting, no dvh +// units, and container queries, subgrid and a color-mix() custom property +// only with an @supports fallback in the same file; +// - in WebKit with those built-ins taken away, the panel and the window +// draw every page with no error. +// Syntax is parsed with the Babel parser Playwright bundles, so no other +// module is needed. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { webkit } = require("playwright"); +const { babelParse } = require("playwright/lib/transform/babelBundle"); + +const assets = path.resolve(__dirname, "../assets"); +const scripts = async () => (await fs.readdir(assets)).filter((f) => f.endsWith(".js")).sort(); +const styles = async () => (await fs.readdir(assets)).filter((f) => f.endsWith(".css")).sort(); +const read = (f) => fs.readFile(path.join(assets, f), "utf8"); + +// Syntax Safari 15.0 can't parse; one of these fails the whole file. +const LOOKBEHIND = /\(\?<[=!]/; +const MODIFIERS = /\(\?[ims]*-?[ims]+:/; +function badSyntax(ast) { + const found = []; + const at = (n) => n.loc ? `:${n.loc.start.line}` : ""; + const regex = (n, pattern, flags) => { + if (LOOKBEHIND.test(pattern)) found.push(`${at(n)} regex lookbehind (Safari 16.4): /${pattern}/${flags}`); + if (/v/.test(flags)) found.push(`${at(n)} regex v flag (Safari 17): /${pattern}/${flags}`); + if (MODIFIERS.test(pattern)) found.push(`${at(n)} regex modifiers: /${pattern}/${flags}`); + }; + const walk = (n) => { + if (!n || typeof n.type !== "string") return; + switch (n.type) { + case "RegExpLiteral": regex(n, n.pattern, n.flags); break; + case "StaticBlock": found.push(`${at(n)} class static block (Safari 16.4)`); break; + case "Decorator": found.push(`${at(n)} decorator`); break; + case "ClassAccessorProperty": found.push(`${at(n)} accessor field`); break; + case "ImportAttribute": found.push(`${at(n)} import attributes`); break; + case "VariableDeclaration": if (/using/.test(n.kind)) found.push(`${at(n)} ${n.kind} declaration`); break; + case "NewExpression": case "CallExpression": { + // new RegExp("…") takes the same syntax, a SyntaxError when it runs + const a = n.arguments?.[0]; + if (n.callee?.type === "Identifier" && n.callee.name === "RegExp" && a) { + const text = a.type === "StringLiteral" ? a.value : a.type === "TemplateLiteral" ? a.quasis.map((q) => q.value.cooked).join("") : null; + const flags = n.arguments[1]?.type === "StringLiteral" ? n.arguments[1].value : ""; + if (text != null) regex(n, text, flags); + } + break; + } + } + for (const k of Object.keys(n)) { + if (k === "loc" || k === "extra" || k === "leadingComments" || k === "trailingComments") continue; + const v = n[k]; + if (Array.isArray(v)) v.forEach(walk); + else if (v && typeof v === "object") walk(v); + } + }; + walk(ast.program); + return found; +} + +// Built-ins Safari 15.0 lacks. `fill` is what compat.js tests for when it +// fills one in; the others must not be used at all. +const BUILTINS = [ + { name: "Array.prototype.at", re: /\.at\(/, since: "15.4", fill: "Array.prototype.at" }, + { name: "findLast", re: /\.findLast\(/, since: "15.4", fill: "Array.prototype.findLast" }, + { name: "findLastIndex", re: /\.findLastIndex\(/, since: "15.4", fill: "Array.prototype.findLastIndex" }, + { name: "Object.hasOwn", re: /\bObject\.hasOwn\(/, since: "15.4", fill: "Object.hasOwn" }, + { name: "structuredClone", re: /\bstructuredClone\(/, since: "15.4", fill: "window.structuredClone" }, + { name: "toSorted/toReversed/toSpliced", re: /\.to(Sorted|Reversed|Spliced)\(/, since: "16" }, + { name: "Array.prototype.with", re: /\]\.with\(|\bArray\.prototype\.with\b/, since: "16" }, + { name: "String isWellFormed/toWellFormed", re: /\.(is|to)WellFormed\(/, since: "16.4" }, + { name: "AbortSignal.timeout/any", re: /\bAbortSignal\.(timeout|any)\(/, since: "16/17.4" }, + { name: "URL.canParse", re: /\bURL\.canParse\(/, since: "17" }, + { name: "Set methods", re: /\.(union|intersection|difference|symmetricDifference|isSubsetOf|isSupersetOf|isDisjointFrom)\(/, since: "17" }, + { name: "Array.fromAsync", re: /\bArray\.fromAsync\(/, since: "16.4" }, + { name: "Object.groupBy/Map.groupBy", re: /\b(Object|Map)\.groupBy\(/, since: "17.4" }, + { name: "Promise.withResolvers", re: /\bPromise\.withResolvers\(/, since: "17.4" }, + { name: "Iterator helpers", re: /\bIterator\.from\(|\.(keys|values|entries)\(\)\.(map|filter|take|drop|toArray|reduce|some|every|find|forEach|flatMap)\(/, since: "18.4" }, + { name: "checkVisibility", re: /\.checkVisibility\(/, since: "17.4" }, + { name: "popover", re: /\.(showPopover|hidePopover|togglePopover)\(|\.popover\s*=/, since: "17" }, + { name: "RegExp.escape", re: /\bRegExp\.escape\(/, since: "18.2" }, + { name: "Promise.try", re: /\bPromise\.try\(/, since: "18.2" }, +]; +const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:\\"'`])\/\/.*$/gm, "$1"); + +test("every script parses with no syntax newer than Safari 15.0", async () => { + const bad = []; + for (const f of await scripts()) { + let ast; + try { ast = babelParse(await read(f), f, false); } catch (e) { bad.push(`${f}: does not parse: ${e.message.split("\n")[0]}`); continue; } + for (const x of badSyntax(ast)) bad.push(f + x); + } + assert.deepEqual(bad, []); +}); + +test("the checks catch what broke macOS 12", () => { + const found = (src) => badSyntax(babelParse(src, "x.js", false)); + assert.equal(found('const re = /("[^"]*"|(?<==)\\S+)/g;').length, 1, "lookbehind"); + assert.equal(found("s.replace(/(?a)(?=b)(?!c)\\k/g; a ??= b; class B { #x; #m() {} }'), [], "what Safari 15 has"); +}); + +test("no built-in newer than Safari 15.0 unless compat.js fills it in", async () => { + const compat = await read("compat.js"); + const bad = []; + for (const f of await scripts()) { + if (f === "compat.js") continue; + const lines = stripComments(await read(f)).split("\n"); + for (const b of BUILTINS) { + if (b.fill && compat.includes(`if (!${b.fill})`)) continue; + lines.forEach((l, i) => { if (b.re.test(l)) bad.push(`${f}:${i + 1} ${b.name} (Safari ${b.since}): ${l.trim().slice(0, 100)}`); }); + } + } + assert.deepEqual(bad, []); +}); + +test("compat.js is the first of the page's own scripts", async () => { + const html = await read("index.html"); + const srcs = [...html.matchAll(/ m[1]).filter((s) => s !== "boot.js"); + assert.equal(srcs[0], "compat.js", "loaded first: " + srcs.join(", ")); + for (const f of await scripts()) assert(srcs.includes(f), f + " is not loaded by index.html"); +}); + +test("the styles hold up in Safari 15.0", async () => { + const bad = []; + for (const f of await styles()) { + const css = (await read(f)).replace(/\/\*[\s\S]*?\*\//g, ""); + const lineOf = (i) => css.slice(0, i).split("\n").length; + const each = (re, what) => { for (const m of css.matchAll(re)) bad.push(`${f}:~${lineOf(m.index)} ${what}: ${m[0].trim().slice(0, 90)}`); }; + each(/:has\(/g, ":has() (Safari 15.4)"); + each(/[\d.]+(dvh|svh|lvh|dvw|svw|lvw)\b/g, "dynamic viewport units (Safari 15.4)"); + each(/[^{}]*&[^{}]*\{/g, "nesting (Safari 16.5)"); + // a selector list with :focus-visible and more: all of it is dropped + for (const m of css.matchAll(/([^{}]+)\{/g)) { + const sel = m[1].trim(); + if (sel.startsWith("@")) continue; + const parts = sel.split(","); + if (parts.some((p) => p.includes(":focus-visible")) && !parts.every((p) => p.includes(":focus-visible"))) bad.push(`${f}:~${lineOf(m.index)} :focus-visible in a selector list (Safari 15.4 drops the rule): ${sel.slice(0, 90)}`); + if (/:is\([^)]*:focus-visible/.test(sel)) bad.push(`${f}:~${lineOf(m.index)} :focus-visible inside :is(): ${sel.slice(0, 90)}`); + } + if (/@container\b/.test(css) && !/@supports not \(container-type: inline-size\)/.test(css)) bad.push(`${f}: @container (Safari 16) with no @supports not (container-type: inline-size) fallback`); + if (/:\s*subgrid\b/.test(css) && !/@supports not \(grid-template-columns: subgrid\)/.test(css)) { + // a subgrid that only lines rows up across cards may go without + for (const m of css.matchAll(/grid-template-columns:\s*subgrid/g)) bad.push(`${f}:~${lineOf(m.index)} grid-template-columns: subgrid (Safari 16) with no fallback`); + } + // a custom property is never checked: one holding color-mix() goes + // invalid where it is used, and whatever it painted goes clear + if (/--[\w-]+:\s*color-mix\(/.test(css) && !/@supports not \(color: color-mix\(/.test(css)) bad.push(`${f}: a custom property with color-mix() (Safari 16.2) and no @supports not (color: color-mix(…)) fallback`); + } + assert.deepEqual(bad, []); +}); + +// In WebKit with what Safari 15.0 lacks taken away, every page is drawn with +// no error: compat.js fills them in, and nothing else needs them. +const TAKEN = ` + for (const k of ["at", "findLast", "findLastIndex", "toSorted", "toReversed", "toSpliced", "with"]) delete Array.prototype[k]; + delete Object.hasOwn; delete window.structuredClone; delete Promise.withResolvers; delete Object.groupBy; +`; +const state = { + agents: [{ id: "codex", name: "Codex", path: "/test/config.toml", fields: [{ key: "model", label: "model", value: "gpt-6", options: [{ value: "gpt-6", label: "gpt-6" }] }] }], + profiles: [{ name: "work" }], settings: { lang: "en", theme: "light" }, +}; +async function serve(route) { + const url = new URL(route.request().url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: 'window.bootPrefs = {lang:"en",theme:"light",web:true};' }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state); + if (url.pathname === "/api/usage/quotas") return json([]); + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/providers") return json({ providers: [], presets: [], gateway: { running: true } }); + if (url.pathname === "/api/gateway/trace") { + if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3)); + return json({ mine: true, now: new Date().toISOString(), seq: 0, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] }); + } + if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404 }); } +} + +test("with Safari 15.0's built-ins, the panel and the window draw every page", async () => { + const browser = await webkit.launch(); + try { + for (const mode of ["panel", "window"]) { + const page = await browser.newPage({ viewport: mode === "panel" ? { width: 380, height: 640 } : { width: 1100, height: 760 } }); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.addInitScript(TAKEN); + await page.route("**/*", serve); + await page.goto(`http://magpie.test/?mode=${mode}`); + await page.locator("#agents .row").first().waitFor(); + assert.equal(await page.evaluate(() => [1, 2, 3].findLast((x) => x < 3)), 2, "compat.js fills findLast in"); + assert.deepEqual(await page.evaluate(() => { const a = { b: [1] }, c = structuredClone(a); return [c.b[0], c !== a && c.b !== a.b]; }), [1, true]); + assert.equal(await page.evaluate(() => [1, 2, 3].at(-1)), 3); + if (mode === "panel") { + assert(await page.locator("#ptabs").isVisible(), "the panel's tabs are the page's own, drawn by app.js"); + for (const tab of ["usage", "routing", "agents", "profiles"]) { + const b = page.locator(`#ptabs [data-ptab="${tab}"]`); + if (await b.isVisible()) await b.click(); + await page.waitForTimeout(150); + } + await page.locator("#save").click(); + assert(await page.locator(".profiles.naming > .chip-input").isVisible(), "Save current opens the name field"); + } else { + for (const v of ["providers", "gateway", "routing", "usage", "library", "agents"]) { + await page.locator(`#nav [data-view="${v}"]`).click(); + await page.locator(`#view-${v}`).waitFor(); + await page.waitForTimeout(200); + } + } + assert.deepEqual(errors, [], mode + ": no error on the page"); + await page.close(); + } + } finally { + await browser.close(); + } +}); diff --git a/site/public/index.html b/site/public/index.html index 1cefcf66..38f5a9d6 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -370,7 +370,7 @@ footer a:hover { color: var(--ink); } Install from terminal -
Free · Open source · macOS 11+ · Intel Mac · Windows & Linux
+
Free · Open source · macOS 12+ · Intel Mac · Windows & Linux
Codex's model picker in magpie, listing DeepSeek, Kimi and GLM models