Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
5 changes: 4 additions & 1 deletion src/core/pubsubhub.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }, "/");
}

/**
Expand Down
2 changes: 1 addition & 1 deletion tests/karma.conf.base.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
132 changes: 132 additions & 0 deletions tests/karma.safari.cjs
Original file line number Diff line number Diff line change
@@ -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],
};
52 changes: 12 additions & 40 deletions tests/unit/SpecHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,13 @@ export function makePluginDoc(
var respecConfig = ${JSON.stringify(config || {}, null, 2)};
</script>
<script type="module">
async function run(plugins) {
const allPlugins = plugins.map(p => "/base" + p);
try {
const [baseRunner, ...plugs] = await Promise.all(
allPlugins.map(plug => import(plug))
);
await baseRunner.runAll(plugs);
} catch (err) {
console.error(err);
if (document.respec) {
document.respec.errors.push(err);
} else {
Object.defineProperty(document, "respec", {
value: { ready: Promise.reject(err) },
});
}
}
}
run(${JSON.stringify(plugins)});
const plugins = ${JSON.stringify(plugins)};
window.respecReady = (async () => {
const [baseRunner, ...plugs] = await Promise.all(
plugins.map(plug => import("/base" + plug))
);
await baseRunner.runAll(plugs);
})();
</script>
</head>
<body>${body}</body>
Expand Down Expand Up @@ -80,27 +68,11 @@ function getDoc(html) {
* @return {Promise<Document>}
*/
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() {
Expand Down
Loading