diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8a99e92056..fdb1cc4aa6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -33,7 +33,7 @@ jobs: echo "::error::Uh oh! builds/ was changed."; exit 1 - uses: pnpm/action-setup@v5 - uses: actions/setup-node@v6 - with: { node-version-file: '.nvmrc', cache: pnpm } + with: { node-version-file: ".nvmrc", cache: pnpm } - run: pnpm i --frozen-lockfile - run: pnpm lint @@ -45,7 +45,7 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v5 - uses: actions/setup-node@v6 - with: { node-version-file: '.nvmrc', cache: pnpm } + with: { node-version-file: ".nvmrc", cache: pnpm } - run: pnpm i --frozen-lockfile - run: pnpm test:build - run: pnpm build:w3c @@ -55,14 +55,23 @@ jobs: name: Karma Unit Tests (${{ matrix.browser }}) strategy: matrix: - browser: [ChromeHeadless, FirefoxHeadless] - runs-on: ubuntu-latest + include: + - browser: ChromeHeadless + os: ubuntu-latest + - browser: FirefoxHeadless + os: ubuntu-latest + - browser: Safari + os: macos-latest + runs-on: ${{ matrix.os }} needs: lint steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v5 - uses: actions/setup-node@v6 - with: { node-version-file: '.nvmrc', cache: pnpm } + with: { node-version-file: ".nvmrc", cache: pnpm } + - name: Enable Safari WebDriver + if: matrix.browser == 'Safari' + run: sudo safaridriver --enable - run: pnpm i --frozen-lockfile - run: pnpm build:w3c & pnpm build:geonovum - run: pnpm test:unit @@ -80,7 +89,7 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v5 - uses: actions/setup-node@v6 - with: { node-version-file: '.nvmrc', cache: pnpm } + with: { node-version-file: ".nvmrc", cache: pnpm } - run: pnpm i --frozen-lockfile - run: pnpm build:w3c - name: run validator diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index d1334956d6..8010ea7d8d 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v5 - uses: actions/setup-node@v6 - with: { node-version-file: '.nvmrc', cache: pnpm } + with: { node-version-file: ".nvmrc", cache: pnpm } - run: pnpm i --frozen-lockfile - run: pnpm build:w3c - run: pnpm test:headless @@ -55,9 +55,27 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v5 - uses: actions/setup-node@v6 - with: { node-version-file: '.nvmrc', cache: pnpm } + with: { node-version-file: ".nvmrc", cache: pnpm } - run: pnpm i --frozen-lockfile - run: pnpm build:w3c & pnpm build:geonovum - run: pnpm test env: BROWSERS: ChromeHeadless + + test-karma-safari: + name: Karma Unit Tests (Safari) + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v6 + with: { node-version-file: ".nvmrc", cache: pnpm } + - run: sudo safaridriver --enable + - run: pnpm i --frozen-lockfile + - run: pnpm build:w3c & pnpm build:geonovum + - run: pnpm test:unit + env: + BROWSERS: Safari + - run: pnpm test:integration + env: + BROWSERS: Safari diff --git a/src/core/highlight.js b/src/core/highlight.js index 5a07aafe87..176fda085a 100644 --- a/src/core/highlight.js +++ b/src/core/highlight.js @@ -30,7 +30,7 @@ async function highlightElement(elem) { const languages = getLanguageHint(htmlElem.classList); let response; try { - response = await sendHighlightRequest(htmlElem.innerText, languages); + response = await sendHighlightRequest(htmlElem.textContent, languages); } catch (err) { console.error(err); return; diff --git a/src/core/markdown.js b/src/core/markdown.js index 9d5b5edf45..c6e5a6b2cb 100644 --- a/src/core/markdown.js +++ b/src/core/markdown.js @@ -64,16 +64,17 @@ class Renderer extends marked.Renderer { } /** - * @param {string} infoString + * @param {string | undefined} infoString */ static parseInfoString(infoString) { - const firstSpace = infoString.search(/\s/); + const str = infoString || ""; + const firstSpace = str.search(/\s/); if (firstSpace === -1) { - return { language: infoString }; + return { language: str }; } - const language = infoString.slice(0, firstSpace); - const metaDataStr = infoString.slice(firstSpace + 1); + const language = str.slice(0, firstSpace); + const metaDataStr = str.slice(firstSpace + 1); let metaData; if (metaDataStr) { try { diff --git a/src/core/pubsubhub.js b/src/core/pubsubhub.js index 7e8acfac2b..dcf4b300b9 100644 --- a/src/core/pubsubhub.js +++ b/src/core/pubsubhub.js @@ -24,7 +24,23 @@ export function pub(topic, detail) { } // If this is an iframe, postMessage parent (used in testing). const args = String(JSON.stringify(detail?.stack || detail)); - window.parent.postMessage({ topic, args }, window.parent.location.origin); + // Safari can throw SecurityError accessing parent.location.origin from + // srcdoc iframes (treated as opaque origin). For the "end-all" signal + // (detail = undefined, no sensitive data) fall back to "*" so the test + // harness receives it. For all other topics skip the postMessage entirely + // to avoid broadcasting potentially sensitive data to unknown origins. + let targetOrigin; + try { + targetOrigin = window.parent.location.origin; + } catch { + if (topic !== "end-all") return; + targetOrigin = "*"; + } + try { + window.parent.postMessage({ topic, args }, targetOrigin); + } catch { + // Ignore: postMessage may throw in restricted browsing contexts. + } } /** diff --git a/tests/karma.conf.base.cjs b/tests/karma.conf.base.cjs index e695ace5fa..869a6ad1bd 100644 --- a/tests/karma.conf.base.cjs +++ b/tests/karma.conf.base.cjs @@ -46,7 +46,7 @@ module.exports = config => { require("karma-jasmine-html-reporter"), require("karma-chrome-launcher"), require("karma-firefox-launcher"), - require("karma-safari-launcher"), + require("./karma.safari.cjs"), ], frameworks: ["jasmine"], files, diff --git a/tests/karma.safari.cjs b/tests/karma.safari.cjs new file mode 100644 index 0000000000..09a36ee6ec --- /dev/null +++ b/tests/karma.safari.cjs @@ -0,0 +1,246 @@ +// @ts-check +/* eslint-env node */ +/** + * Minimal karma launcher for Safari using safaridriver (W3C WebDriver). + * Avoids the old karma-safari-launcher redirect.html hack and the broken + * wd@1.x dependency of @onslip/karma-safari-launcher on Node 24+. + * + * Requires safaridriver to be enabled once: + * sudo safaridriver --enable + */ + +const { spawn } = require("child_process"); +const http = require("http"); + +const SAFARIDRIVER_PORT = (() => { + const raw = process.env.SAFARIDRIVER_PORT; + if (!raw) return 4445; + const port = Number.parseInt(raw, 10); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error( + `Invalid SAFARIDRIVER_PORT: "${raw}" (must be an integer 1–65535)` + ); + } + return port; +})(); + +/** + * Simple W3C WebDriver client using Node's built-in http module. + * @param {string} method + * @param {string} path + * @param {object} [body] + * @param {number} [timeoutMs] + */ +function webdriver(method, path, body, timeoutMs = 10000) { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null; + const req = http.request( + { + hostname: "localhost", + port: SAFARIDRIVER_PORT, + path, + method, + timeout: timeoutMs, + headers: { + "Content-Type": "application/json", + ...(data ? { "Content-Length": Buffer.byteLength(data) } : {}), + }, + }, + res => { + let raw = ""; + res.on("data", chunk => (raw += chunk)); + res.on("end", () => { + if (res.statusCode >= 400) { + reject( + new Error( + `WebDriver ${method} ${path} → ${res.statusCode}: ${raw}` + ) + ); + return; + } + try { + resolve(JSON.parse(raw)); + } catch { + resolve(raw); + } + }); + } + ); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(new Error(`WebDriver request timed out: ${method} ${path}`)); + }); + if (data) req.write(data); + req.end(); + }); +} + +/** + * Poll GET /status until safaridriver is accepting connections, or until + * maxMs have elapsed. Retries on ECONNREFUSED (driver not yet listening). + * @param {number} [maxMs] + */ +function waitForReady(maxMs = 15000) { + return new Promise((resolve, reject) => { + const deadline = Date.now() + maxMs; + const attempt = () => { + const req = http.request( + { + hostname: "localhost", + port: SAFARIDRIVER_PORT, + path: "/status", + method: "GET", + timeout: 2000, + }, + res => { + res.resume(); // drain the body + if (res.statusCode < 400) { + resolve(); + } else if (Date.now() < deadline) { + setTimeout(attempt, 250); + } else { + reject( + new Error( + `safaridriver not ready after ${maxMs}ms (last status ${res.statusCode})` + ) + ); + } + } + ); + req.on("error", err => { + // ECONNREFUSED — driver not listening yet; keep retrying + if (Date.now() < deadline) { + setTimeout(attempt, 250); + } else { + reject( + new Error(`safaridriver not ready after ${maxMs}ms: ${err.message}`) + ); + } + }); + req.on("timeout", () => req.destroy()); + req.end(); + }; + attempt(); + }); +} + +function SafariLauncher(logger, baseBrowserDecorator) { + baseBrowserDecorator(this); + + const log = logger.create("launcher.Safari"); + let safariDriver = null; + let sessionId = null; + + const cleanup = async () => { + if (sessionId) { + await webdriver("DELETE", `/session/${sessionId}`).catch(() => {}); + sessionId = null; + } + if (safariDriver) { + safariDriver.kill(); + safariDriver = null; + } + }; + + this._start = async url => { + safariDriver = spawn("safaridriver", ["--port", String(SAFARIDRIVER_PORT)]); + safariDriver.stderr.on("data", d => + log.debug("safaridriver:", d.toString().trim()) + ); + safariDriver.on("exit", async (code, signal) => { + if (sessionId) { + log.error( + `safaridriver exited unexpectedly (code=${code} signal=${signal})` + ); + // Null the process first so cleanup() doesn't try to kill an + // already-dead process, then run full cleanup to tear down the session. + safariDriver = null; + await cleanup(); + this._done("failure"); + } + }); + + // Wait for safaridriver to be ready, but reject immediately if the + // process errors or exits early (e.g. port in use, not enabled, invalid args). + try { + await new Promise((resolve, reject) => { + let settled = false; + const finish = + fn => + (...args) => { + if (settled) return; + settled = true; + fn(...args); + }; + + const onError = finish(err => { + safariDriver.off("exit", onExit); + log.error( + `safaridriver failed to start — is it installed and enabled? Run: sudo safaridriver --enable (${err.message})` + ); + reject(err); + }); + const onExit = finish((code, signal) => { + safariDriver.off("error", onError); + reject( + new Error( + `safaridriver exited (code=${code} signal=${signal}) — port may be in use or safaridriver not enabled` + ) + ); + }); + + safariDriver.once("error", onError); + safariDriver.once("exit", onExit); + + waitForReady(15000).then( + finish(() => { + safariDriver.off("error", onError); + safariDriver.off("exit", onExit); + resolve(); + }), + finish(err => { + safariDriver.off("error", onError); + safariDriver.off("exit", onExit); + reject(err); + }) + ); + }); + } catch { + await cleanup(); + this._done("failure"); + return; + } + + try { + // Create a W3C WebDriver session (opens a new Safari window). + // Use a generous timeout: Safari can be slow to start on cold CI runners. + const res = await webdriver( + "POST", + "/session", + { capabilities: { alwaysMatch: { browserName: "safari" } } }, + 30000 + ); + sessionId = res?.value?.sessionId; + if (!sessionId) throw new Error("No sessionId from safaridriver"); + + // Navigate to the karma URL + await webdriver("POST", `/session/${sessionId}/url`, { url }); + log.info("Safari launched at", url); + } catch (err) { + log.error("Failed to start Safari:", err.message); + await cleanup(); + this._done("failure"); + } + }; + + this.on("kill", async done => { + await cleanup(); + done(); + }); +} + +SafariLauncher.$inject = ["logger", "baseBrowserDecorator"]; + +module.exports = { + "launcher:Safari": ["type", SafariLauncher], +}; diff --git a/tests/unit/SpecHelper.js b/tests/unit/SpecHelper.js index 6ed547ef51..6ec84033d3 100644 --- a/tests/unit/SpecHelper.js +++ b/tests/unit/SpecHelper.js @@ -1,5 +1,6 @@ "use strict"; const iframes = []; +const POLL_INTERVAL_MS = 100; /** * Create a doc for unit tests. @@ -36,14 +37,24 @@ export function makePluginDoc( allPlugins.map(plug => import(plug)) ); await baseRunner.runAll(plugs); - } catch (err) { + } catch (rawErr) { + // Normalise to a real Error so Safari never rejects with + // undefined (which crashes jasmine-core's formatProperties). + const err = + rawErr instanceof Error + ? rawErr + : new Error(String(rawErr ?? "ReSpec failed")); console.error(err); if (document.respec) { document.respec.errors.push(err); } else { - Object.defineProperty(document, "respec", { - value: { ready: Promise.reject(err) }, - }); + // Add a void .catch() so Safari doesn't propagate this + // rejected Promise as an unhandled rejection to the parent + // frame (where it arrives with reason=undefined, crashing + // jasmine-core's formatProperties). + const ready = Promise.reject(err); + ready.catch(() => {}); + Object.defineProperty(document, "respec", { value: { ready } }); } } } @@ -80,26 +91,59 @@ function getDoc(html) { * @return {Promise} */ async function waitReady(iframe) { - const timeoutId = setTimeout(() => { - throw new Error(`Timed out waiting for document.respec.ready.`); - }, jasmine.DEFAULT_TIMEOUT_INTERVAL); - const doc = iframe.contentDocument; - if (doc.respec) { + if (doc && doc.respec) { await doc.respec.ready; - clearTimeout(timeoutId); return doc; } - return await new Promise(res => { - window.addEventListener("message", function msgHandler(ev) { - if (!doc || !ev.source || doc !== ev.source.document) return; - if (ev.data.topic === "end-all") { - window.removeEventListener("message", msgHandler); - clearTimeout(timeoutId); - res(doc); + return await new Promise((resolve, reject) => { + let settled = false; + + // Settle exactly once: cancel all pending timers/listeners, then call fn. + function settle(fn) { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + clearInterval(pollId); + window.removeEventListener("message", msgHandler); + fn(); + } + + const timeoutId = setTimeout( + () => + settle(() => + reject(new Error("Timed out waiting for document.respec.ready.")) + ), + jasmine.DEFAULT_TIMEOUT_INTERVAL + ); + + // Polling fallback: in Safari, postMessage from srcdoc iframes may not be + // received because ev.source !== iframe.contentWindow (different proxy + // objects for opaque-origin frames). Poll doc.respec.ready directly so + // tests complete without relying solely on postMessage. + const pollId = setInterval(() => { + try { + if (!doc || !doc.respec) return; + settle(() => { + doc.respec.ready.then(() => resolve(doc), reject); + }); + } catch { + // Cross-origin access denied; rely on postMessage path. + } + }, POLL_INTERVAL_MS); + + function msgHandler(ev) { + // Don't check ev.source: in Safari, opaque-origin srcdoc iframes send + // postMessages with ev.source being a different WindowProxy than + // iframe.contentWindow, so identity checks always fail. Tests run + // sequentially (jasmine), so at most one waitReady is active at a time; + // matching on topic alone is safe. + if (ev.data?.topic === "end-all") { + settle(() => resolve(doc)); } - }); + } + window.addEventListener("message", msgHandler); }); }