diff --git a/README.md b/README.md index d9b3f1d..7224596 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ npm run test:playground bundle exec jekyll build ``` -The build requires JDK 17, Python 3, Node.js, and access to Maven Central. It recompiles the published verifier and annotation API sources for Java 17 without changing them, packages their dependencies, and adds the docs-owned runner and Z3 loader. `scripts/playground/pom.xml` is the single source of truth for the Maven artifact versions; the Python build reads it directly, without requiring Maven. The verifier binary and sources always use the same version. The standard-library classpath comes from the build JDK's `java.base.jmod`. Generated runtime files are ignored by Git and included in the Pages artifact. +The build requires JDK 17, Python 3, Node.js, and access to Maven Central. It recompiles the published verifier and annotation API sources for Java 17 without changing them, packages their dependencies, and adds the docs-owned runner and Z3 loader. `scripts/playground/pom.xml` is the single source of truth for the Maven artifact versions; the Python build reads it directly, without requiring Maven. The verifier binary and sources always use the same version. The standard-library classpath includes the build JDK's `java.base`, `java.desktop`, `java.datatransfer`, and `java.xml` modules for the gallery's JDK protocols. Generated runtime files are ignored by Git and included in the Pages artifact. After the configuration is merged into `main`, Dependabot checks the manifest daily and opens update PRs for the LiquidJava verifier and annotation API. Every pull request builds the playground, runs its tests, and builds Jekyll. Review upgrades with browser verification before merging, including successful and failing refinements and typestate transitions. Pages deployment runs only after a push to `main` or a manual workflow run. @@ -62,4 +62,12 @@ Open `http://127.0.0.1:8770/playground/`. This uses a static server with HTTP ra `playground/isolation.js` is a service worker scoped to `/playground/`. It adds the cross-origin isolation response headers Z3 requires on static hosting, including GitHub Pages. The first visit registers it and reloads once. Other docs pages are outside its scope. CheerpJ loads from its official CDN inside the verification worker; its runtime is not copied into this repository. -The playground checks a single Java file using Java 8 source syntax, bundled annotations and core standard-library types from `java.base`. External dependencies and other Java modules are not available. Z3 4.16 runs behind the verifier's 4.8.17 Java API; browser integration tests must be repeated when updating either version. Unsupported native operations and unknown solver results fail explicitly instead of reporting success. This integration is kept entirely in `liquidjava-docs`. +The playground checks a single Java file using Java 8 source syntax, bundled annotations and standard-library types from those four JDK modules. External dependencies and other Java modules are not available. Z3 4.16 runs behind the verifier's 4.8.17 Java API; browser integration tests must be repeated when updating either version. Unsupported native operations and unknown solver results fail explicitly instead of reporting success. The shared runtime is maintained in `liquidjava-docs`. + +## Shared verifier for the tutorial and website + +After `npm run build:playground`, run `node scripts/playground/export.mjs PATH_TO_SITE` to export the generated runtime, compact client, and isolation service worker. Consumers host these files on their own origin; Java code never goes to a verification service. The runtime is maintained here and rebuilt by each consumer’s deployment workflow. + +The worker accepts `{ type: 'verify', files: { 'Example.java': source, ... } }`. Each request clears the previous Java files, so specifications cannot leak between examples. The runner returns structured `title` and `message` fields alongside the playground’s full diagnostics. The exported `BrowserVerifier` client exposes only the compact results, handles loading/timeouts/cancellation, and reuses the initialized worker for subsequent checks. + +Call `prepareVerifier()` before mounting editable content: it prepares cross-origin isolation and performs the first-visit reload before edits are possible. Exported sites scope `isolation.js` to their site root; the docs playground keeps its own narrower scope. Runtime assets load on the first verification request. diff --git a/playground/isolation.js b/playground/isolation.js index aef32c9..1ad07be 100644 --- a/playground/isolation.js +++ b/playground/isolation.js @@ -1,8 +1,12 @@ -// GitHub Pages cannot set these response headers; isolate only the playground. +// GitHub Pages cannot set these headers; isolate pages within this worker’s scope. self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', event => event.waitUntil(self.clients.claim())); self.addEventListener('fetch', event => { - if (new URL(event.request.url).origin !== self.location.origin) return; + const url = new URL(event.request.url); + if (url.origin !== self.location.origin) return; + // a root-hosted website must not isolate neighboring GitHub Pages projects + const relative = url.pathname.slice(new URL('./', self.location.href).pathname.length); + if (event.request.mode === 'navigate' && relative.includes('/')) return; event.respondWith((async () => { const response = await fetch(event.request); if (response.type === 'opaque') return response; diff --git a/scripts/playground/build.py b/scripts/playground/build.py index c0fcf64..8533ee5 100644 --- a/scripts/playground/build.py +++ b/scripts/playground/build.py @@ -89,10 +89,13 @@ def main(): out.writestr(item.filename, jar.read(item.filename)) for file in classes.rglob('*.class'): out.writestr(file.relative_to(classes).as_posix(), file.read_bytes()) - with zipfile.ZipFile(java_home / 'jmods/java.base.jmod') as jar, zipfile.ZipFile(OUTPUT / 'java-base.jar', 'w', zipfile.ZIP_DEFLATED) as out: - for name in jar.namelist(): - if name.startswith('classes/') and name.endswith('.class') and name != 'classes/module-info.class': - out.writestr(name[len('classes/'):], jar.read(name)) + with zipfile.ZipFile(OUTPUT / 'java-base.jar', 'w', zipfile.ZIP_DEFLATED) as out: + # include the JDK types used by the website's Swing and image examples + for module in ['java.base', 'java.desktop', 'java.datatransfer', 'java.xml']: + with zipfile.ZipFile(java_home / f'jmods/{module}.jmod') as jar: + for name in jar.namelist(): + if name.startswith('classes/') and name.endswith('.class') and name != 'classes/module-info.class': + out.writestr(name[len('classes/'):], jar.read(name)) with zipfile.ZipFile(z3_sources) as jar: native = jar.read('com/microsoft/z3/Native.java').decode() methods = [] diff --git a/scripts/playground/client.mjs b/scripts/playground/client.mjs new file mode 100644 index 0000000..81d1e8d --- /dev/null +++ b/scripts/playground/client.mjs @@ -0,0 +1,92 @@ +import { summarize } from './summary.mjs'; + +// Called before editors are mounted so the first isolation reload cannot lose edits. +export async function prepareVerifier() { + const key = 'liquidjava-isolation:' + new URL('../', import.meta.url).pathname; + if (globalThis.crossOriginIsolated) { + sessionStorage.removeItem(key); + return; + } + if (!globalThis.isSecureContext || !navigator.serviceWorker) { + throw new Error('Verification requires HTTPS and a browser with cross-origin isolation support.'); + } + if (sessionStorage.getItem(key)) { + throw new Error('This browser could not enable verification. Try a current Chrome, Firefox or Safari browser.'); + } + const script = new URL('../isolation.js', import.meta.url); + await navigator.serviceWorker.register(script, { + scope: new URL('../', import.meta.url).pathname, updateViaCache: 'none' + }); + await new Promise((resolve, reject) => { + const finish = error => { + clearTimeout(timer); + navigator.serviceWorker.removeEventListener('controllerchange', controlled); + if (error) reject(error); else resolve(); + }; + const controlled = () => { + if (navigator.serviceWorker.controller?.scriptURL === script.href) finish(); + }; + const timer = setTimeout(() => finish(new Error('The browser could not prepare verification. Reload and try again.')), 15000); + navigator.serviceWorker.addEventListener('controllerchange', controlled); + controlled(); + }); + sessionStorage.setItem(key, 'reload'); + location.reload(); + await new Promise(() => {}); +} + +export class BrowserVerifier { + worker; + pending; + timer; + + cancel() { + clearTimeout(this.timer); + this.worker?.terminate(); + this.worker = undefined; + this.pending?.reject(new DOMException('Verification stopped.', 'AbortError')); + this.pending = undefined; + } + + verify(files, onStatus = () => {}) { + if (this.pending) throw new Error('Verification is already running.'); + if (!globalThis.crossOriginIsolated || typeof SharedArrayBuffer === 'undefined') { + return Promise.reject(new Error('This browser cannot start the verifier. Reload the page and try again.')); + } + return new Promise((resolve, reject) => { + this.pending = { resolve, reject }; + const fail = message => { + this.pending?.reject(new Error(message)); + this.pending = undefined; + this.cancel(); + }; + const send = () => { + clearTimeout(this.timer); + onStatus('Verifying…'); + this.timer = setTimeout(() => fail('Verification timed out. Simplify the example and try again.'), 60000); + this.worker.postMessage({ type: 'verify', files }); + }; + if (this.worker) { send(); return; } + onStatus('Downloading and starting the verifier. This may take a moment…'); + this.timer = setTimeout(() => fail('The verifier took too long to load. Check your connection and try again.'), 120000); + try { this.worker = new Worker(new URL('./worker.js', import.meta.url)); } + catch { fail('The verifier could not start. Reload the page and try again.'); return; } + const current = this.worker; + current.onerror = () => { + if (this.worker === current) fail('The verifier could not start. Reload the page and try again.'); + }; + current.onmessage = ({ data }) => { + if (this.worker !== current) return; + if (data.type === 'status') onStatus(data.message); + if (data.type === 'ready') send(); + if (data.type === 'failure') fail(data.message); + if (data.type === 'result') { + clearTimeout(this.timer); + this.pending.resolve(summarize(data.result)); + this.pending = undefined; + if (data.result.status === 'failure') this.cancel(); + } + }; + }); + } +} diff --git a/scripts/playground/client.test.mjs b/scripts/playground/client.test.mjs new file mode 100644 index 0000000..1ff5bb4 --- /dev/null +++ b/scripts/playground/client.test.mjs @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { BrowserVerifier } from './client.mjs'; + +class Worker { + static instances = []; + constructor() { Worker.instances.push(this); } + postMessage(data) { this.sent = data; } + terminate() { this.terminated = true; } + emit(data) { this.onmessage({ data }); } +} + +test('reuse a ready worker with new files and compact results', async t => { + globalThis.Worker = Worker; + globalThis.crossOriginIsolated = true; + t.after(() => { delete globalThis.Worker; delete globalThis.crossOriginIsolated; }); + const client = new BrowserVerifier(); + const first = client.verify({ 'A.java': 'class A {}' }); + const worker = Worker.instances.at(-1); + worker.emit({ type: 'ready' }); + assert.equal(worker.sent.files['A.java'], 'class A {}'); + worker.emit({ type: 'result', result: { status: 'success', diagnostics: [] } }); + assert.equal((await first).status, 'success'); + const second = client.verify({ 'B.java': 'class B {}' }); + assert.deepEqual(worker.sent.files, { 'B.java': 'class B {}' }); + worker.emit({ type: 'result', result: { status: 'error', diagnostics: [{ title: 'Error', message: 'Bad value', output: 'full output' }] } }); + assert.deepEqual((await second).diagnostics, [{ title: 'Error', message: 'Bad value' }]); + client.cancel(); +}); + +test('cancellation terminates workers and late results cannot settle a later request', async t => { + globalThis.Worker = Worker; + globalThis.crossOriginIsolated = true; + t.after(() => { delete globalThis.Worker; delete globalThis.crossOriginIsolated; }); + const client = new BrowserVerifier(); + const first = client.verify({ 'A.java': '' }); + const old = Worker.instances.at(-1); + const cancelled = assert.rejects(first, { name: 'AbortError' }); + client.cancel(); + await cancelled; + assert.ok(old.terminated); + const next = client.verify({ 'B.java': '' }); + const current = Worker.instances.at(-1); + old.emit({ type: 'result', result: { status: 'success', diagnostics: [] } }); + assert.ok(client.pending); + current.emit({ type: 'ready' }); + current.emit({ type: 'failure', message: 'Solver unavailable' }); + await assert.rejects(next, /Solver unavailable/); + assert.ok(current.terminated); +}); diff --git a/scripts/playground/editor.js b/scripts/playground/editor.js index 0cadf31..6c5ae49 100644 --- a/scripts/playground/editor.js +++ b/scripts/playground/editor.js @@ -84,7 +84,7 @@ function send() { clearTimeout(timer); message('Verifying…'); timer = setTimeout(() => failure('Verification timed out. Simplify the example and try again.'), 30000); - worker.postMessage({ type: 'verify', source }); + worker.postMessage({ type: 'verify', files: { 'Example.java': source } }); } async function run() { if (checking || loading) return; diff --git a/scripts/playground/export.mjs b/scripts/playground/export.mjs new file mode 100644 index 0000000..8bd7e87 --- /dev/null +++ b/scripts/playground/export.mjs @@ -0,0 +1,16 @@ +import { copyFile, mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const destination = process.argv[2]; +if (!destination) throw new Error('Usage: node scripts/playground/export.mjs SITE_DIRECTORY'); +const root = fileURLToPath(new URL('../../', import.meta.url)); +const runtime = resolve(destination, 'verifier'); +await mkdir(runtime, { recursive: true }); +for (const file of ['worker.js', 'liquidjava.jar', 'java-base.jar', 'native-methods.json', 'z3-built.js', 'z3-built.wasm']) { + await copyFile(resolve(root, 'playground/runtime', file), resolve(runtime, file)); +} +for (const file of ['client.mjs', 'summary.mjs']) { + await copyFile(new URL(file, import.meta.url), resolve(runtime, file)); +} +await copyFile(resolve(root, 'playground/isolation.js'), resolve(destination, 'isolation.js')); diff --git a/scripts/playground/isolation.test.mjs b/scripts/playground/isolation.test.mjs new file mode 100644 index 0000000..4820da1 --- /dev/null +++ b/scripts/playground/isolation.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { runInNewContext } from 'node:vm'; + +const source = await readFile(new URL('../../playground/isolation.js', import.meta.url), 'utf8'); + +test('root isolation leaves neighboring Pages projects unchanged', async () => { + const listeners = {}; + runInNewContext(source, { + self: { location: new URL('https://example.com/isolation.js'), addEventListener: (type, listener) => { listeners[type] = listener; } }, + URL, Headers, Response, fetch: async () => new Response('body') + }); + const response = async (path, mode = 'navigate') => { + let pending; + listeners.fetch({ request: { url: new URL(path, 'https://example.com').href, mode }, respondWith: value => { pending = value; } }); + return pending; + }; + assert.equal((await response('/')).headers.get('Cross-Origin-Embedder-Policy'), 'require-corp'); + assert.equal((await response('/states')).headers.get('Cross-Origin-Opener-Policy'), 'same-origin'); + assert.equal((await response('/verifier/worker.js', 'same-origin')).headers.get('Cross-Origin-Embedder-Policy'), 'require-corp'); + assert.equal(await response('/liquidjava-docs/'), undefined); + assert.equal(await response('/liquidjava-docs/annotations/'), undefined); + assert.equal(await response('https://other.example/'), undefined); +}); diff --git a/scripts/playground/java/liquidjava/playground/BrowserRunner.java b/scripts/playground/java/liquidjava/playground/BrowserRunner.java index dc0bdc8..1145020 100644 --- a/scripts/playground/java/liquidjava/playground/BrowserRunner.java +++ b/scripts/playground/java/liquidjava/playground/BrowserRunner.java @@ -1,10 +1,12 @@ package liquidjava.playground; import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import liquidjava.diagnostics.Diagnostics; import liquidjava.diagnostics.LJDiagnostic; @@ -26,18 +28,29 @@ import spoon.support.compiler.jdt.JDTBasedSpoonCompiler; public final class BrowserRunner { - public static String verify(String source, String jar, String javaBase) { + public static String verify(String sources, String jar, String javaBase) { Map result = new LinkedHashMap<>(); ArrayList> issues = new ArrayList<>(); try { Path dir = Path.of("/files/playground"); Files.createDirectories(dir); - Path input = dir.resolve("Example.java"); - Files.writeString(input, source); + try (var previous = Files.list(dir)) { + for (Path file : previous.toList()) Files.delete(file); + } + Map files = new Gson().fromJson(sources, new TypeToken>() {}.getType()); + if (files == null || files.isEmpty()) throw new IllegalArgumentException("No Java files supplied"); + List inputs = new ArrayList<>(); + for (var file : files.entrySet()) { + if (!file.getKey().matches("[A-Za-z_$][A-Za-z0-9_$]*\\.java")) + throw new IllegalArgumentException("Invalid Java filename"); + Path input = dir.resolve(file.getKey()); + Files.writeString(input, file.getValue()); + inputs.add(input.toString()); + } Diagnostics.getInstance().clear(); ContextHistory.getInstance().clearHistory(); Launcher launcher = new Launcher(); - launcher.addInputResource(input.toString()); + launcher.addInputResource(dir.toString()); launcher.getEnvironment().setNoClasspath(true); launcher.getEnvironment().setComplianceLevel(8); launcher.getEnvironment().setSourceClasspath(new String[] {jar}); @@ -45,13 +58,15 @@ public static String verify(String source, String jar, String javaBase) { .classpathOptions(new ClasspathOptions().classpath(jar).bootclasspath(javaBase)) .complianceOptions(new ComplianceOptions().compliance(8)) .advancedOptions(new AdvancedOptions().preserveUnusedVars().continueExecution().enableJavadoc()) - .sources(new SourceOptions().sources(input.toString())); + .sources(new SourceOptions().sources(inputs.toArray(String[]::new))); JDTBasedSpoonCompiler compiler = (JDTBasedSpoonCompiler) launcher.getModelBuilder(); boolean built = compiler.build(builder); for (CategorizedProblem problem : compiler.getProblems()) { if (!problem.isError()) continue; Map issue = new LinkedHashMap<>(); issue.put("severity", "error"); + issue.put("title", "Java error"); + issue.put("message", problem.getMessage()); issue.put("output", problem.toString()); issue.put("line", problem.getSourceLineNumber()); issue.put("from", problem.getSourceStart()); @@ -84,6 +99,8 @@ public static String verify(String source, String jar, String javaBase) { private static Map issue(LJDiagnostic issue, String severity) { Map result = new LinkedHashMap<>(); result.put("severity", severity); + result.put("title", issue.getTitle()); + result.put("message", issue.getMessage()); result.put("output", issue.toString()); if (issue.getPosition() != null && issue.getPosition().isValidPosition()) { result.put("line", issue.getPosition().getLine()); diff --git a/scripts/playground/summary.mjs b/scripts/playground/summary.mjs new file mode 100644 index 0000000..a9812a6 --- /dev/null +++ b/scripts/playground/summary.mjs @@ -0,0 +1,8 @@ +export function summarize(result) { + if (result.status === 'failure') { + return { status: 'failure', diagnostics: [{ title: 'Verification could not complete', message: result.message || 'Please try again.' }] }; + } + const diagnostics = result.diagnostics.map(({ title, message }) => ({ title, message })); + if (!diagnostics.length) diagnostics.push({ title: 'Verification passed', message: 'All refinements verified.' }); + return { status: result.status, diagnostics }; +} diff --git a/scripts/playground/summary.test.mjs b/scripts/playground/summary.test.mjs new file mode 100644 index 0000000..4a4bcf4 --- /dev/null +++ b/scripts/playground/summary.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { summarize } from './summary.mjs'; + +test('compact diagnostics expose only titles and messages, never full output', () => { + assert.deepEqual(summarize({ status: 'error', diagnostics: [{ title: 'Type Error', message: 'Expected a positive value.', output: 'source and stack', severity: 'error' }] }), { + status: 'error', diagnostics: [{ title: 'Type Error', message: 'Expected a positive value.' }] + }); +}); + +test('success, warnings and runtime failures remain distinct', () => { + assert.equal(summarize({ status: 'success', diagnostics: [] }).diagnostics[0].title, 'Verification passed'); + assert.equal(summarize({ status: 'warning', diagnostics: [{ title: 'Warning', message: 'Check this.' }] }).status, 'warning'); + assert.deepEqual(summarize({ status: 'failure', message: 'Solver unavailable', details: 'stack' }), { + status: 'failure', diagnostics: [{ title: 'Verification could not complete', message: 'Solver unavailable' }] + }); +}); diff --git a/scripts/playground/worker.js b/scripts/playground/worker.js index e5eef76..d0ba855 100644 --- a/scripts/playground/worker.js +++ b/scripts/playground/worker.js @@ -30,7 +30,7 @@ self.onmessage = async event => { busy = true; try { await ready; - postMessage({ type: 'result', result: JSON.parse(await runner.verify(event.data.source, runnerJar, standardLibrary)) }); + postMessage({ type: 'result', result: JSON.parse(await runner.verify(JSON.stringify(event.data.files), runnerJar, standardLibrary)) }); } catch (error) { postMessage({ type: 'failure', message: await describe(error) }); } finally { busy = false; }