feat: publish WASM streaming smoke site

Add a browser WASM smoke site, four platform CI workflows, README status badges, and real file streaming coverage over the Geekbench corpus.
This commit is contained in:
Mihaela Mihaljevic 2026-06-06 22:06:38 +02:00
parent f56c3e5578
commit be03732baa
15 changed files with 671 additions and 11 deletions

16
.github/workflows/linux.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: Linux
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
container: swift:6.2
steps:
- uses: actions/checkout@v6
- name: Build and test
run: bash scripts/check.sh

View File

@ -1,4 +1,4 @@
name: CI
name: macOS
on:
push:

110
.github/workflows/wasm-site.yml vendored Normal file
View File

@ -0,0 +1,110 @@
name: WASM Test Site
on:
push:
branches: [main]
paths:
- "Package.swift"
- "Sources/PureYAMLStreaming/**"
- "Sources/PureYAMLStreamingWasmSmoke/**"
- "WasmSite/**"
- "scripts/build-wasm-site.sh"
- ".github/workflows/wasm-site.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pureyaml-streaming-wasm-site
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install build tools
run: |
sudo apt-get update
sudo apt-get install -y binaryen gzip libcurl4-openssl-dev
- name: Install swiftly
run: |
curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz
tar zxf swiftly.tar.gz
./swiftly init --quiet-shell-followup --assume-yes --skip-install
- name: Install Swift 6.3.2 and Wasm SDK
run: |
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
swiftly install --use 6.3.2
swiftly run swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c +6.3.2
- name: Build WASM site
run: |
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
bash scripts/build-wasm-site.sh
mkdir -p site
cp WasmSite/index.html site/index.html
cp WasmSite/main.js site/main.js
cp WasmSite/style.css site/style.css
cp WasmSite/pureyaml-streaming-wasm-smoke.wasm.gz site/pureyaml-streaming-wasm-smoke.wasm.gz
ls -lh site
- name: Install Playwright Chromium
run: |
mkdir -p .build/playwright-ci
cd .build/playwright-ci
npm init -y
npm install playwright-chromium
npx playwright install --with-deps chromium
- name: Test site locally
run: |
python3 -m http.server 8766 --bind 127.0.0.1 --directory site &
server_pid=$!
trap 'kill "$server_pid"' EXIT
node <<'NODE'
const { chromium } = require('./.build/playwright-ci/node_modules/playwright-chromium');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('http://127.0.0.1:8766/?v=' + Date.now());
await page.click('#runButton');
await page.waitForFunction(
() => document.querySelector('#status')?.textContent === 'Passed',
null,
{ timeout: 60000 }
);
const output = await page.locator('#output').textContent();
if (!output.includes('"ok" : true') && !output.includes('"ok":true')) {
throw new Error(output);
}
await browser.close();
})();
NODE
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

37
.github/workflows/wasm.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: WASM
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install build tools
run: |
sudo apt-get update
sudo apt-get install -y binaryen gzip libcurl4-openssl-dev
- name: Install swiftly
run: |
curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz
tar zxf swiftly.tar.gz
./swiftly init --quiet-shell-followup --assume-yes --skip-install
- name: Install Swift 6.3.2 and Wasm SDK
run: |
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
swiftly install --use 6.3.2
swiftly run swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c +6.3.2
- name: Build WASM smoke site
run: |
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
bash scripts/build-wasm-site.sh
- name: Upload WASM site artifact
uses: actions/upload-artifact@v6
with:
name: pureyaml-streaming-wasm-site
path: WasmSite
retention-days: 7

24
.github/workflows/windows.yml vendored Normal file
View File

@ -0,0 +1,24 @@
name: Windows
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: windows-2022
steps:
- uses: actions/checkout@v6
- name: Install Swift
uses: compnerd/gha-setup-swift@main
with:
swift-version: swift-6.3.2-release
swift-build: 6.3.2-RELEASE
- name: Build
shell: pwsh
run: swift build
- name: Test
shell: pwsh
run: swift test

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.build/
.swiftpm/
.DS_Store
WasmSite/*.wasm
WasmSite/*.wasm.gz

View File

@ -12,6 +12,10 @@ let package = Package(
name: "PureYAMLStreaming",
targets: ["PureYAMLStreaming"],
),
.executable(
name: "pureyaml-streaming-wasm-smoke",
targets: ["PureYAMLStreamingWasmSmoke"],
),
],
dependencies: [
.package(url: "https://github.com/mihaelamj/PureYAML.git", branch: "main"),
@ -21,6 +25,10 @@ let package = Package(
name: "PureYAMLStreaming",
dependencies: ["PureYAML"],
),
.executableTarget(
name: "PureYAMLStreamingWasmSmoke",
dependencies: ["PureYAMLStreaming", "PureYAML"],
),
.testTarget(
name: "PureYAMLStreamingTests",
dependencies: ["PureYAMLStreaming"],

View File

@ -1,5 +1,11 @@
# PureYAMLStreaming
[![macOS](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureYAMLStreaming/macos.yml?branch=main&label=macOS)](https://github.com/mihaelamj/PureYAMLStreaming/actions/workflows/macos.yml)
[![Linux](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureYAMLStreaming/linux.yml?branch=main&label=Linux)](https://github.com/mihaelamj/PureYAMLStreaming/actions/workflows/linux.yml)
[![Windows](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureYAMLStreaming/windows.yml?branch=main&label=Windows)](https://github.com/mihaelamj/PureYAMLStreaming/actions/workflows/windows.yml)
[![WASM](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureYAMLStreaming/wasm.yml?branch=main&label=WASM)](https://github.com/mihaelamj/PureYAMLStreaming/actions/workflows/wasm.yml)
[![WASM Site](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureYAMLStreaming/wasm-site.yml?branch=main&label=WASM%20Site)](https://github.com/mihaelamj/PureYAMLStreaming/actions/workflows/wasm-site.yml)
Experimental full-streaming incubator for PureYAML.
This package is intentionally separate from `PureYAML` while the chunked input,
@ -43,6 +49,28 @@ try parser.parseDocuments(from: url) { document in
}
```
## WASM Browser Smoke Site
Live site:
`https://mihaelamj.github.io/PureYAMLStreaming/`
Build the Swift WASI smoke executable and copy it into the static test site:
```sh
bash scripts/build-wasm-site.sh
```
Serve the site locally:
```sh
bash scripts/serve-wasm-site.sh
```
Then open `http://localhost:8080` and click **Run WASM Test**. The page loads
`pureyaml-streaming-wasm-smoke.wasm`, runs a chunked document-stream parse in
the browser through a WASI shim, captures stdout, and renders pass/fail.
## Relationship To PureYAML
`PureYAML` remains the dependency-free core package. This package is the

View File

@ -0,0 +1,76 @@
import Foundation
import PureYAML
import PureYAMLStreaming
struct SmokeResult: Encodable {
var ok: Bool
var documentCount: Int
var firstName: String?
var lastName: String?
var message: String
}
let yaml = Data(
"""
---
name: browser-0
enabled: true
nested:
index: 0
---
name: browser-1
enabled: false
nested:
index: 1
---
name: browser-2
enabled: true
nested:
index: 2
""".utf8,
)
do {
let parser = PureYAMLStreaming.Parser(chunkSize: 7)
let documents = try parser.collectDocuments(data: yaml)
let names = documents.compactMap { document in
stringValue("name", in: document.value)
}
let ok = documents.count == 3
&& names.first == "browser-0"
&& names.last == "browser-2"
let result = SmokeResult(
ok: ok,
documentCount: documents.count,
firstName: names.first,
lastName: names.last,
message: ok ? "PureYAMLStreaming WASM smoke passed" : "PureYAMLStreaming WASM smoke failed",
)
print(try renderJSON(result))
Foundation.exit(ok ? 0 : 1)
} catch {
let result = SmokeResult(
ok: false,
documentCount: 0,
firstName: nil,
lastName: nil,
message: String(describing: error),
)
print((try? renderJSON(result)) ?? #"{"ok":false,"message":"failed to render error"}"#)
Foundation.exit(1)
}
func stringValue(_ key: String, in value: PureYAML.Model.Value) -> String? {
guard case let .mapping(mapping) = value,
case let .string(string)? = mapping[key]
else {
return nil
}
return string
}
func renderJSON<Value: Encodable>(_ value: Value) throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return String(decoding: try encoder.encode(value), as: UTF8.self)
}

View File

@ -57,14 +57,7 @@ struct StreamingTests {
@Test("streaming matches PureYAML on Geekbench fixtures")
func streamingMatchesGeekbenchFixtures() throws {
let fixtureRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("PureYAMLGeekbench")
.appendingPathComponent("Fixtures")
.appendingPathComponent("real-yaml")
let fixtureRoot = geekbenchFixtureRoot()
guard FileManager.default.fileExists(atPath: fixtureRoot.path) else {
return
}
@ -78,16 +71,48 @@ struct StreamingTests {
#expect(files.count >= 100)
let streamingParser = PureYAMLStreaming.Parser(chunkSize: 4096)
let streamingParser = PureYAMLStreaming.Parser(chunkSize: 31)
let pureParser = PureYAML.Parsing.Parser()
for file in files {
let data = try Data(contentsOf: file)
let streamed = try streamingParser.collectDocuments(data: data).map(\.value)
var streamed: [PureYAML.Model.Value] = []
try streamingParser.parseDocuments(from: file) { document in
streamed.append(document.value)
}
let direct = try pureParser.parseStream(String(decoding: data, as: UTF8.self)).map(\.value)
#expect(streamed == direct)
}
}
@Test("parser streams generated large file without collecting documents")
func parserStreamsGeneratedLargeFileWithoutCollectingDocuments() throws {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("pureyaml-stream-\(UUID().uuidString).yaml")
try writeGeneratedStream(to: fileURL, documentCount: 5_000)
defer {
try? FileManager.default.removeItem(at: fileURL)
}
let parser = PureYAMLStreaming.Parser(chunkSize: 37)
var count = 0
var firstName: String?
var lastName: String?
try parser.parseDocuments(from: fileURL) { document in
let name = stringValue("name", in: document.value)
if count == 0 {
firstName = name
}
lastName = name
#expect(document.index == count)
count += 1
}
#expect(count == 5_000)
#expect(firstName == "doc-0")
#expect(lastName == "doc-4999")
}
private func stringValue(_ key: String, in value: PureYAML.Model.Value) -> String? {
guard case let .mapping(mapping) = value,
case let .string(string)? = mapping[key]
@ -96,4 +121,39 @@ struct StreamingTests {
}
return string
}
private func geekbenchFixtureRoot() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("PureYAMLGeekbench")
.appendingPathComponent("Fixtures")
.appendingPathComponent("real-yaml")
}
private func writeGeneratedStream(to fileURL: URL, documentCount: Int) throws {
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
let handle = try FileHandle(forWritingTo: fileURL)
defer {
try? handle.close()
}
for index in 0 ..< documentCount {
let document = """
---
name: doc-\(index)
enabled: \(index.isMultiple(of: 2) ? "true" : "false")
nested:
index: \(index)
tags:
- real
- streaming
- chunk-\(index % 17)
"""
try handle.write(contentsOf: Data(document.utf8))
}
}
}

31
WasmSite/index.html Normal file
View File

@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PureYAMLStreaming WASM Smoke</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main>
<section class="card">
<p class="eyebrow">PureYAMLStreaming</p>
<h1>WASM Streaming Smoke Test</h1>
<p>
This page loads the Swift WASI build in the browser, runs a
document-streaming parse, captures stdout, and reports whether the
smoke test passed.
</p>
<button id="runButton" type="button">Run WASM Test</button>
<div id="status" class="status idle">Idle</div>
</section>
<section class="card">
<h2>Output</h2>
<pre id="output">Click "Run WASM Test".</pre>
</section>
</main>
<script type="module" src="./main.js"></script>
</body>
</html>

115
WasmSite/main.js Normal file
View File

@ -0,0 +1,115 @@
import {
File,
OpenFile,
WASI,
} from "https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.4.1/+esm";
const runButton = document.querySelector("#runButton");
const statusElement = document.querySelector("#status");
const outputElement = document.querySelector("#output");
const params = new URLSearchParams(location.search);
const wasmURL = params.get("wasm") || "./pureyaml-streaming-wasm-smoke.wasm.gz";
runButton.addEventListener("click", () => {
runSmokeTest().catch((error) => {
renderFailure(error);
});
});
async function runSmokeTest() {
runButton.disabled = true;
statusElement.className = "status idle";
statusElement.textContent = "Running...";
outputElement.textContent = "Loading WASM...";
const wasmBytes = await loadWasmBytes(wasmURL);
const stdout = new CapturingFile();
const stderr = new CapturingFile();
const fds = [
new OpenFile(new File([])),
new OpenFile(stdout),
new OpenFile(stderr),
];
const wasi = new WASI([], [], fds);
const wasm = await WebAssembly.compile(wasmBytes);
const instance = await WebAssembly.instantiate(wasm, {
wasi_snapshot_preview1: wasi.wasiImport,
});
let exitCode = 0;
try {
wasi.start(instance);
} catch (error) {
if (typeof error?.code === "number") {
exitCode = error.code;
} else {
throw error;
}
}
const stdoutText = stdout.text();
const stderrText = stderr.text();
const parsed = parseSmokeOutput(stdoutText);
const ok = exitCode === 0 && parsed?.ok === true;
statusElement.className = ok ? "status pass" : "status fail";
statusElement.textContent = ok ? "Passed" : "Failed";
outputElement.textContent = [
`exitCode: ${exitCode}`,
"",
"stdout:",
stdoutText || "(empty)",
"",
"stderr:",
stderrText || "(empty)",
].join("\n");
runButton.disabled = false;
}
async function loadWasmBytes(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch WASM: HTTP ${response.status}`);
}
const bytes = await response.arrayBuffer();
if (!url.endsWith(".gz")) {
return bytes;
}
if (!("DecompressionStream" in globalThis)) {
throw new Error("This browser does not support DecompressionStream.");
}
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
return new Response(stream).arrayBuffer();
}
function parseSmokeOutput(text) {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function renderFailure(error) {
statusElement.className = "status fail";
statusElement.textContent = "Failed";
outputElement.textContent = String(error?.stack || error);
runButton.disabled = false;
}
class CapturingFile extends File {
constructor() {
super([]);
this.bytes = [];
}
write(buffer) {
this.bytes.push(...buffer);
return { ret: buffer.length };
}
text() {
return new TextDecoder().decode(new Uint8Array(this.bytes));
}
}

94
WasmSite/style.css Normal file
View File

@ -0,0 +1,94 @@
:root {
color-scheme: light dark;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
background: Canvas;
color: CanvasText;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
}
main {
width: min(920px, calc(100vw - 32px));
display: grid;
gap: 20px;
}
.card {
border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
border-radius: 20px;
padding: 28px;
background: color-mix(in srgb, Canvas 92%, CanvasText 8%);
box-shadow: 0 24px 80px color-mix(in srgb, CanvasText 12%, transparent);
}
.eyebrow {
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 12px;
opacity: 0.7;
}
h1,
h2 {
margin: 0 0 12px;
line-height: 1.05;
}
h1 {
font-size: clamp(36px, 7vw, 72px);
}
p {
max-width: 720px;
line-height: 1.6;
opacity: 0.82;
}
button {
appearance: none;
border: 0;
border-radius: 999px;
padding: 12px 18px;
font: inherit;
font-weight: 700;
cursor: pointer;
background: CanvasText;
color: Canvas;
}
button:disabled {
cursor: wait;
opacity: 0.55;
}
.status {
margin-top: 18px;
display: inline-flex;
border-radius: 999px;
padding: 8px 12px;
font-weight: 700;
background: color-mix(in srgb, CanvasText 10%, transparent);
}
.status.pass {
background: color-mix(in srgb, #008a45 30%, transparent);
}
.status.fail {
background: color-mix(in srgb, #c21f39 30%, transparent);
}
pre {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.5;
}

51
scripts/build-wasm-site.sh Executable file
View File

@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SDK_ID="${PUREYAML_STREAMING_WASM_SDK:-swift-6.3.2-RELEASE_wasm}"
SWIFT_SELECTOR="${PUREYAML_STREAMING_WASM_SWIFT_SELECTOR:-+6.3.2}"
PRODUCT="pureyaml-streaming-wasm-smoke"
SITE_DIR="$ROOT_DIR/WasmSite"
swift_command() {
if command -v swiftly >/dev/null 2>&1; then
swiftly run swift "$@" "$SWIFT_SELECTOR"
else
swift "$@"
fi
}
if ! swift_command sdk list | grep -qx "$SDK_ID"; then
echo "wasm: missing Swift SDK '$SDK_ID'" >&2
echo "wasm: install the SDK or set PUREYAML_STREAMING_WASM_SDK" >&2
exit 2
fi
swift_command build \
--package-path "$ROOT_DIR" \
--swift-sdk "$SDK_ID" \
-c release \
--product "$PRODUCT"
BIN_PATH="$(swift_command build \
--package-path "$ROOT_DIR" \
--swift-sdk "$SDK_ID" \
-c release \
--show-bin-path)"
WASM_PATH="$BIN_PATH/$PRODUCT.wasm"
STAGED_WASM="$SITE_DIR/$PRODUCT.wasm"
if command -v wasm-opt >/dev/null 2>&1; then
wasm-opt -Oz --strip-debug --strip-producers "$WASM_PATH" -o "$STAGED_WASM"
else
cp "$WASM_PATH" "$STAGED_WASM"
fi
gzip -9 -c "$STAGED_WASM" > "$STAGED_WASM.gz"
echo "WASM test site ready:"
echo " $SITE_DIR/index.html"
echo " $STAGED_WASM.gz"
echo
echo "Serve it with:"
echo " bash scripts/serve-wasm-site.sh"

8
scripts/serve-wasm-site.sh Executable file
View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PORT="${1:-8080}"
cd "$ROOT_DIR/WasmSite"
python3 -m http.server "$PORT"