diff --git a/Cargo.lock b/Cargo.lock index 6d27a6cfa..5e8c4bc8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -234,6 +234,8 @@ dependencies = [ "anyhow", "base64", "heck 0.5.0", + "log", + "semver", "wasm-encoder 0.212.0", "wasmparser", "wasmtime-environ", diff --git a/Cargo.toml b/Cargo.toml index 237deadad..ea1346b26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ strip = true anyhow = "1.0.86" base64 = "0.22.1" heck = "0.5.0" +log = "0.4.22" +semver = "1.0.23" js-component-bindgen = { path = "./crates/js-component-bindgen" } structopt = "0.3.26" wasm-encoder = "0.212.0" @@ -55,4 +57,4 @@ wit-parser = "0.212.0" xshell = "0.2.6" [dev-dependencies] -anyhow = { workspace = true } +anyhow = { workspace = true } \ No newline at end of file diff --git a/crates/js-component-bindgen-component/src/lib.rs b/crates/js-component-bindgen-component/src/lib.rs index 636dc2e31..b6fdb41f1 100644 --- a/crates/js-component-bindgen-component/src/lib.rs +++ b/crates/js-component-bindgen-component/src/lib.rs @@ -113,6 +113,20 @@ impl Guest for JsComponentBindgenComponent { opts: TypeGenerationOptions, ) -> Result)>, String> { let mut resolve = Resolve::default(); + + // Add features if specified + match opts.features { + Some(EnabledFeatureSet::List(ref features)) => { + for f in features.into_iter() { + resolve.features.insert(f.to_string()); + } + } + Some(EnabledFeatureSet::All) => { + resolve.all_features = true; + } + _ => {} + } + let ids = match opts.wit { Wit::Source(source) => resolve .push_str(format!("{name}.wit"), &source) diff --git a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit index c0b19cde5..631e5deef 100644 --- a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit +++ b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit @@ -73,6 +73,14 @@ world js-component-bindgen { path(string), } + /// Enumerate enabled features + variant enabled-feature-set { + /// Enable only the given list of features + %list(list), + /// Enable all features + all, + } + record type-generation-options { /// wit to generate typing from wit: wit, @@ -81,6 +89,8 @@ world js-component-bindgen { tla-compat: option, instantiation: option, map: option, + /// Features that should be enabled as part of feature gating + features: option, } enum export-type { diff --git a/crates/js-component-bindgen/Cargo.toml b/crates/js-component-bindgen/Cargo.toml index da8ef8f8f..f2f5421d8 100644 --- a/crates/js-component-bindgen/Cargo.toml +++ b/crates/js-component-bindgen/Cargo.toml @@ -22,11 +22,13 @@ transpile-bindgen = [] [dependencies] anyhow = { workspace = true } +base64 = { workspace = true } heck = { workspace = true } +log = { workspace = true } +semver = { workspace = true } +wasm-encoder = { workspace = true } wasmparser = { workspace = true } wasmtime-environ = { workspace = true, features = ['component-model'] } wit-bindgen-core = { workspace = true } wit-component = { workspace = true } wit-parser = { workspace = true } -base64 = { workspace = true } -wasm-encoder = { workspace = true } diff --git a/crates/js-component-bindgen/src/lib.rs b/crates/js-component-bindgen/src/lib.rs index eef83e704..22d0d68d5 100644 --- a/crates/js-component-bindgen/src/lib.rs +++ b/crates/js-component-bindgen/src/lib.rs @@ -13,14 +13,14 @@ pub use transpile_bindgen::{BindingsMode, InstantiationMode, TranspileOpts}; use anyhow::Result; use transpile_bindgen::transpile_bindgen; -use anyhow::{bail, Context}; +use anyhow::{bail, ensure, Context}; use wasmtime_environ::component::{ComponentTypesBuilder, Export, StaticModuleIndex}; use wasmtime_environ::wasmparser::Validator; use wasmtime_environ::{PrimaryMap, ScopeVec, Tunables}; use wit_component::DecodedWasm; use ts_bindgen::ts_bindgen; -use wit_parser::{Resolve, Type, TypeDefKind, TypeId, WorldId}; +use wit_parser::{Package, Resolve, Stability, Type, TypeDefKind, TypeId, WorldId}; /// Calls [`write!`] with the passed arguments and unwraps the result. /// @@ -67,7 +67,8 @@ pub fn generate_types( ) -> Result)>, anyhow::Error> { let mut files = files::Files::default(); - ts_bindgen(&name, &resolve, world_id, &opts, &mut files); + ts_bindgen(&name, &resolve, world_id, &opts, &mut files) + .context("failed to generate Typescript bindings")?; let mut files_out: Vec<(String, Vec)> = Vec::new(); for (name, source) in files.iter() { @@ -137,7 +138,8 @@ pub fn transpile(component: &[u8], opts: TranspileOpts) -> Result TypeId { } } } + +/// Check if an item (usually some form of [`WorldItem`]) should be allowed through the feature gate +/// of a given package. +fn feature_gate_allowed( + resolve: &Resolve, + package: &Package, + stability: &Stability, + item_name: &str, +) -> Result { + Ok(match stability { + Stability::Unknown => true, + Stability::Stable { since, .. } => { + let Some(package_version) = package.name.version.as_ref() else { + // If the package version is missing (we're likely dealing with an unresolved package) + // and we can't really check much. + return Ok(true); + }; + + ensure!( + package_version >= since, + "feature gate on [{item_name}] refers to an unreleased (future) package version [{since}] (current package version is [{package_version}])" + ); + + // Stabilization (@since annotation) overrides features and deprecation + true + } + Stability::Unstable { feature } => { + // If a @unstable feature is present but the related feature was not enabled + // or all features was not selected, exclude + resolve.all_features || resolve.features.contains(feature) + } + }) +} diff --git a/crates/js-component-bindgen/src/ts_bindgen.rs b/crates/js-component-bindgen/src/ts_bindgen.rs index 02d1112bb..f90d407a9 100644 --- a/crates/js-component-bindgen/src/ts_bindgen.rs +++ b/crates/js-component-bindgen/src/ts_bindgen.rs @@ -3,12 +3,17 @@ use crate::function_bindgen::{array_ty, as_nullable, maybe_null}; use crate::names::{is_js_identifier, maybe_quote_id, LocalNames, RESERVED_KEYWORDS}; use crate::source::Source; use crate::transpile_bindgen::{parse_world_key, InstantiationMode, TranspileOpts}; -use crate::{dealias, uwrite, uwriteln}; +use crate::{dealias, feature_gate_allowed, uwrite, uwriteln}; +use anyhow::{Context as _, Result}; use heck::*; +use log::debug; use std::collections::btree_map::Entry; use std::collections::{BTreeMap, HashSet}; use std::fmt::Write; -use wit_parser::*; +use wit_bindgen_core::wit_parser::{ + Docs, Enum, Flags, Function, FunctionKind, Handle, InterfaceId, Record, Resolve, Result_, + Tuple, Type, TypeDefKind, TypeId, TypeOwner, Variant, WorldId, WorldItem, WorldKey, +}; struct TsBindgen { /// The source code for the "main" file that's going to be created for the @@ -47,7 +52,7 @@ pub fn ts_bindgen( id: WorldId, opts: &TranspileOpts, files: &mut Files, -) { +) -> Result<()> { let mut bindgen = TsBindgen { src: Source::default(), interface_names: LocalNames::default(), @@ -57,42 +62,81 @@ pub fn ts_bindgen( }; let world = &resolve.worlds[id]; + let package = resolve + .packages + .get( + world + .package + .context("unexpectedly missing package in world")?, + ) + .context("unexpectedly missing package in world for ID")?; { let mut funcs = Vec::new(); let mut interface_imports = BTreeMap::new(); for (name, import) in world.imports.iter() { match import { - WorldItem::Function(f) => match name { - WorldKey::Name(name) => funcs.push((name.to_string(), f)), - WorldKey::Interface(id) => funcs.push((resolve.id_of(*id).unwrap(), f)), - }, - WorldItem::Interface { id, stability: _ } => match name { - WorldKey::Name(name) => { - // kebab name -> direct ns namespace import - bindgen.import_interface(resolve, name, *id, files); + WorldItem::Function(f) => { + if !feature_gate_allowed(resolve, package, &f.stability, &f.name) + .context("failed to check feature gate for imported function")? + { + debug!("skipping imported function [{}] feature gate due to feature gate visibility", f.name); + continue; } - // namespaced ns:pkg/iface - // TODO: map support - WorldKey::Interface(id) => { + + match name { + WorldKey::Name(name) => funcs.push((name.to_string(), f)), + WorldKey::Interface(id) => funcs.push((resolve.id_of(*id).unwrap(), f)), + } + } + WorldItem::Interface { id, stability } => { + let iface_name = &resolve.interfaces[*id] + .name + .as_ref() + .map(String::as_str) + .unwrap_or(""); + if !feature_gate_allowed(resolve, package, &stability, iface_name) + .context("failed to check feature gate for imported interface")? + { let import_specifier = resolve.id_of(*id).unwrap(); let (_, _, iface) = parse_world_key(&import_specifier).unwrap(); - let iface = iface.to_string(); - match interface_imports.entry(import_specifier) { - Entry::Vacant(entry) => { - entry.insert(vec![("*".into(), id)]); - } - Entry::Occupied(ref mut entry) => { - entry.get_mut().push((iface, id)); + debug!("skipping imported interface [{}] feature gate due to feature gate visibility", iface.to_string()); + continue; + } + + match name { + WorldKey::Name(name) => { + // kebab name -> direct ns namespace import + bindgen.import_interface(resolve, &name, *id, files); + } + // namespaced ns:pkg/iface + // TODO: map support + WorldKey::Interface(id) => { + let import_specifier = resolve.id_of(*id).unwrap(); + let (_, _, iface) = parse_world_key(&import_specifier).unwrap(); + let iface = iface.to_string(); + match interface_imports.entry(import_specifier) { + Entry::Vacant(entry) => { + entry.insert(vec![("*".into(), id)]); + } + Entry::Occupied(ref mut entry) => { + entry.get_mut().push((iface, id)); + } } } } - }, + } WorldItem::Type(tid) => { let ty = &resolve.types[*tid]; - let name = ty.name.as_ref().unwrap(); + if !feature_gate_allowed(resolve, package, &ty.stability, name) + .context("failed to check feature gate for imported type")? + { + debug!("skipping imported type [{name}] feature gate due to feature gate visibility"); + continue; + } + let mut gen = bindgen.ts_interface(resolve, true); gen.docs(&ty.docs); match &ty.kind { @@ -134,6 +178,7 @@ pub fn ts_bindgen( let mut funcs = Vec::new(); let mut seen_names = HashSet::new(); let mut export_aliases: Vec<(String, String)> = Vec::new(); + for (name, export) in world.exports.iter() { match export { WorldItem::Function(f) => { @@ -141,10 +186,16 @@ pub fn ts_bindgen( WorldKey::Name(export_name) => export_name, WorldKey::Interface(_) => unreachable!(), }; + if !feature_gate_allowed(resolve, package, &f.stability, &f.name) + .context("failed to check feature gate for export")? + { + debug!("skipping exported interface [{export_name}] feature gate due to feature gate visibility"); + continue; + } seen_names.insert(export_name.to_string()); funcs.push((export_name.to_lower_camel_case(), f)); } - WorldItem::Interface { id, stability: _ } => { + WorldItem::Interface { id, stability } => { let iface_id: String; let (export_name, iface_name): (&str, &str) = match name { WorldKey::Name(export_name) => (export_name, export_name), @@ -154,6 +205,14 @@ pub fn ts_bindgen( (iface_id.as_ref(), iface) } }; + + if !feature_gate_allowed(resolve, package, &stability, iface_name) + .context("failed to check feature gate for export")? + { + debug!("skipping exported interface [{export_name}] feature gate due to feature gate visibility"); + continue; + } + seen_names.insert(export_name.to_string()); let local_name = bindgen.export_interface( resolve, @@ -298,6 +357,7 @@ pub fn ts_bindgen( } files.push(&format!("{name}.d.ts"), bindgen.src.as_bytes()); + Ok(()) } impl TsBindgen { @@ -414,6 +474,14 @@ impl TsBindgen { id: InterfaceId, files: &mut Files, ) -> String { + let iface = resolve + .interfaces + .get(id) + .expect("unexpectedly missing interface in resolve"); + let package = resolve + .packages + .get(iface.package.expect("missing package on interface")) + .expect("unexpectedly missing package"); let id_name = resolve.id_of(id).unwrap_or_else(|| name.to_string()); let goal_name = interface_goal_name(&id_name); let goal_name_kebab = goal_name.to_kebab_case(); @@ -456,10 +524,16 @@ impl TsBindgen { let mut gen = self.ts_interface(resolve, false); uwriteln!(gen.src, "export namespace {camel} {{"); - for (_, func) in resolve.interfaces[id].functions.iter() { + // Ensure that the function the world item for stability guarantees and exclude if they do not match + if !feature_gate_allowed(resolve, package, &func.stability, &func.name) + .expect("failed to check feature gate for function") + { + continue; + } gen.ts_func(func, false, true); } + // Export resources for the interface for (_, ty) in resolve.interfaces[id].types.iter() { let ty = &resolve.types[*ty]; if let TypeDefKind::Resource = ty.kind { diff --git a/crates/wasm-tools-component/src/lib.rs b/crates/wasm-tools-component/src/lib.rs index b8ca53521..186b71175 100644 --- a/crates/wasm-tools-component/src/lib.rs +++ b/crates/wasm-tools-component/src/lib.rs @@ -7,7 +7,8 @@ use wit_component::{ComponentEncoder, DecodedWasm, WitPrinter}; use wit_parser::Resolve; use exports::local::wasm_tools::tools::{ - EmbedOpts, Guest, ModuleMetaType, ModuleMetadata, ProducersFields, StringEncoding, + EmbedOpts, EnabledFeatureSet, Guest, ModuleMetaType, ModuleMetadata, ProducersFields, + StringEncoding, }; wit_bindgen::generate!({ @@ -74,6 +75,20 @@ impl Guest for WasmToolsJs { let mut resolve = Resolve::default(); + // Add all features specified in embed options to the resolve + // (this helps identify/use feature gating properly) + match embed_opts.features { + Some(EnabledFeatureSet::List(ref features)) => { + for f in features.into_iter() { + resolve.features.insert(f.to_string()); + } + } + Some(EnabledFeatureSet::All) => { + resolve.all_features = true; + } + _ => {} + }; + let ids = if let Some(wit_source) = &embed_opts.wit_source { let path = PathBuf::from("component.wit"); resolve @@ -89,6 +104,7 @@ impl Guest for WasmToolsJs { }; let world_string = embed_opts.world.as_ref().map(|world| world.to_string()); + let world = resolve .select_world(&ids, world_string.as_deref()) .map_err(|e| e.to_string())?; diff --git a/crates/wasm-tools-component/wit/wasm-tools.wit b/crates/wasm-tools-component/wit/wasm-tools.wit index cb2c5fd81..8c4ac17cb 100644 --- a/crates/wasm-tools-component/wit/wasm-tools.wit +++ b/crates/wasm-tools-component/wit/wasm-tools.wit @@ -21,18 +21,35 @@ interface tools { type producers-fields = list>>>; + /// Enumerate enabled features + variant enabled-feature-set { + /// Enable only the given list of features + %list(list), + /// Enable all features + all, + } + /// Embed a WIT type into a component. /// Only a singular WIT document without use resolutions is supported for this API. record embed-opts { binary: option>, + /// Pass an inline WIT source wit-source: option, + /// Pass the file system path to WIT file wit-path: option, + string-encoding: option, + dummy: option, + %world: option, - metadata: option + + metadata: option, + + /// Features that should be enabled as part of feature gating + features: option, } component-embed: func(embed-opts: embed-opts) -> result, string>; diff --git a/src/cmd/transpile.js b/src/cmd/transpile.js index cca9e2dcf..20c56d8b4 100644 --- a/src/cmd/transpile.js +++ b/src/cmd/transpile.js @@ -27,6 +27,7 @@ export async function types (witPath, opts) { * instantiation?: 'async' | 'sync', * tlaCompat?: bool, * outDir?: string, + * features?: string[] | 'all', * }} opts * @returns {Promise<{ [filename: string]: Uint8Array }>} */ @@ -42,11 +43,20 @@ export async function typesComponent (witPath, opts) { let outDir = (opts.outDir ?? '').replace(/\\/g, '/'); if (!outDir.endsWith('/') && outDir !== '') outDir += '/'; + + let features = null; + if (opts.allFeatures) { + features = { tag: 'all' }; + } else if (Array.isArray(opts.feature)) { + features = { tag: 'list', val: opts.feature }; + } + return Object.fromEntries(generateTypes(name, { wit: { tag: 'path', val: (isWindows ? '//?/' : '') + resolve(witPath) }, instantiation, tlaCompat: opts.tlaCompat ?? false, - world: opts.worldName + world: opts.worldName, + features, }).map(([name, file]) => [`${outDir}${name}`, file])); } @@ -59,7 +69,7 @@ async function writeFiles(files, summaryTitle) { return; console.log(c` {bold ${summaryTitle}:} - + ${table(Object.entries(files).map(([name, source]) => [ c` - {italic ${name}} `, c`{black.italic ${sizeStr(source.length)}}` diff --git a/src/jco.js b/src/jco.js index a9da350e1..b9c2a8b2d 100755 --- a/src/jco.js +++ b/src/jco.js @@ -17,6 +17,16 @@ function myParseInt(value) { return parseInt(value, 10); } +/** +* Option parsing that allows for collecting repeated arguments +* +* @param {string} value - the new value that is added +* @param {string[]} previous - the existing list of values +*/ +function collectOptions(value, previous) { + return previous.concat([value]); +} + program.command('componentize') .description('Create a component from a JavaScript module') .usage(' --wit wit-world.wit -o ') @@ -64,6 +74,8 @@ program.command('types') .option('--tla-compat', 'generates types for the TLA compat output with an async $init promise export') .addOption(new Option('-I, --instantiation [mode]', 'type output for custom module instantiation').choices(['async', 'sync']).preset('async')) .option('-q, --quiet', 'disable output summary') + .option('--feature ', 'enable one specific WIT feature (repeatable)', collectOptions, []) + .option('--all-features', 'enable all features') .action(asyncAction(types)); program.command('run') diff --git a/test/cli.js b/test/cli.js index e735b2f9c..712f20569 100644 --- a/test/cli.js +++ b/test/cli.js @@ -1,3 +1,6 @@ +import { resolve, normalize, sep } from "node:path"; +import { execArgv } from "node:process"; +import { tmpdir, EOL } from "node:os"; import { deepStrictEqual, ok, strictEqual } from "node:assert"; import { mkdir, @@ -7,11 +10,9 @@ import { writeFile, mkdtemp, } from "node:fs/promises"; + import { fileURLToPath, pathToFileURL } from "url"; -import { exec, jcoPath } from "./helpers.js"; -import { tmpdir } from "node:os"; -import { resolve, normalize, sep } from "node:path"; -import { execArgv } from "node:process"; +import { exec, jcoPath, getTmpDir } from "./helpers.js"; const multiMemory = execArgv.includes("--experimental-wasm-multi-memory") ? ["--multi-memory"] @@ -19,15 +20,6 @@ const multiMemory = execArgv.includes("--experimental-wasm-multi-memory") export async function cliTest(fixtures) { suite("CLI", () => { - /** - * Securely creates a temporary directory and returns its path. - * - * The new directory is created using `fsPromises.mkdtemp()`. - */ - async function getTmpDir() { - return await mkdtemp(normalize(tmpdir() + sep)); - } - var tmpDir; var outDir; var outFile; @@ -195,6 +187,64 @@ export async function cliTest(fixtures) { ok(source.includes("export const test")); }); + test("Type generation (specific features)", async () => { + const { stderr, stdout } = await exec( + jcoPath, + "types", + "test/fixtures/wits/feature-gates-unstable.wit", + "--world-name", + "test:feature-gates-unstable/gated", + "--feature", + "enable-c", + "-o", + outDir + ); + strictEqual(stderr, ""); + const source = await readFile(`${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, "utf8"); + ok(source.includes("export function a(): void;")); + ok(!source.includes("export function b(): void;")); + ok(source.includes("export function c(): void;")); + }); + + test("Type generation (all features)", async () => { + const { stderr, stdout } = await exec( + jcoPath, + "types", + "test/fixtures/wits/feature-gates-unstable.wit", + "--world-name", + "test:feature-gates-unstable/gated", + "--all-features", + "-o", + outDir + ); + strictEqual(stderr, ""); + const source = await readFile(`${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, "utf8"); + ok(source.includes("export function a(): void;")); + ok(source.includes("export function b(): void;")); + ok(source.includes("export function c(): void;")); + }); + + // NOTE: enabling all features and a specific feature means --all-features takes precedence + test("Type generation (all features + feature)", async () => { + const { stderr, stdout } = await exec( + jcoPath, + "types", + "test/fixtures/wits/feature-gates-unstable.wit", + "--world-name", + "test:feature-gates-unstable/gated", + "--all-features", + "--feature", + "enable-c", + "-o", + outDir + ); + strictEqual(stderr, ""); + const source = await readFile(`${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, "utf8"); + ok(source.includes("export function a(): void;")); + ok(source.includes("export function b(): void;")); + ok(source.includes("export function c(): void;")); + }); + test("TypeScript naming checks", async () => { const { stderr } = await exec( jcoPath, diff --git a/test/fixtures/wits/feature-gates-unstable.wit b/test/fixtures/wits/feature-gates-unstable.wit new file mode 100644 index 000000000..6266d9866 --- /dev/null +++ b/test/fixtures/wits/feature-gates-unstable.wit @@ -0,0 +1,16 @@ +package test:feature-gates-unstable@0.1.0; + +interface foo { + @since(version = 0.1.0) + a: func(); + + @unstable(feature = enable-b) + b: func(); + + @unstable(feature = enable-c) + c: func(); +} + +world gated { + export foo; +} diff --git a/test/fixtures/wits/feature-gates.wit b/test/fixtures/wits/feature-gates.wit new file mode 100644 index 000000000..456725c2c --- /dev/null +++ b/test/fixtures/wits/feature-gates.wit @@ -0,0 +1,26 @@ +/// This WIT is used to test/ensure that JCO does not +/// generate exports/imports for interfaces that are invalid +/// on the feature-gating feature of WIT +/// +/// see: https://github.com/WebAssembly/component-model/blob/main/design/mvp/WIT.md#feature-gates + +package test:feature-gates@0.2.1; + +interface foo { + a: func(); + + @since(version = 0.2.1) + b: func(); + + @since(version = 0.2.1, feature = gated) + c: func(); + + @unstable(feature = fancier-foo) + d: func(); +} + +world import-and-export { + import foo; + export foo; +} + diff --git a/test/helpers.js b/test/helpers.js index e5ea767d2..5273db8b4 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -1,5 +1,8 @@ +import { tmpdir } from "node:os"; import { spawn } from "node:child_process"; import { argv, execArgv } from "node:process"; +import { normalize, sep } from "node:path"; +import { mkdtemp } from "node:fs/promises"; export const jcoPath = "src/jco.js"; const multiMemory = @@ -27,3 +30,12 @@ export async function exec(cmd, ...args) { }); return { stdout, stderr }; } + +/** + * Securely creates a temporary directory and returns its path. + * + * The new directory is created using `fsPromises.mkdtemp()`. + */ +export async function getTmpDir() { + return await mkdtemp(normalize(tmpdir() + sep)); +} diff --git a/test/preview2.js b/test/preview2.js index d8034a9c3..7880f9a8d 100644 --- a/test/preview2.js +++ b/test/preview2.js @@ -3,22 +3,15 @@ import { readFile, rm, writeFile, mkdtemp } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { normalize, resolve, sep } from "node:path"; + import { fileURLToPath, pathToFileURL } from "url"; -import { componentNew, preview1AdapterCommandPath } from "../src/api.js"; -import { exec, jcoPath } from "./helpers.js"; import { HTTPServer } from "@bytecodealliance/preview2-shim/http"; +import { componentNew, preview1AdapterCommandPath } from "../src/api.js"; +import { exec, jcoPath, getTmpDir } from "./helpers.js"; + export async function preview2Test() { suite("Preview 2", () => { - /** - * Securely creates a temporary directory and returns its path. - * - * The new directory is created using `fsPromises.mkdtemp()`. - */ - async function getTmpDir() { - return await mkdtemp(normalize(tmpdir() + sep)); - } - var tmpDir; var outFile; suiteSetup(async function () { diff --git a/test/test.js b/test/test.js index a3a65b184..9249ab146 100644 --- a/test/test.js +++ b/test/test.js @@ -25,6 +25,7 @@ import { commandsTest } from './commands.js'; import { apiTest } from './api.js'; import { cliTest } from './cli.js'; import { preview2Test } from './preview2.js'; +import { witTest } from './wit.js'; import { tsTest } from './typescript.js'; await codegenTest(componentFixtures); @@ -34,7 +35,7 @@ await runtimeTest(componentFixtures); await commandsTest(); await apiTest(componentFixtures); await cliTest(componentFixtures); - +await witTest(); if (versions.node.split('.')[0] !== '22') { const { browserTest } = await import('./browser.js'); await browserTest(); diff --git a/test/wit.js b/test/wit.js new file mode 100644 index 000000000..70eaf00c2 --- /dev/null +++ b/test/wit.js @@ -0,0 +1,258 @@ +import { ok, deepStrictEqual } from "node:assert"; +import { readFile, rm, writeFile, mkdtemp } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { normalize, resolve, sep } from "node:path"; + +import { fileURLToPath, pathToFileURL } from "url"; +import { HTTPServer } from "@bytecodealliance/preview2-shim/http"; + +import { componentNew, preview1AdapterCommandPath, componentEmbed, transpile, types } from "../src/api.js"; +import { exec, jcoPath, getTmpDir } from "./helpers.js"; + +export async function witTest() { + suite("WIT", () => { + var tmpDir; + var outFile; + + // Content of test/fixtures/wits/feature-gates.wit + var featureGatesWitContent; + var featureGatesWitPath; + + suiteSetup(async function () { + tmpDir = await getTmpDir(); + outFile = resolve(tmpDir, "out-component-file"); + featureGatesWitPath = resolve("test/fixtures/wits/feature-gates.wit"); + featureGatesWitContent = await readFile( + featureGatesWitPath, + "utf8" + ); + }); + + suiteTeardown(async function () { + try { + await rm(tmpDir, { recursive: true }); + } catch {} + }); + + teardown(async function () { + try { + await rm(outFile); + } catch {} + }); + + // (transpile): features marked @unstable should *not* be present when no features are enabled + // + // NOTE: this works primarily the features are fed through to the `wit_parser::Resolve` that is used, + // not due to active filtering on the jco side. + test("Feature gates (no features)", async () => { + // Build a dummy WIT component + const generatedComponent = await componentEmbed({ + witSource: featureGatesWitContent, + dummy: true, + metadata: [ + ["language", [["javascript", ""]]], + ["processed-by", [["dummy-gen", "test"]]], + ], + }); + const component = await componentNew(generatedComponent); + + // Transpile the component + const { files, imports, exports } = await transpile(component); + deepStrictEqual(imports, [ + "test:feature-gates/foo", + ]); + deepStrictEqual(exports, [ + ["foo", "instance"], + ["test:feature-gates/foo@0.2.1", "instance"], + ]); + ok(files['component.js'], "component js was generated"); + ok(files['component.d.ts'], "component typings were generated"); + ok(files['interfaces/test-feature-gates-foo.d.ts'], "interface typings were generated"); + + // Check the interfaces file for the right exports + const interfaces = Buffer.from(files['interfaces/test-feature-gates-foo.d.ts']).toString('utf8'); + ok(interfaces.includes("export function a(): void;"), "unconstrained export foo/a is present"); + ok(interfaces.includes("export function b(): void;"), "@since(0.2.1) export foo/b is present (version matches)"); + ok(interfaces.includes("export function c(): void;"), "@since(0.2.1) export foo/c is present (feature is ignored)"); + ok(!interfaces.includes("export function d(): void;"), "@unstable(...) export is missing, without the feature specified"); + }); + + // (transpile): features marked @unstable should *not* be present when an unrelated feature is enabled + // + // NOTE: this works primarily the features are fed through to the `wit_parser::Resolve` that is used, + // not due to active filtering on the jco side. + test("Feature gates (unrelated feature)", async () => { + // Build a dummy WIT component + const generatedComponent = await componentEmbed({ + witSource: featureGatesWitContent, + dummy: true, + metadata: [ + ["language", [["javascript", ""]]], + ["processed-by", [["dummy-gen", "test"]]], + ], + features: { + tag: "list", + val: [ + "some-feature", + ], + }, + }); + const component = await componentNew(generatedComponent); + + // Transpile the component + const { files, imports, exports } = await transpile(component); + deepStrictEqual(imports, [ + "test:feature-gates/foo", + ]); + deepStrictEqual(exports, [ + ["foo", "instance"], + ["test:feature-gates/foo@0.2.1", "instance"], + ]); + ok(files['component.js'], "component js was generated"); + ok(files['component.d.ts'], "component typings were generated"); + ok(files['interfaces/test-feature-gates-foo.d.ts'], "interface typings were generated"); + + // Check the interfaces file for the right exports + const interfaces = Buffer.from(files['interfaces/test-feature-gates-foo.d.ts']).toString('utf8'); + ok(interfaces.includes("export function a(): void;"), "unconstrained export foo/a is present"); + ok(interfaces.includes("export function b(): void;"), "@since(0.2.1) export foo/b is present (version matches)"); + ok(interfaces.includes("export function c(): void;"), "@since(0.2.1) export foo/c is present (feature is ignored)"); + ok(!interfaces.includes("export function d(): void;"), "@unstable(...) export is missing, without the feature specified"); + }); + + // (transpile): features marked @unstable shoudl be present in exports when only the specific feature is enabled + // + // NOTE: this works primarily the features are fed through to the `wit_parser::Resolve` that is used, + // not due to active filtering on the jco side. + test("Feature gates (single feature enabled)", async () => { + // Build a dummy WIT component + const generatedComponent = await componentEmbed({ + witSource: featureGatesWitContent, + dummy: true, + metadata: [ + ["language", [["javascript", ""]]], + ["processed-by", [["dummy-gen", "test"]]], + ], + features: { + tag: "list", + val: [ + "fancier-foo", + ], + }, + }); + const component = await componentNew(generatedComponent); + + // Transpile the component + const { files, imports, exports } = await transpile(component); + deepStrictEqual(imports, [ + "test:feature-gates/foo", + ]); + deepStrictEqual(exports, [ + ["foo", "instance"], + ["test:feature-gates/foo@0.2.1", "instance"], + ]); + ok(files['component.js'], "component js was generated"); + ok(files['component.d.ts'], "component typings were generated"); + ok(files['interfaces/test-feature-gates-foo.d.ts'], "interface typings were generated"); + + // Check the interfaces file for the right exports + const interfaces = Buffer.from(files['interfaces/test-feature-gates-foo.d.ts']).toString('utf8'); + ok(interfaces.includes("export function a(): void;"), "unconstrained export foo/a is present"); + ok(interfaces.includes("export function b(): void;"), "@since(0.2.1) export foo/b is present (version matches)"); + ok(interfaces.includes("export function c(): void;"), "@since(0.2.1) export foo/c is present (feature is ignored)"); + ok(interfaces.includes("export function d(): void;"), "@unstable(...) export is present, with all features enabled"); + }); + + // (transpile): features marked @unstable shoudl be present in exports when all features are enabled + // + // NOTE: this works primarily the features are fed through to the `wit_parser::Resolve` that is used, + // not due to active filtering on the jco side. + test("Feature gates (all features enabled)", async () => { + // Build a dummy WIT component + const generatedComponent = await componentEmbed({ + witSource: featureGatesWitContent, + dummy: true, + metadata: [ + ["language", [["javascript", ""]]], + ["processed-by", [["dummy-gen", "test"]]], + ], + features: { tag: "all" }, + }); + const component = await componentNew(generatedComponent); + + // Transpile the component + const { files, imports, exports } = await transpile(component); + deepStrictEqual(imports, [ + "test:feature-gates/foo", + ]); + deepStrictEqual(exports, [ + ["foo", "instance"], + ["test:feature-gates/foo@0.2.1", "instance"], + ]); + ok(files['component.js'], "component js was generated"); + ok(files['component.d.ts'], "component typings were generated"); + ok(files['interfaces/test-feature-gates-foo.d.ts'], "interface typings were generated"); + + // Check the interfaces file for the right exports + const interfaces = Buffer.from(files['interfaces/test-feature-gates-foo.d.ts']).toString('utf8'); + ok(interfaces.includes("export function a(): void;"), "unconstrained export foo/a is present"); + ok(interfaces.includes("export function b(): void;"), "@since(0.2.1) export foo/b is present (version matches)"); + ok(interfaces.includes("export function c(): void;"), "@since(0.2.1) export foo/c is present (feature is ignored)"); + ok(interfaces.includes("export function d(): void;"), "@unstable(...) export is present, with all features enabled"); + }); + + // (`jco types`) features marked @unstable() are missing as imports *and* exports + test("Feature gates - (types, no features enabled)", async () => { + const files = await types(featureGatesWitPath, { + worldName: 'import-and-export', + }); + ok(files['import-and-export.d.ts'], "component js was generated"); + ok(files['interfaces/test-feature-gates-foo.d.ts'], "interface typings were generated"); + + const imports = Buffer.from(files['import-and-export.d.ts']).toString('utf8'); + + // Check the interfaces file for the right exports + const interfaces = Buffer.from(files['interfaces/test-feature-gates-foo.d.ts']).toString('utf8'); + ok(interfaces.includes("export function a(): void;"), "unconstrained export foo/a is present"); + ok(interfaces.includes("export function b(): void;"), "@since(0.2.1) export foo/b is present (version matches)"); + ok(interfaces.includes("export function c(): void;"), "@since(0.2.1) export foo/c is present (feature is ignored)"); + ok(!interfaces.includes("export function d(): void;"), "@unstable(...) export is missing (no features enabled)"); + }); + + // (`jco types`) features marked @unstable(feature = f) should be present when the specific feature is enabled + test("Feature gates (types, single feature enabled)", async () => { + const files = await types(featureGatesWitPath, { + worldName: 'import-and-export', + feature: ['fancier-foo'], + }); + ok(files['import-and-export.d.ts'], "component js was generated"); + ok(files['interfaces/test-feature-gates-foo.d.ts'], "interface typings were generated"); + + // Check the interfaces file for the right exports + const interfaces = Buffer.from(files['interfaces/test-feature-gates-foo.d.ts']).toString('utf8'); + ok(interfaces.includes("export function a(): void;"), "unconstrained export foo/a is present"); + ok(interfaces.includes("export function b(): void;"), "@since(0.2.1) export foo/b is present (version matches)"); + ok(interfaces.includes("export function c(): void;"), "@since(0.2.1) export foo/c is present (feature is ignored)"); + ok(interfaces.includes("export function d(): void;"), "@unstable(...) export is present (single feature enabled)"); + }); + + // (`jco types`) features marked @unstable() should be present with all features enabled + test("Feature gates (types, all features enabled)", async () => { + const files = await types(featureGatesWitPath, { + worldName: 'import-and-export', + allFeatures: true, + }); + ok(files['import-and-export.d.ts'], "component js was generated"); + ok(files['interfaces/test-feature-gates-foo.d.ts'], "interface typings were generated"); + + // Check the interfaces file for the right exports + const interfaces = Buffer.from(files['interfaces/test-feature-gates-foo.d.ts']).toString('utf8'); + ok(interfaces.includes("export function a(): void;"), "unconstrained export foo/a is present"); + ok(interfaces.includes("export function b(): void;"), "@since(0.2.1) export foo/b is present (version matches)"); + ok(interfaces.includes("export function c(): void;"), "@since(0.2.1) export foo/c is present (feature is ignored)"); + ok(interfaces.includes("export function d(): void;"), "@unstable(...) export is present (all features enabled)"); + }); + + }); +}