Skip to content
Merged
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
31 changes: 31 additions & 0 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: release gate

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '22'

# The minifier configuration is part of the release record, so the version
# is pinned rather than floating.
- uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.14'

- run: npm install --no-package-lock --no-save playwright@1.56.1
- run: npx playwright install --with-deps chromium

# Unit tests, minification, unit tests against the minified artifact, the
# byte budget, and a real Chromium loading the library through
# /content/<id> exactly as an inscription does.
- run: node scripts/gate.mjs
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
*.DS_Store
*.DS_Store
node_modules/
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,34 @@ This example outlines the basic structure for utilizing OrdJS within an asynchro
- The pinned CBOR decoder still loses precision on 64-bit integers and collapses
distinct map keys. Fixing that needs a separate audited decoder inscription.

## Tests
## Tests and the release gate

`test/` runs on Node's built-in runner with no dependencies:

```sh
node --test test/ordjs.test.mjs
```

Tests, examples and this README are repository files only; inscribing the library
should use a minified `src/content/OrdJS.js`.
Before inscribing, run the full gate. It runs the tests, minifies with a pinned
configuration, re-runs the tests against the **minified** artifact, enforces a byte
budget against the size of the live inscription, drives a real Chromium that loads
the library through `/content/<id>` exactly as an inscription does, and prints the
byte counts, hash and dependency inscription IDs a release record needs:

```sh
bun install # playwright, dev-only
npx playwright install chromium
node scripts/gate.mjs # --no-browser skips only the Chromium stage
```

CI runs the same gate on every push and pull request.

The gate does not cover, and a release still requires: a real ord server with the
sat index both enabled and disabled, Firefox and WebKit, and a commit/reveal fee
dry-run at the chosen rate.

Tests, examples, tooling and this README are repository files only; inscribing the
library uses a minified `src/content/OrdJS.js` and nothing else.

## Contributing

Expand Down
17 changes: 17 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "ordjs",
"version": "0.1.3-beta",
"private": true,
"description": "Development tooling for the OrdJS inscription. Nothing here is inscribed: the released artifact is a minified src/content/OrdJS.js.",
"license": "MIT",
"scripts": {
"test": "node --test test/ordjs.test.mjs",
"smoke": "node scripts/browser-smoke.mjs",
"gate": "node scripts/gate.mjs"
},
"devDependencies": {
"playwright": "^1.56.1"
}
}
126 changes: 126 additions & 0 deletions scripts/browser-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Real-browser smoke test for the inscription path.
//
// Serves a stub ord server, loads the library the way an inscription does —
// <script src="/content/<library-id>"> on the same origin — and drives the public
// API from inside the page. This is the part a unit test cannot cover: the
// recursive script load, the real fetch/btoa/document, and the decoder inscription
// being pulled in lazily by a second <script> tag.
//
// Usage: node scripts/browser-smoke.mjs [path-to-library.js]

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';

const LIB_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaai0';
const DECODER_ID = 'a9f6a9b050af3de1a4ce714978c1f2231ba731f1f46731a16d0e411f89308566i0';
const IMAGE_ID = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbi0';
const EMPTY_SAT = 100;
const FULL_SAT = 200;

const libPath = process.argv[2] ?? fileURLToPath(new URL('../src/content/OrdJS.js', import.meta.url));
const library = await readFile(libPath, 'utf8');

// 1 MiB of deterministic bytes: the payload that used to blow the call stack.
const bigContent = Buffer.from(Uint8Array.from({ length: 1024 * 1024 }, (_, i) => i % 256));

// A stand-in for the inscribed CBOR decoder. The smoke test asserts that it is
// fetched exactly once and only when decoded metadata is requested; decoder
// correctness itself is a separate concern.
const decoderScript = `globalThis.CBOR = { decode: (buf) => ({ bytes: [...new Uint8Array(buf)] }) };
globalThis.__decoderLoads = (globalThis.__decoderLoads ?? 0) + 1;`;

const routes = new Map([
[`/content/${LIB_ID}`, { type: 'text/javascript', body: Buffer.from(library) }],
[`/content/${DECODER_ID}`, { type: 'text/javascript', body: Buffer.from(decoderScript) }],
[`/content/${IMAGE_ID}`, { type: 'image/png', body: bigContent }],
[`/r/blockheight`, { type: 'application/json', body: Buffer.from('850000') }],
[`/r/sat/${EMPTY_SAT}/at/-1`, { type: 'application/json', body: Buffer.from('{"id":null}') }],
[`/r/sat/${FULL_SAT}/at/-1`, { type: 'application/json', body: Buffer.from(`{"id":"${IMAGE_ID}"}`) }],
[`/r/sat/${FULL_SAT}/at/0`, { type: 'application/json', body: Buffer.from(`{"id":"${IMAGE_ID}"}`) }],
[`/r/sat/${FULL_SAT}/0`, { type: 'application/json', body: Buffer.from('{"ids":[],"more":false,"page":0}') }],
[`/r/metadata/${IMAGE_ID}`, { type: 'application/json', body: Buffer.from('"0102"') }],
]);

const requested = [];
const server = createServer((req, res) => {
requested.push(req.url);
if (req.url === '/') {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(`<!doctype html><meta charset="utf-8"><script src="/content/${LIB_ID}"></script>`);
return;
}
const route = routes.get(req.url);
if (!route) {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('not found');
return;
}
res.writeHead(200, { 'content-type': route.type });
res.end(route.body);
});

await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;

const browser = await chromium.launch();
const failures = [];
try {
const page = await browser.newPage();
page.on('pageerror', (error) => failures.push(`page error: ${error.message}`));
await page.goto(`${origin}/`, { waitUntil: 'load' });

const loaded = await page.evaluate(() => typeof OrdJS);
if (loaded !== 'function') {
throw new Error(`library did not define OrdJS through the recursive script path (typeof ${loaded})`);
}

const result = await page.evaluate(async (ids) => {
const ord = new OrdJS('');
const out = {};
out.blockheight = await ord.getBlockheight();
out.emptySat = await ord.getSatLastInscriptionContent(ids.emptySat);

const content = await ord.getSatLastInscriptionContent(ids.fullSat);
out.mime = content.mime;
const bytes = Uint8Array.from(atob(content.base64), (c) => c.charCodeAt(0));
out.length = bytes.length;
out.exact = bytes.every((b, i) => b === i % 256);

out.decoderBefore = globalThis.__decoderLoads ?? 0;
const [a, b] = await Promise.all([
ord.getDecodedMetadata(ids.imageId),
ord.getDecodedMetadata(ids.imageId)
]);
out.decoded = [a.bytes, b.bytes];
out.decoderAfter = globalThis.__decoderLoads ?? 0;
out.scriptTags = document.querySelectorAll('script[src^="/content/"]').length;
return out;
}, { emptySat: EMPTY_SAT, fullSat: FULL_SAT, imageId: IMAGE_ID });

const check = (name, ok, detail) => {
if (!ok) failures.push(`${name}: ${detail}`);
};
check('blockheight', result.blockheight === 850000, `got ${result.blockheight}`);
check('empty sat resolves null', result.emptySat === null, `got ${JSON.stringify(result.emptySat)}`);
check('mime preserved', result.mime?.startsWith('image/png'), `got ${result.mime}`);
check('1 MiB length', result.length === 1024 * 1024, `got ${result.length}`);
check('1 MiB bytes exact', result.exact === true, 'content did not round-trip');
check('decoder not loaded early', result.decoderBefore === 0, `loaded ${result.decoderBefore} times before use`);
check('decoder loaded once', result.decoderAfter === 1, `loaded ${result.decoderAfter} times`);
check('decoded metadata', JSON.stringify(result.decoded) === JSON.stringify([[1, 2], [1, 2]]),
JSON.stringify(result.decoded));
check('no /content/null request', !requested.includes('/content/null'), 'library requested /content/null');
check('recursive script tags', result.scriptTags === 2, `found ${result.scriptTags}`);
} finally {
await browser.close();
server.close();
}

if (failures.length > 0) {
console.error('browser smoke FAILED');
for (const failure of failures) console.error(` - ${failure}`);
process.exit(1);
}
console.log(`browser smoke ok (${libPath})`);
104 changes: 104 additions & 0 deletions scripts/gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Release gate: everything that must pass before bytes are inscribed.
//
// Inscribing is irreversible and costs money, so this runs the checks that a
// unit test alone does not cover, and prints the byte accounting a release
// record needs.
//
// node scripts/gate.mjs full gate (needs bun + playwright chromium)
// node scripts/gate.mjs --no-browser skip only the real-browser stage
//
// Stages:
// 1. unit tests against the readable source
// 2. minify with a pinned, recorded configuration
// 3. unit tests again against the MINIFIED artifact (the thing that ships)
// 4. byte budget: the artifact must not exceed the live inscription body
// 5. real browser: recursive /content/<id> load, 1 MiB round-trip, lazy decoder
// 6. release record: sizes, hashes, dependency inscription IDs

import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, writeFileSync, cpSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = fileURLToPath(new URL('..', import.meta.url));
const source = join(root, 'src/content/OrdJS.js');
const withBrowser = !process.argv.includes('--no-browser');

// The body of the live v0.1.2 inscription. A release must not grow past what is
// already on chain without a deliberate decision.
const BUDGET_BYTES = 3911;

// Minifier configuration is part of the release record: a different config
// produces a different artifact from identical source.
const MINIFY = ['build', '--no-bundle', '--minify-syntax', '--minify-whitespace', '--target=browser'];

const steps = [];
function run(name, command, args, options = {}) {
process.stdout.write(`\n=== ${name}\n`);
const result = spawnSync(command, args, { stdio: 'inherit', cwd: root, ...options });
const ok = result.status === 0;
steps.push({ name, ok });
if (!ok && result.error) console.error(String(result.error.message));
return ok;
}

run('unit tests (source)', 'node', ['--test', 'test/ordjs.test.mjs']);

const work = mkdtempSync(join(tmpdir(), 'ordjs-gate-'));
const minified = join(work, 'OrdJS.min.js');
const minifiedOk = run('minify', 'bun', [...MINIFY, source, '--outfile', minified]);

let bytes = null;
if (minifiedOk && existsSync(minified)) {
const artifact = readFileSync(minified);
bytes = artifact.length;

// Run the same suite against the minified artifact by swapping it in for the
// source the tests read. A minifier that breaks behaviour must fail here.
const mirror = join(work, 'repo');
cpSync(root, mirror, {
recursive: true,
filter: (path) => !path.includes('/.git/') && !path.endsWith('/.git')
});
writeFileSync(join(mirror, 'src/content/OrdJS.js'), artifact);
run('unit tests (minified artifact)', 'node', ['--test', 'test/ordjs.test.mjs'], { cwd: mirror });

const withinBudget = bytes <= BUDGET_BYTES;
steps.push({ name: `byte budget (${bytes} <= ${BUDGET_BYTES})`, ok: withinBudget });
process.stdout.write(`\n=== byte budget\n${bytes} B minified, budget ${BUDGET_BYTES} B, ` +
`${withinBudget ? `${BUDGET_BYTES - bytes} B of headroom` : `${bytes - BUDGET_BYTES} B OVER`}\n`);

if (withBrowser) {
run('browser smoke (minified artifact)', 'node', ['scripts/browser-smoke.mjs', minified]);
} else {
process.stdout.write('\n=== browser smoke\nSKIPPED (--no-browser): the inscription path is unverified\n');
}

const sha256 = createHash('sha256').update(artifact).digest('hex');
const commit = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout?.trim();
const dependencies = [...readFileSync(source, 'utf8').matchAll(/[0-9a-f]{64}i\d+/g)].map((m) => m[0]);

process.stdout.write('\n=== release record\n');
process.stdout.write(JSON.stringify({
commit,
source_bytes: readFileSync(source).length,
artifact_bytes: bytes,
artifact_sha256: sha256,
minifier: `bun ${spawnSync('bun', ['--version'], { encoding: 'utf8' }).stdout?.trim()} ${MINIFY.join(' ')}`,
dependency_inscriptions: dependencies,
live_inscription_body_bytes: BUDGET_BYTES
}, null, 2) + '\n');
process.stdout.write('\nNot covered by this gate, and required before inscribing: a real ord server ' +
'(indexed and non-indexed), Firefox and WebKit, and a commit/reveal fee dry-run at the chosen rate.\n');
}

const failed = steps.filter((step) => !step.ok);
process.stdout.write('\n=== gate summary\n');
for (const step of steps) process.stdout.write(`${step.ok ? 'pass' : 'FAIL'} ${step.name}\n`);
if (failed.length > 0) {
process.stdout.write(`\n${failed.length} stage(s) failed; do not inscribe.\n`);
process.exit(1);
}
process.stdout.write('\nAll gate stages passed.\n');
Loading