Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
* @exercism/guardians

# Writes to the test-runners bucket, so changes to it need sign-off.
/.github/workflows/publish-clientside.yml @iHiD @exercism/maintainers-admin
180 changes: 180 additions & 0 deletions .github/clientside/run-in-kernel.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/usr/bin/env node
// Boot a kernel in headless Chromium, untar a runner into it, run one command.
//
// run-in-kernel.mjs [--extract <kernel-path> <host-path>]... \
// <kernel-dir> <sysroot-dir> <boot.json> <runner.tar> <cmd> [args...]
//
// Exits with the command's status. stdout/stderr are relayed as they arrive.
//
// --extract copies a file out of the kernel once the command has succeeded:
// the browser equivalent of `docker cp`. It is how a binary built by the
// sysroot's own compiler gets back out to be shipped.
//
// This is the browser equivalent of `docker run --entrypoint <cmd> <image>`.
// The kernel is threaded wasm and only runs in a cross-origin isolated page,
// so there is no way to drive it from Node directly: everything is served
// to a real headless Chromium, which is also the runtime students get.
//
// Needs `playwright` on the module path and a Chromium it can launch
// (`npx playwright install --with-deps chromium`, or CHROMIUM_PATH).
//
// A published sysroot carries its wasm binaries as stubs that name the real
// bytes by absolute path under /test-runners/, which the kernel fetches from
// this origin on first use. Those requests are passed through to where the
// website serves them (TEST_RUNNERS_BASE overrides), so what runs here is
// what students get. A raw, unstubbed sysroot never asks.

import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { chromium } from "playwright";

const args = process.argv.slice(2);
const extracts = [];
while (args[0] === "--extract") {
const [, from, to] = args.splice(0, 3);
if (!from || !to) break;
extracts.push([from, to]);
}
const [kernelDir, sysrootDir, bootJson, tarball, ...argv] = args;
if (!argv.length) {
console.error(
"usage: run-in-kernel.mjs [--extract <kernel-path> <host-path>]... " +
"<kernel-dir> <sysroot-dir> <boot.json> <runner.tar> <cmd> [args...]",
);
process.exit(64);
}

const DEBUG = !!process.env.KERNEL_DEBUG;
const TEST_RUNNERS_BASE = process.env.TEST_RUNNERS_BASE ?? "https://exercism.org/test-runners";

const files = {
"/kernel/kernel.js": [path.join(kernelDir, "kernel.js"), "text/javascript"],
"/kernel/kernel_bg.wasm": [path.join(kernelDir, "kernel_bg.wasm"), "application/wasm"],
"/kernel/kernel_client.mjs": [path.join(kernelDir, "kernel_client.mjs"), "text/javascript"],
"/sysroot.tar": [path.join(sysrootDir, "sysroot.tar"), "application/x-tar"],
"/boot.json": [bootJson, "application/json"],
"/runner.tar": [tarball, "application/x-tar"],
};
for (const [p] of Object.values(files)) {
if (!fs.existsSync(p)) { console.error(`missing: ${p}`); process.exit(66); }
}

// The kernel is threaded wasm: the page must be cross-origin isolated, which
// means every response carries COOP/COEP. Same pair the website sends.
const ISOLATION = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "credentialless",
};

const PAGE = `<!doctype html><meta charset="utf-8"><title>kernel</title>
<script type="module">
import { KernelClient } from "/kernel/kernel_client.mjs";
const dec = new TextDecoder();
const worker = new Worker("/kernel/kernel.js", { type: "module", name: "kernel" });
const client = new KernelClient({
endpoint: worker,
handlers: {
streamOut: (_s, d) => window.__out(dec.decode(d)),
streamErr: (_s, d) => window.__err(dec.decode(d)),
streamIn: () => undefined,
streamClosed: () => {},
// Newer kernels insist on these. Nothing a test runner runs draws.
createCanvas: () => { throw new Error("no canvas in a test run"); },
destroyCanvas: () => {},
},
});
// run() resolves at spawn with a session id; SessionEnded carries the
// exit status. Mirrors what the website's Kernel.ts does.
const ended = new Map(); // sid -> resolve
const early = new Map(); // sid -> result, when the event beat run()
client.processEvents((e) => {
window.__event(JSON.stringify(e));
if (e.tag !== "SessionEnded") return;
const status = e.result.tag === "Ok" ? (e.result.value ?? 0) : 1;
if (ended.has(e.sid)) ended.get(e.sid)(status); else early.set(e.sid, status);
});
window.__run = async (argv) => {
const boot = await (await fetch("/boot.json")).json();
const env = Object.entries(boot.env).map(([k, v]) => k + "=" + v);
// Newer kernels call the list precompile; older ones, preload.
await client.boot(new URL("/sysroot.tar", location.href).href, env, boot.precompile ?? boot.preload, boot.licence);
await client.untar("/", await (await fetch("/runner.tar")).arrayBuffer());
const sid = await client.run(argv, env, "/opt/test-runner", true);
if (early.has(sid)) return early.get(sid);
return await new Promise((resolve) => ended.set(sid, resolve));
};
// Binary-safe: the bytes cross back to Node as base64, not decoded text.
window.__read = async (path) => {
const bytes = new Uint8Array(await client.readFile(path));
let binary = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
};
window.__ready = true;
</script>`;

// fetch() has already undone any Content-Encoding, so the bytes go out plain.
// Immutable, as upstream: the kernel fetches a stubbed file every time it is
// opened, and a test suite's links open the same libraries over and over.
async function passThrough(p, res) {
const upstream = await fetch(TEST_RUNNERS_BASE + p.slice("/test-runners".length));
if (!upstream.ok) { res.writeHead(upstream.status, ISOLATION); return res.end(); }
const bytes = Buffer.from(await upstream.arrayBuffer());
res.writeHead(200, {
...ISOLATION,
"Content-Type": upstream.headers.get("content-type") ?? "application/octet-stream",
"Content-Length": bytes.length,
"Cache-Control": "public, max-age=31536000, immutable",
});
res.end(bytes);
}

const server = http.createServer((req, res) => {
const p = new URL(req.url, "http://localhost").pathname;
if (p === "/") { res.writeHead(200, { ...ISOLATION, "Content-Type": "text/html" }); return res.end(PAGE); }
if (p.startsWith("/test-runners/")) {
return passThrough(p, res).catch((e) => { console.error("[proxy]", p, e.message); res.writeHead(502, ISOLATION); res.end(); });
}
const entry = files[p];
if (!entry) { res.writeHead(404, ISOLATION); return res.end(); }
res.writeHead(200, { ...ISOLATION, "Content-Type": entry[1] });
fs.createReadStream(entry[0]).pipe(res);
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const origin = `http://127.0.0.1:${server.address().port}`;

const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH, args: ["--no-sandbox"] });
const page = await browser.newPage();
await page.exposeFunction("__out", (s) => process.stdout.write(s));
await page.exposeFunction("__err", (s) => process.stderr.write(s));
await page.exposeFunction("__event", (s) => { if (DEBUG) console.error("[event]", s); });
page.on("pageerror", (e) => console.error("[page]", e.message));
page.on("console", (m) => { if (m.type() === "error") console.error("[console]", m.text()); });

let status = 1;
try {
await page.goto(origin, { waitUntil: "load" });
if (!(await page.evaluate(() => crossOriginIsolated))) throw new Error("page is not cross-origin isolated");
await page.waitForFunction(() => window.__ready, null, { timeout: 30_000 });
const raw = await page.evaluate((a) => window.__run(a), argv).then(
(s) => s,
(e) => { console.error("[kernel]", e.message); return 1; },
);
if (DEBUG) console.error("[run returned]", JSON.stringify(raw));
status = raw ?? 0;
if (status === 0) {
for (const [from, to] of extracts) {
const bytes = Buffer.from(await page.evaluate((p) => window.__read(p), from), "base64");
fs.mkdirSync(path.dirname(path.resolve(to)), { recursive: true });
fs.writeFileSync(to, bytes);
console.error(`[extract] ${from} -> ${to} (${bytes.length} bytes)`);
}
}
} finally {
await browser.close().catch(() => {});
server.close();
}
process.exit(status);
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ jobs:

- name: Run Tests in Docker
run: bin/run-tests-in-docker.sh

# The same suite, run the way a student's browser runs it.
clientside:
uses: ./.github/workflows/clientside-test.yml
119 changes: 119 additions & 0 deletions .github/workflows/clientside-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Runs a track's test-runner suite inside the wasm kernel, on the kernel and
# sysroot its clientside.json names: the track's bin/run.sh, driven by its own
# bin/run-tests.sh, in headless Chromium. It is the browser equivalent of
# `bin/run-tests-in-docker.sh`, and what's under test is the tarball
# publish-clientside.yml would ship, down to the parser being built the same
# way. publish-clientside.yml runs this suite again against the exact bytes it
# publishes.
#
# Copied from jq-test-runner, where it is written as the reusable workflow it
# will become in exercism/github-actions. The one C++-specific addition is
# "Build exercism_parser": the parser is compiled, so it has to be built for
# the kernel before there is a tarball to test.
name: Client-side tests

on:
workflow_call:

jobs:
test:
name: Tests (client-side)
runs-on: ubuntu-26.04
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1

# Once this workflow lives in exercism/github-actions, this becomes a
# sparse checkout of that repo's clientside/ into harness/. Today the
# harness is beside this file.
- name: Get the harness
run: cp -r .github/clientside harness

- name: Read clientside.json
id: config
run: |
track="${GITHUB_REPOSITORY##*/}"
track="${track%-test-runner}"
kernel="$(jq -er '.kernel' clientside.json)"
version="$(jq -er '.sysroot.version' clientside.json)"
id="$(jq -er '.sysroot.id' clientside.json)"
{
echo "kernel=kernel/${kernel}"
echo "sysroot=sysroot/${track}/${version}/${id}"
} >> "$GITHUB_OUTPUT"

# From where the website serves them, so what's tested is exactly what
# students get. Nothing here is published until the track points at it,
# so a PR naming an unpublished kernel or sysroot fails right here.
# --compressed: they are stored brotli-encoded, and what lands on disk has
# to be the decoded bytes, as in a browser.
- name: Fetch the kernel and sysroot
run: |
base="https://exercism.org/test-runners"
fetch() {
mkdir -p "$(dirname "$1")"
curl --fail --silent --show-error --location --retry 3 --compressed "${base}/$1" --output "$1"
}
for f in kernel.js kernel_bg.wasm kernel_client.mjs; do
fetch "${{ steps.config.outputs.kernel }}/${f}"
done
for f in sysroot.tar boot.json; do
fetch "${{ steps.config.outputs.sysroot }}/${f}"
done
ls -l "${{ steps.config.outputs.kernel }}" "${{ steps.config.outputs.sysroot }}"

# Same merge as publish-clientside.yml: the sysroot describes itself,
# the track adds its env and precompile list.
- name: Build boot.json
run: |
jq -s '.[0] as $base | .[1] as $track |
$base + {
env: ($base.env + ($track.env // {})),
precompile: ($track.precompile // $base.precompile)
}' "${{ steps.config.outputs.sysroot }}/boot.json" clientside.json > boot.json

- name: Install Playwright
working-directory: harness
run: |
npm install --no-save --no-package-lock playwright@1.63.0
npx playwright install --with-deps chromium

# For bin/build-clientside-parser.sh. Pinned, and checked, because its
# output is part of what the published tarball's hash covers.
- name: Install wasm-opt
run: |
version=130
sha256=0a18362361ad05465118cd8eeb72edaeec89de6894bc283576ef4e07aa3babcc
curl --fail --silent --show-error --location --retry 3 --output binaryen.tar.gz \
"https://github.com/WebAssembly/binaryen/releases/download/version_${version}/binaryen-version_${version}-x86_64-linux.tar.gz"
echo "${sha256} binaryen.tar.gz" | sha256sum --check --quiet
mkdir -p "${RUNNER_TEMP}/binaryen"
tar -xzf binaryen.tar.gz --directory "${RUNNER_TEMP}/binaryen" --strip-components=2 "binaryen-version_${version}/bin/wasm-opt"
rm binaryen.tar.gz
echo "${RUNNER_TEMP}/binaryen" >> "$GITHUB_PATH"

# By the sysroot's own clang, inside the kernel: the Docker image's
# parser is native and cannot run there.
- name: Build exercism_parser
env:
HARNESS: harness
run: |
bin/build-clientside-parser.sh \
"${{ steps.config.outputs.kernel }}" \
"${{ steps.config.outputs.sysroot }}" \
boot.json

# The exact tarball publish-clientside.yml would ship, with the suite
# laid on top.
- name: Build the runner tarball
run: |
bin/build-clientside-tarball.sh test-runner.tar
tar --append --file test-runner.tar --transform 's|^|opt/test-runner/|' bin/run-tests.sh tests

- name: Run the suite in the kernel
run: |
node harness/run-in-kernel.mjs \
"${{ steps.config.outputs.kernel }}" \
"${{ steps.config.outputs.sysroot }}" \
boot.json test-runner.tar \
/opt/test-runner/bin/run-tests.sh
Loading
Loading