diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e141b14565..749d0c20be 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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@v7 - uses: pnpm/action-setup@v5 - uses: actions/setup-node@v7 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 diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 671ba0bc33..e7c7a2a01f 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -49,15 +49,25 @@ jobs: RESPEC_SECRET: ${{ secrets.RESPEC_GH_ACTION_SECRET }} test-karma: - name: Karma Unit Tests (Chrome) - runs-on: ubuntu-latest + name: Karma Unit Tests (${{ matrix.browser }}) + strategy: + matrix: + include: + - browser: ChromeHeadless + os: ubuntu-latest + - browser: Safari + os: macos-latest + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v5 - uses: actions/setup-node@v7 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 env: - BROWSERS: ChromeHeadless + BROWSERS: ${{ matrix.browser }} diff --git a/src/core/pubsubhub.js b/src/core/pubsubhub.js index 2730d211cd..2365059c47 100644 --- a/src/core/pubsubhub.js +++ b/src/core/pubsubhub.js @@ -23,7 +23,10 @@ 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); + // "/" means "only deliver to a parent on our own origin", which is what + // reading parent.location.origin achieved — except that read throws a + // SecurityError when the parent is not same origin. + window.parent.postMessage({ topic, args }, "/"); } /** 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..6031e9f371 --- /dev/null +++ b/tests/karma.safari.cjs @@ -0,0 +1,132 @@ +// @ts-check +/** + * Minimal karma launcher that drives Safari through safaridriver over the W3C + * WebDriver protocol. The published karma-safari-launcher uses a redirect.html + * hack that modern Safari treats as a download, and @onslip/karma-safari-launcher + * depends on wd@1.x, which needs a native build that fails on Node 24+. + * + * Requires safaridriver to be enabled once: + * sudo safaridriver --enable + */ + +const { spawn } = require("child_process"); + +const PORT = 4445; + +/** + * @param {string} method + * @param {string} path + * @param {object} [body] + * @param {number} [timeout] in milliseconds + */ +async function webdriver(method, path, body, timeout = 10000) { + const res = await fetch(`http://localhost:${PORT}${path}`, { + method, + headers: { "Content-Type": "application/json" }, + body: body && JSON.stringify(body), + signal: AbortSignal.timeout(timeout), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`WebDriver ${method} ${path} → ${res.status}: ${text}`); + } + return res.json(); +} + +/** + * Polls GET /status until safaridriver is accepting connections. + * @param {number} [timeout] in milliseconds + */ +async function waitForReady(timeout = 15000) { + const deadline = Date.now() + timeout; + for (;;) { + try { + return await webdriver("GET", "/status", undefined, 2000); + } catch (err) { + if (Date.now() >= deadline) { + throw new Error(`safaridriver not ready after ${timeout}ms`, { + cause: err, + }); + } + await new Promise(resolve => setTimeout(resolve, 250)); + } + } +} + +function SafariLauncher(logger, baseBrowserDecorator) { + baseBrowserDecorator(this); + this.name = "Safari"; + + const log = logger.create("launcher.Safari"); + /** @type {import("child_process").ChildProcess | null} */ + let driver = null; + /** @type {string | null} */ + let sessionId = null; + + const cleanup = async () => { + if (sessionId) { + await webdriver("DELETE", `/session/${sessionId}`).catch(() => {}); + sessionId = null; + } + driver?.kill(); + driver = null; + }; + + this._start = async url => { + let failed = false; + const fail = async message => { + if (failed) return; + failed = true; + log.error(message); + await cleanup(); + this._done("failure"); + }; + + driver = spawn("safaridriver", ["--port", String(PORT)]); + driver.stderr.on("data", data => + log.debug("safaridriver:", String(data).trim()) + ); + const exited = new Promise((_, reject) => { + driver.once("error", reject); + driver.once("exit", (code, signal) => + reject(new Error(`safaridriver exited (code=${code} signal=${signal})`)) + ); + }); + // An exit while a session is live means Safari died mid-run; during + // startup the race below reports it instead. + exited.catch(err => { + if (sessionId) fail(String(err)); + }); + + try { + await Promise.race([waitForReady(), exited]); + // Creating a session opens a new Safari window. Allow longer than the + // default: Safari is slow to launch on a cold CI runner. + const { value } = await webdriver( + "POST", + "/session", + { capabilities: { alwaysMatch: { browserName: "safari" } } }, + 30000 + ); + sessionId = value?.sessionId; + if (!sessionId) throw new Error("safaridriver returned no sessionId"); + await webdriver("POST", `/session/${sessionId}/url`, { url }); + log.info("Safari launched at", url); + } catch (err) { + await fail( + `Safari failed to start: ${err}. Is safaridriver enabled (sudo safaridriver --enable) and port ${PORT} free?` + ); + } + }; + + 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..1b536d6229 100644 --- a/tests/unit/SpecHelper.js +++ b/tests/unit/SpecHelper.js @@ -29,25 +29,13 @@ export function makePluginDoc( var respecConfig = ${JSON.stringify(config || {}, null, 2)}; ${body} @@ -80,27 +68,11 @@ 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) { - 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); - } - }); - }); + // makePluginDoc exposes the promise for its own ReSpec run, so await that + // rather than the "end-all" postMessage: document.respec only exists once + // the dynamically imported plugins have loaded, which is after iframe load. + await iframe.contentWindow.respecReady; + return iframe.contentDocument; } export function flushIframes() {