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
63 changes: 62 additions & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ jobs:
- host: windows-latest
target: aarch64-pc-windows-msvc
build: pnpm build --target aarch64-pc-windows-msvc
- host: ubuntu-latest
target: wasm32-wasip1-threads
build: pnpm build --target wasm32-wasip1-threads
name: stable - ${{ matrix.settings.target }}
runs-on: ${{ matrix.settings.host }}
steps:
Expand Down Expand Up @@ -156,7 +159,15 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: bindings-${{ matrix.settings.target }}
path: '*.node'
path: |
*.node
*.wasm
!*.debug.wasm
*.wasi.cjs
*.wasi-browser.js
*.wasi.d.cts
wasi-worker.mjs
wasi-worker-browser.mjs
if-no-files-found: error
test-macOS-windows-binding:
name: Test bindings on ${{ matrix.settings.target }} - node@${{ matrix.node }}
Expand Down Expand Up @@ -273,6 +284,55 @@ jobs:
name: test-binding
options: -v ${{ steps.docker.outputs.PNPM_STORE_PATH }}:${{ steps.docker.outputs.PNPM_STORE_PATH }} -v ${{ github.workspace }}:${{ github.workspace }} -w ${{ github.workspace }} --platform ${{ steps.docker.outputs.PLATFORM }}
args: npm run test
test-wasm-binding:
name: Test wasm32-wasi binding - node@${{ matrix.node }}
needs:
- build
strategy:
fail-fast: false
matrix:
node:
- '22'
- '26'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: setup pnpm
uses: pnpm/action-setup@v6
- name: Setup node
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Download artifacts
uses: actions/download-artifact@v8
with:
name: bindings-wasm32-wasip1-threads
path: .
- name: Download native artifacts
uses: actions/download-artifact@v8
with:
name: bindings-x86_64-unknown-linux-gnu
path: .
- name: List packages
run: ls -R .
shell: bash
- name: Test bindings (WASI forced)
run: pnpm test
env:
# The generated loader only honours the literal strings 'true' and
# 'error'; 'error' would also force @oxc-node/core (ava's TS loader)
# onto a WASI build it does not ship.
NAPI_RS_FORCE_WASI: 'true'
# Node prints "ExperimentalWarning: WASI" on stderr, which CLI tests compare.
NODE_OPTIONS: --no-warnings
- name: Test bindings (native, WASI cross-check)
run: pnpm test
env:
OPENAPI_NG_EXPECT_WASI_BINDING: '1'
NODE_OPTIONS: --no-warnings
publish:
name: Publish
runs-on: ubuntu-latest
Expand All @@ -281,6 +341,7 @@ jobs:
- test-rust-cross-os
- test-macOS-windows-binding
- test-linux-binding
- test-wasm-binding
steps:
- uses: actions/checkout@v7
- name: setup pnpm
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ dist
.pnp.*

*.node
*.wasm
*.wasi.cjs
*.wasi-browser.js
*.wasi.d.cts
wasi-worker.mjs
wasi-worker-browser.mjs

### Node Patch ###
# Serverless Webpack directories
Expand Down
17 changes: 8 additions & 9 deletions Cargo.lock

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

11 changes: 8 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ crate-type = ["cdylib", "rlib"]

[dependencies]
indexmap = { version = "2", features = ["serde"] }
napi = "3.0.0"
napi-derive = "3.0.0"
# Pinned together with napi-build below: napi >= 3.12 requires napi-build 2.4,
# which links for the emnapi v2 archive (`--export=emnapi_create_env`) that
# emnapi 1.x does not define; rustc >= 1.98 (lld) rejects the missing export.
# napi-derive 3.6 generates code only napi 3.12 provides.
napi = "=3.11.0"
napi-derive = "=3.5.10"
regex = "1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Expand All @@ -19,7 +23,8 @@ serde_json = "1.0"
serde_yml = "=0.0.12"

[build-dependencies]
napi-build = "2"
# See the napi pin above.
napi-build = "=2.3.2"

[dev-dependencies]
proptest = { version = "1.7", default-features = false, features = ["std"] }
Expand Down
117 changes: 100 additions & 17 deletions __test__/browser.spec.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,118 @@
import test from 'ava';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';

import { generate as nativeGenerate } from '../lib/index.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.join(__dirname, '..');
const require = createRequire(import.meta.url);

const browserEntry = require(path.join(repoRoot, 'browser.js')) as {
generate: (options?: unknown) => Promise<never>;
GenerateError: {
isGenerateError: (value: unknown) => boolean;
new (payload?: unknown): Error & { code?: string; message: string };
};
type GenerateFn = (options: unknown) => Promise<{
artifacts: Array<{ path: string; contents: string }>;
diagnostics: unknown[];
}>;
type BrowserEntry = {
generate: GenerateFn;
createGenerate: (load: () => Promise<unknown>) => GenerateFn;
GenerateError: { isGenerateError: (value: unknown) => boolean };
EmitTarget: { Models: string; Angular: string };
};
type TypedError = { code?: string; subcode?: string | null; message: string };

test('browser generate throws a GenerateError, not a plain Error', async t => {
const { generate, GenerateError } = browserEntry;
const err = await t.throwsAsync(async () => {
await generate({ inputPath: 'x', emit: ['models'] });
const browserEntry = require(path.join(repoRoot, 'browser.js')) as BrowserEntry;
const wasiCjsPath = path.join(repoRoot, 'openapi-ng.wasi.cjs');
// `import()` needs a URL, not a bare Windows path.
const wasiCjs = pathToFileURL(wasiCjsPath).href;
const hasWasi = fs.existsSync(wasiCjsPath);
// CI's cross-check job downloads both bindings, so a skipped WASI test there
// means the artifact went missing rather than that the run is a local one.
const expectWasi = process.env.OPENAPI_NG_EXPECT_WASI_BINDING === '1';
if (expectWasi && !hasWasi) {
test('WASI binding is present when OPENAPI_NG_EXPECT_WASI_BINDING is set', t => {
t.fail(`expected ${wasiCjsPath} to exist`);
});
t.true(GenerateError.isGenerateError(err));
t.is((err as { code?: string } | undefined)?.code, 'E_UNSUPPORTED_RUNTIME');
// Be lenient on the message — the test originally expected /browser/i but
// the new wrapper says "browser/runtime context".
t.regex(err?.message ?? '', /browser|runtime/i);
}
const wasiTest = hasWasi ? test : test.skip;
const petstore = fs.readFileSync(
path.join(repoRoot, 'test', 'fixtures', 'petstore-minimal.openapi.yaml'),
'utf8',
);
const petstoreOptions = {
inputContents: petstore,
displayPath: 'petstore-minimal.openapi.yaml',
emit: ['models', 'angular'],
};

wasiTest(
'browser generate through the WASI binding matches the native output',
async t => {
const generate = browserEntry.createGenerate(() => import(wasiCjs));
const [fromWasi, fromNative] = await Promise.all([
generate(petstoreOptions),
nativeGenerate(petstoreOptions),
]);
t.deepEqual(
fromWasi.artifacts.map(a => [a.path, a.contents]),
fromNative.artifacts.map(a => [a.path, a.contents]),
);
t.deepEqual(fromWasi.diagnostics, fromNative.diagnostics);
},
);

wasiTest('browser generate surfaces typed fatal diagnostics', async t => {
const generate = browserEntry.createGenerate(() => import(wasiCjs));
const err = (await t.throwsAsync(() =>
generate({
inputContents:
'openapi: 3.0.3\ninfo: {title: x, version: "1"}\npaths: {/a: {get: {responses: {"200": {description: ok}}}}}',
displayPath: 'x.yaml',
emit: ['models', 'angular'],
}),
)) as TypedError;
t.true(browserEntry.GenerateError.isGenerateError(err));
t.is(err.code, 'E_POLICY_VIOLATION');
t.is(err.subcode, 'missing-operation-id');
});

test('browser generate rejects inputPath with E_INVALID_OPTION', async t => {
const err = (await t.throwsAsync(() =>
browserEntry.generate({ inputPath: 'spec.yaml', emit: ['models'] }),
)) as TypedError;
t.is(err.code, 'E_INVALID_OPTION');
t.is(err.subcode, 'shape');
t.regex(err.message, /inputContents/);
});

test('browser generate rejects outputPath with E_INVALID_OPTION', async t => {
const err = (await t.throwsAsync(() =>
browserEntry.generate({ ...petstoreOptions, outputPath: 'out' }),
)) as TypedError;
t.is(err.code, 'E_INVALID_OPTION');
t.is(err.subcode, 'shape');
t.regex(err.message, /outputPath/);
});

test('browser generate maps a failed binding load to E_UNSUPPORTED_RUNTIME', async t => {
const generate = browserEntry.createGenerate(() =>
Promise.reject(new Error('no wasm here')),
);
const err = (await t.throwsAsync(() => generate(petstoreOptions))) as TypedError;
t.is(err.code, 'E_UNSUPPORTED_RUNTIME');
t.regex(err.message, /openapi-ng-wasm32-wasi/);
t.regex(err.message, /no wasm here/);
});

test('browser generate maps a binding without generateNative to E_UNSUPPORTED_RUNTIME', async t => {
const generate = browserEntry.createGenerate(() => Promise.resolve({}));
const err = (await t.throwsAsync(() => generate(petstoreOptions))) as TypedError;
t.is(err.code, 'E_UNSUPPORTED_RUNTIME');
t.regex(err.message, /generateNative/);
});

test('browser entry exports EmitTarget mirror', t => {
t.truthy(browserEntry.EmitTarget);
t.is(browserEntry.EmitTarget.Models, 'models');
t.is(browserEntry.EmitTarget.Angular, 'angular');
});
16 changes: 10 additions & 6 deletions __test__/generate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1444,7 +1444,11 @@ test('generate wraps Rust panic into E_UNEXPECTED GenerateError', async t => {
{ instanceOf: GenerateError },
);
t.is(err?.code, 'E_UNEXPECTED');
t.regex(err?.message ?? '', /unexpected.*panic/i);
// wasm cannot unwind, so a panic traps as `unreachable` and the payload is
// lost; only the native binding carries the panic text through.
if (process.env.NAPI_RS_FORCE_WASI !== 'true') {
t.regex(err?.message ?? '', /unexpected.*panic/i);
}
});

test('GenerateError is a real class so consumers can guard with instanceof', async t => {
Expand Down Expand Up @@ -1508,11 +1512,11 @@ test('GenerateError.isGenerateError detects upgraded errors via the cross-realm
t.false(GenerateError.isGenerateError({ code: 'E_INVALID_OPTION' }));
});

test('GenerateError marker constant matches the value Rust embeds via build.rs', async t => {
// `lib/error-marker.json` is the single source of truth shared with
// the Rust binding through `env!("OPENAPI_NG_ERROR_MARKER")` (see
// build.rs). The constant inside the published JSON file must remain
// a non-empty string so the cross-realm marker survives the boundary.
test('GenerateError marker constant is stamped by the constructor alone', async t => {
// `lib/error-marker.json` is the only source of the marker; the Rust side
// neither embeds nor reads it, `GenerateError`'s constructor stamps it.
// The constant must stay a non-empty string so the cross-realm marker
// survives the boundary.
const marker = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'lib', 'error-marker.json'), 'utf8'),
);
Expand Down
Loading
Loading