diff --git a/.gitignore b/.gitignore index 84d33ed..c88d4d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules /lib /examples/build +/examples/package-lock.json diff --git a/.gitmodules b/.gitmodules index e7db5ec..b9e3ba9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "third_party/wasi-test-suite"] - path = third_party/wasi-test-suite - url = https://github.com/caspervonb/wasi-test-suite +[submodule "third_party/wasi-testsuite"] + path = third_party/wasi-testsuite + url = https://github.com/WebAssembly/wasi-testsuite diff --git a/examples/package-lock.json b/examples/package-lock.json deleted file mode 100644 index 7ae5ca6..0000000 --- a/examples/package-lock.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "examples", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "dependencies": { - "uwasi": "../" - } - }, - "..": { - "version": "1.1.0", - "license": "MIT", - "devDependencies": { - "@types/jest": "^28.1.4", - "@types/node": "^17.0.31", - "jest": "^28.1.2", - "ts-jest": "^28.0.5", - "typescript": "^4.6.4" - } - }, - "node_modules/uwasi": { - "resolved": "..", - "link": true - } - }, - "dependencies": { - "uwasi": { - "version": "file:..", - "requires": { - "@types/jest": "^28.1.4", - "@types/node": "^17.0.31", - "jest": "^28.1.2", - "ts-jest": "^28.0.5", - "typescript": "^4.6.4" - } - } - } -} diff --git a/package.json b/package.json index 53b60e5..1378304 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "./lib/esm/platforms/crypto.js": "./lib/esm/platforms/crypto.browser.js" }, "scripts": { - "build": "tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json", - "test": "jest", + "build": "tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && echo '{ \"type\": \"module\" }' > lib/esm/package.json", + "test": "node --test test/*.test.mjs", "format": "prettier --write ./src ./test", "prepare": "npm run build" }, @@ -32,11 +32,8 @@ "author": "SwiftWasm Team", "license": "MIT", "devDependencies": { - "@types/jest": "^28.1.4", "@types/node": "^17.0.31", - "jest": "^28.1.2", "prettier": "^3.5.2", - "ts-jest": "^28.0.5", "typescript": "^4.6.4" } } diff --git a/src/abi.ts b/src/abi.ts index 0552232..f4d1e5a 100644 --- a/src/abi.ts +++ b/src/abi.ts @@ -62,6 +62,22 @@ export class WASIAbi { * The file descriptor or file refers to a regular file inode. */ static readonly WASI_FILETYPE_REGULAR_FILE = 4; + /** + * Create file if it does not exist. + */ + static readonly WASI_OFLAGS_CREAT = 1 << 0; + /** + * Open directory. + */ + static readonly WASI_OFLAGS_DIRECTORY = 1 << 1; + /** + * Fail if not a directory. + */ + static readonly WASI_OFLAGS_EXCL = 1 << 2; + /** + * Truncate to zero length. + */ + static readonly WASI_OFLAGS_TRUNC = 1 << 3; static readonly IMPORT_FUNCTIONS = [ "args_get", diff --git a/src/features/all.ts b/src/features/all.ts index 2b4b6a4..8b95d29 100644 --- a/src/features/all.ts +++ b/src/features/all.ts @@ -1,29 +1,24 @@ -import { WASIAbi } from "../abi"; -import { WASIFeatureProvider, WASIOptions } from "../options"; -import { useArgs } from "./args"; -import { useClock } from "./clock"; -import { useEnviron } from "./environ"; -import { useFS, useStdio } from "./fd"; -import { useProc } from "./proc"; -import { useRandom } from "./random"; +import { WASIAbi } from "../abi.js"; +import { WASIFeatureProvider, WASIOptions } from "../options.js"; +import { useArgs } from "./args.js"; +import { useClock } from "./clock.js"; +import { useEnviron } from "./environ.js"; +import { useMemoryFS } from "./fd.js"; +import { useProc } from "./proc.js"; +import { useRandom } from "./random.js"; -type Options = (Parameters<typeof useFS>[0] | Parameters<typeof useStdio>[0]) & - Parameters<typeof useRandom>[0]; +type Options = Parameters<typeof useMemoryFS>[0] & Parameters<typeof useRandom>[0]; export function useAll(useOptions: Options = {}): WASIFeatureProvider { return (options: WASIOptions, abi: WASIAbi, memoryView: () => DataView) => { const features = [ + useMemoryFS(useOptions), useEnviron, useArgs, useClock, useProc, useRandom(useOptions), ]; - if ("fs" in useOptions) { - features.push(useFS({ fs: useOptions.fs })); - } else { - features.push(useStdio(useOptions)); - } return features.reduce((acc, fn) => { return { ...acc, ...fn(options, abi, memoryView) }; }, {}); diff --git a/src/features/args.ts b/src/features/args.ts index 25acc6e..d947db8 100644 --- a/src/features/args.ts +++ b/src/features/args.ts @@ -1,5 +1,5 @@ -import { WASIAbi } from "../abi"; -import { WASIOptions } from "../options"; +import { WASIAbi } from "../abi.js"; +import { WASIOptions } from "../options.js"; /** * A feature provider that provides `args_get` and `args_sizes_get` diff --git a/src/features/clock.ts b/src/features/clock.ts index cf2fe97..28f5074 100644 --- a/src/features/clock.ts +++ b/src/features/clock.ts @@ -1,5 +1,5 @@ -import { WASIAbi } from "../abi"; -import { WASIOptions } from "../options"; +import { WASIAbi } from "../abi.js"; +import { WASIOptions } from "../options.js"; /** * A feature provider that provides `clock_res_get` and `clock_time_get` by JavaScript's Date. diff --git a/src/features/environ.ts b/src/features/environ.ts index 80e60c0..d6c517d 100644 --- a/src/features/environ.ts +++ b/src/features/environ.ts @@ -1,5 +1,5 @@ -import { WASIAbi } from "../abi"; -import { WASIOptions } from "../options"; +import { WASIAbi } from "../abi.js"; +import { WASIOptions } from "../options.js"; /** * A feature provider that provides `environ_get` and `environ_sizes_get` diff --git a/src/features/fd.ts b/src/features/fd.ts index fa0d34b..acfafed 100644 --- a/src/features/fd.ts +++ b/src/features/fd.ts @@ -1,5 +1,5 @@ -import { WASIAbi } from "../abi"; -import { WASIFeatureProvider, WASIOptions } from "../options"; +import { WASIAbi } from "../abi.js"; +import { WASIFeatureProvider, WASIOptions } from "../options.js"; interface FdEntry { writev(iovs: Uint8Array[]): number; @@ -96,7 +96,7 @@ export class ReadableTextProxy implements FdEntry { close(): void {} } -export type StdIoOptions = { +export type StdioOptions = { stdin?: () => string | Uint8Array; stdout?: (lines: string | Uint8Array) => void; stderr?: (lines: string | Uint8Array) => void; @@ -104,7 +104,7 @@ export type StdIoOptions = { }; function bindStdio( - useOptions: StdIoOptions = {}, + useOptions: StdioOptions = {}, ): (ReadableTextProxy | WritableTextProxy)[] { const outputBuffers = useOptions.outputBuffers || false; return [ @@ -144,7 +144,7 @@ function bindStdio( * * This provides `fd_write`, `fd_prestat_get` and `fd_prestat_dir_name` implementations to make libc work with minimal effort. */ -export function useStdio(useOptions: StdIoOptions = {}): WASIFeatureProvider { +export function useStdio(useOptions: StdioOptions = {}): WASIFeatureProvider { return (options, abi, memoryView) => { const fdTable = bindStdio(useOptions); return { @@ -275,13 +275,13 @@ export class MemoryFileSystem { const data = new TextEncoder().encode(content); this.createFile(path, data); return; - } else if (content instanceof Blob) { + } else if (globalThis.Blob && content instanceof Blob) { return content.arrayBuffer().then((buffer) => { const data = new Uint8Array(buffer); this.createFile(path, data); }); } else { - this.createFile(path, content); + this.createFile(path, content as Uint8Array); return; } } @@ -304,7 +304,7 @@ export class MemoryFileSystem { * @param node The node to set */ setNode(path: string, node: FSNode): void { - const normalizedPath = this.normalizePath(path); + const normalizedPath = normalizePath(path); const parts = normalizedPath.split("/").filter((p) => p.length > 0); if (parts.length === 0) { @@ -345,7 +345,7 @@ export class MemoryFileSystem { * @returns The node at the path, or null if not found */ lookup(path: string): FSNode | null { - const normalizedPath = this.normalizePath(path); + const normalizedPath = normalizePath(path); if (normalizedPath === "/") return this.root; const parts = normalizedPath.split("/").filter((p) => p.length > 0); @@ -367,7 +367,7 @@ export class MemoryFileSystem { * @returns The resolved node, or null if not found */ resolve(dir: DirectoryNode, relativePath: string): FSNode | null { - const normalizedPath = this.normalizePath(relativePath); + const normalizedPath = normalizePath(relativePath); const parts = normalizedPath.split("/").filter((p) => p.length > 0); let current: FSNode = dir; @@ -391,7 +391,7 @@ export class MemoryFileSystem { * @returns The directory node */ ensureDir(path: string): DirectoryNode { - const normalizedPath = this.normalizePath(path); + const normalizedPath = normalizePath(path); const parts = normalizedPath.split("/").filter((p) => p.length > 0); let current: DirectoryNode = this.root; @@ -418,7 +418,7 @@ export class MemoryFileSystem { * @returns The created file node */ createFileIn(dir: DirectoryNode, relativePath: string): FileNode { - const normalizedPath = this.normalizePath(relativePath); + const normalizedPath = normalizePath(relativePath); const parts = normalizedPath.split("/").filter((p) => p.length > 0); if (parts.length === 0) { @@ -446,24 +446,45 @@ export class MemoryFileSystem { return fileNode; } - /** - * Normalizes a path by removing duplicate slashes and trailing slashes. - * @param path Path to normalize - * @returns Normalized path - */ - private normalizePath(path: string): string { - // Handle empty path - if (!path) return "/"; - - // Ensure path starts with a slash - const withLeadingSlash = path.startsWith("/") ? path : `/${path}`; + removeEntry(path: string): void { + const normalizedPath = normalizePath(path); + const parts = normalizedPath.split("/").filter((p) => p.length > 0); + let parentDir = this.root; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (parentDir.type !== "dir") return; + parentDir = parentDir.entries[part] as DirectoryNode; + } - // Remove duplicate slashes and normalize - const normalized = withLeadingSlash.replace(/\/+/g, "/"); + const fileName = parts[parts.length - 1]; + delete parentDir.entries[fileName]; + } +} - // Remove trailing slash unless it's the root path - return normalized === "/" ? normalized : normalized.replace(/\/+$/, ""); +/** + * Normalizes a path by removing duplicate slashes and trailing slashes. + * @param path Path to normalize + * @returns Normalized path + */ +function normalizePath(path: string): string { + // Handle empty path + if (!path) return "/"; + + const parts = path.split("/").filter((p) => p.length > 0); + const normalizedParts: string[] = []; + + for (const part of parts) { + if (part === ".") continue; + if (part === "..") { + normalizedParts.pop(); + continue; + } + normalizedParts.push(part); } + if (normalizedParts.length === 0) return "/"; + + const normalized = "/" + normalizedParts.join("/"); + return normalized; } /** @@ -496,7 +517,7 @@ export class MemoryFileSystem { * const wasi = new WASI({ * features: [ * useMemoryFS({ - * withStdIo: { + * withStdio: { * stdout: (lines) => document.write(lines), * stderr: (lines) => document.write(lines), * } @@ -507,13 +528,13 @@ export class MemoryFileSystem { * * @param useOptions - Configuration options for the memory file system * @param useOptions.withFileSystem - Optional pre-configured file system instance - * @param useOptions.withStdIo - Optional standard I/O configuration + * @param useOptions.withStdio - Optional standard I/O configuration * @returns A WASI feature provider implementing file system functionality */ export function useMemoryFS( useOptions: { withFileSystem?: MemoryFileSystem; - withStdIo?: StdIoOptions; + withStdio?: StdioOptions; } = {}, ): WASIFeatureProvider { return ( @@ -525,7 +546,7 @@ export function useMemoryFS( useOptions.withFileSystem || new MemoryFileSystem(wasiOptions.preopens); const files: { [fd: FileDescriptor]: OpenFile } = {}; - bindStdio(useOptions.withStdIo || {}).forEach((entry, fd) => { + bindStdio(useOptions.withStdio || {}).forEach((entry, fd) => { files[fd] = { node: { type: "character", kind: "stdio", entry }, position: 0, @@ -803,8 +824,9 @@ export function useMemoryFS( return WASIAbi.WASI_ESUCCESS; }, - fd_open: ( + path_open: ( dirfd: number, + _dirflags: number, pathPtr: number, pathLen: number, oflags: number, @@ -823,9 +845,9 @@ export function useMemoryFS( const path = abi.readString(view, pathPtr, pathLen); - const guestPath = - (dirEntry.path.endsWith("/") ? dirEntry.path : dirEntry.path + "/") + - path; + const guestPath = normalizePath( + (dirEntry.path.endsWith("/") ? dirEntry.path : dirEntry.path + "/") + path, + ); const existing = getFileFromPath(guestPath); if (existing) { @@ -833,20 +855,20 @@ export function useMemoryFS( return WASIAbi.WASI_ESUCCESS; } - let target = fileSystem.resolve(dirEntry.node, path); - const O_CREAT = 1 << 0, - O_EXCL = 1 << 1, - O_TRUNC = 1 << 2; + let target = fileSystem.resolve(dirEntry.node as DirectoryNode, path); if (target) { - if (oflags & O_EXCL) return WASIAbi.WASI_ERRNO_EXIST; - if (oflags & O_TRUNC) { + if (oflags & WASIAbi.WASI_OFLAGS_EXCL) return WASIAbi.WASI_ERRNO_EXIST; + if (oflags & WASIAbi.WASI_OFLAGS_TRUNC) { if (target.type !== "file") return WASIAbi.WASI_ERRNO_INVAL; - target.content = new Uint8Array(0); + (target as FileNode).content = new Uint8Array(0); } } else { - if (!(oflags & O_CREAT)) return WASIAbi.WASI_ERRNO_NOENT; - target = fileSystem.createFileIn(dirEntry.node, path); + if (!(oflags & WASIAbi.WASI_OFLAGS_CREAT)) return WASIAbi.WASI_ERRNO_NOENT; + target = fileSystem.createFileIn( + dirEntry.node as DirectoryNode, + path, + ); } files[nextFd] = { @@ -862,66 +884,48 @@ export function useMemoryFS( return WASIAbi.WASI_ESUCCESS; }, - path_open: ( - dirfd: number, - _dirflags: number, - pathPtr: number, - pathLen: number, - oflags: number, - _fs_rights_base: bigint, - _fs_rights_inheriting: bigint, - _fdflags: number, - opened_fd: number, - ) => { + path_create_directory: (fd: number, pathPtr: number, pathLen: number) => { const view = memoryView(); + const guestRelPath = abi.readString(view, pathPtr, pathLen); + const dirEntry = getFileFromFD(fd); + if (!dirEntry || dirEntry.node.type !== "dir") + return WASIAbi.WASI_ERRNO_NOTDIR; - if (dirfd < 3) return WASIAbi.WASI_ERRNO_NOTDIR; + const fullGuestPath = + (dirEntry.path.endsWith("/") ? dirEntry.path : dirEntry.path + "/") + + guestRelPath; - const dirEntry = getFileFromFD(dirfd); + fileSystem.ensureDir(fullGuestPath); + return WASIAbi.WASI_ESUCCESS; + }, + + path_unlink_file: (fd: number, pathPtr: number, pathLen: number) => { + const view = memoryView(); + const guestRelPath = abi.readString(view, pathPtr, pathLen); + const dirEntry = getFileFromFD(fd); if (!dirEntry || dirEntry.node.type !== "dir") return WASIAbi.WASI_ERRNO_NOTDIR; - const path = abi.readString(view, pathPtr, pathLen); - - const guestPath = + const fullGuestPath = (dirEntry.path.endsWith("/") ? dirEntry.path : dirEntry.path + "/") + - path; - - const existing = getFileFromPath(guestPath); - if (existing) { - view.setUint32(opened_fd, existing.fd, true); - return WASIAbi.WASI_ESUCCESS; - } + guestRelPath; - let target = fileSystem.resolve(dirEntry.node as DirectoryNode, path); - const O_CREAT = 1 << 0, - O_EXCL = 1 << 1, - O_TRUNC = 1 << 2; + fileSystem.removeEntry(fullGuestPath); + return WASIAbi.WASI_ESUCCESS; + }, - if (target) { - if (oflags & O_EXCL) return WASIAbi.WASI_ERRNO_EXIST; - if (oflags & O_TRUNC) { - if (target.type !== "file") return WASIAbi.WASI_ERRNO_INVAL; - (target as FileNode).content = new Uint8Array(0); - } - } else { - if (!(oflags & O_CREAT)) return WASIAbi.WASI_ERRNO_NOENT; - target = fileSystem.createFileIn( - dirEntry.node as DirectoryNode, - path, - ); - } + path_remove_directory: (fd: number, pathPtr: number, pathLen: number) => { + const view = memoryView(); + const guestRelPath = abi.readString(view, pathPtr, pathLen); + const dirEntry = getFileFromFD(fd); + if (!dirEntry || dirEntry.node.type !== "dir") + return WASIAbi.WASI_ERRNO_NOTDIR; - files[nextFd] = { - node: target, - position: 0, - isPreopen: false, - path: guestPath, - fd: nextFd, - }; + const fullGuestPath = + (dirEntry.path.endsWith("/") ? dirEntry.path : dirEntry.path + "/") + + guestRelPath; - view.setUint32(opened_fd, nextFd, true); - nextFd++; + fileSystem.removeEntry(fullGuestPath); return WASIAbi.WASI_ESUCCESS; }, diff --git a/src/features/proc.ts b/src/features/proc.ts index 6bb3476..94f0c55 100644 --- a/src/features/proc.ts +++ b/src/features/proc.ts @@ -1,5 +1,5 @@ -import { WASIAbi, WASIProcExit } from "../abi"; -import { WASIOptions } from "../options"; +import { WASIAbi, WASIProcExit } from "../abi.js"; +import { WASIOptions } from "../options.js"; /** * A feature provider that provides `proc_exit` and `proc_raise` by JavaScript's exception. diff --git a/src/features/random.ts b/src/features/random.ts index 420118e..05208ac 100644 --- a/src/features/random.ts +++ b/src/features/random.ts @@ -1,6 +1,6 @@ -import { WASIAbi } from "../abi"; -import { WASIFeatureProvider } from "../options"; -import { defaultRandomFillSync } from "../platforms/crypto"; +import { WASIAbi } from "../abi.js"; +import { WASIFeatureProvider } from "../options.js"; +import { defaultRandomFillSync } from "../platforms/crypto.js"; /** * Create a feature provider that provides `random_get` with `crypto` APIs as backend by default. diff --git a/src/features/tracing.ts b/src/features/tracing.ts index 0ff9cc2..dbf8bf2 100644 --- a/src/features/tracing.ts +++ b/src/features/tracing.ts @@ -1,4 +1,4 @@ -import { WASIFeatureProvider } from "../options"; +import { WASIFeatureProvider } from "../options.js"; export function useTrace(features: WASIFeatureProvider[]): WASIFeatureProvider { return (options, abi, memoryView) => { diff --git a/src/index.ts b/src/index.ts index d69248f..24164d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,15 @@ -import { WASIAbi, WASIProcExit } from "./abi"; -export { WASIProcExit } from "./abi"; -import { WASIOptions } from "./options"; +import { WASIAbi, WASIProcExit } from "./abi.js"; +export { WASIProcExit } from "./abi.js"; +import { WASIOptions } from "./options.js"; -export * from "./features/all"; -export * from "./features/args"; -export * from "./features/clock"; -export * from "./features/environ"; -export { useFS, useStdio, useMemoryFS, MemoryFileSystem } from "./features/fd"; -export * from "./features/proc"; -export * from "./features/random"; -export * from "./features/tracing"; +export * from "./features/all.js"; +export * from "./features/args.js"; +export * from "./features/clock.js"; +export * from "./features/environ.js"; +export { useFS, useStdio, useMemoryFS, MemoryFileSystem } from "./features/fd.js"; +export * from "./features/proc.js"; +export * from "./features/random.js"; +export * from "./features/tracing.js"; export class WASI { /** diff --git a/src/options.ts b/src/options.ts index 76d7f6f..ba9b177 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,4 +1,4 @@ -import { WASIAbi } from "./abi"; +import { WASIAbi } from "./abi.js"; export type WASIFeatureProvider = ( options: WASIOptions, diff --git a/test/fd.test.ts b/test/fd.test.mjs similarity index 64% rename from test/fd.test.ts rename to test/fd.test.mjs index fa4aeaa..7bb6e0f 100644 --- a/test/fd.test.ts +++ b/test/fd.test.mjs @@ -1,4 +1,6 @@ -import { ReadableTextProxy } from "../src/features/fd"; +import { ReadableTextProxy } from "../lib/esm/features/fd.js"; +import { describe, it } from 'node:test'; +import assert from 'node:assert'; describe("fd.ReadableTextProxy", () => { it("readv single buffer", () => { @@ -7,9 +9,9 @@ describe("fd.ReadableTextProxy", () => { const proxy = new ReadableTextProxy(() => inputs.shift() || ""); const buffer = new Uint8Array(10); const read = proxy.readv([buffer]); - expect(read).toBe(5); + assert.strictEqual(read, 5); const expected = new TextEncoder().encode(input); - expect(buffer.slice(0, 5)).toEqual(expected); + assert.deepStrictEqual(buffer.slice(0, 5), expected); }); it("readv 2 buffer", () => { @@ -19,9 +21,9 @@ describe("fd.ReadableTextProxy", () => { const buf0 = new Uint8Array(2); const buf1 = new Uint8Array(2); const read = proxy.readv([buf0, buf1]); - expect(read).toBe(4); + assert.strictEqual(read, 4); const expected = new TextEncoder().encode(input); - expect(buf0).toEqual(expected.slice(0, 2)); - expect(buf1).toEqual(expected.slice(2, 4)); + assert.deepStrictEqual(buf0, expected.slice(0, 2)); + assert.deepStrictEqual(buf1, expected.slice(2, 4)); }); }); diff --git a/test/wasi-test-suite/core.test.ts b/test/wasi-test-suite/core.test.ts deleted file mode 100644 index 7c75baa..0000000 --- a/test/wasi-test-suite/core.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { readdirSync, statSync } from "fs"; -import { join as pathJoin } from "path"; -import { runTest } from "./harness"; - -describe("wasi-test-suite-core", () => { - const suiteDir = pathJoin( - __dirname, - "../../third_party/wasi-test-suite/core", - ); - const entries = readdirSync(suiteDir); - const UNSUPPORTED = [ - "fd_stat_get-stderr.wasm", - "fd_stat_get-stdin.wasm", - "fd_stat_get-stdout.wasm", - "sched_yield.wasm", - ]; - - for (const entry of entries) { - const filePath = pathJoin(suiteDir, entry); - const stat = statSync(filePath); - if (!entry.endsWith(".wasm") || !stat.isFile()) { - continue; - } - const defineCase = UNSUPPORTED.includes(entry) ? it.skip : it; - defineCase(entry, async () => { - await runTest(filePath); - }); - } -}); diff --git a/test/wasi-test-suite/harness.ts b/test/wasi-test-suite/harness.ts deleted file mode 100644 index c4c52cb..0000000 --- a/test/wasi-test-suite/harness.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { WASI, useAll } from "../../src/index"; -import { WASIAbi } from "../../src/abi"; -import { existsSync } from "fs"; -import { readFile } from "fs/promises"; -import { basename } from "path"; - -export async function runTest(filePath: string) { - let stdout = ""; - let stderr = ""; - let stdin = await (async () => { - const path = filePath.replace(/\.wasm$/, ".stdin"); - if (!existsSync(path)) { - return ""; - } - return await readFile(path, "utf8"); - })(); - const features = [ - useAll({ - stdin: () => { - const result = stdin; - stdin = ""; - return result; - }, - stdout: (lines) => { - stdout += lines; - }, - stderr: (lines) => { - stderr += lines; - }, - }), - ]; - const env = await (async () => { - const path = filePath.replace(/\.wasm$/, ".env"); - if (!existsSync(path)) { - return {}; - } - const data = await readFile(path, "utf8"); - return data.split("\n").reduce((acc, line) => { - const components = line.trim().split("="); - if (components.length < 2) { - return acc; - } - return { ...acc, [components[0]]: components.slice(1).join("=") }; - }, {}); - })(); - const args = await (async () => { - const path = filePath.replace(/\.wasm$/, ".arg"); - if (!existsSync(path)) { - return []; - } - const data = await readFile(path, "utf8"); - return data - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - })(); - const wasi = new WASI({ - args: [basename(filePath)].concat(args), - env, - features: features, - }); - const { instance } = await WebAssembly.instantiate(await readFile(filePath), { - wasi_snapshot_preview1: wasi.wasiImport, - }); - let exitCode: number; - try { - exitCode = wasi.start(instance); - } catch (e) { - if (e instanceof WebAssembly.RuntimeError && e.message == "unreachable") { - // When unreachable code is executed, many WebAssembly runtimes raise - // SIGABRT (=0x6) signal. It results in exit code 0x80 + signal number in shell. - // Reference: https://tldp.org/LDP/abs/html/exitcodes.html#EXITCODESREF - exitCode = 0x86; - } else { - throw e; - } - } - const expectedExitCode = await (async () => { - const path = filePath.replace(/\.wasm$/, ".status"); - if (!existsSync(path)) { - return WASIAbi.WASI_ESUCCESS; - } - return parseInt(await readFile(path, { encoding: "utf-8" }), 10); - })(); - const expectedStdout = await (async () => { - const path = filePath.replace(/\.wasm$/, ".stdout"); - if (!existsSync(path)) { - return null; - } - return await readFile(path, { encoding: "utf-8" }); - })(); - const expectedStderr = await (async () => { - const path = filePath.replace(/\.wasm$/, ".stderr"); - if (!existsSync(path)) { - return null; - } - return await readFile(path, { encoding: "utf-8" }); - })(); - expect(exitCode).toBe(expectedExitCode); - if (expectedStdout) { - expect(stdout).toBe(expectedStdout); - } - if (expectedStderr) { - expect(stderr).toBe(expectedStderr); - } -} diff --git a/test/wasi-test-suite/libc.test.ts b/test/wasi-test-suite/libc.test.ts deleted file mode 100644 index 2115c17..0000000 --- a/test/wasi-test-suite/libc.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { readdirSync, statSync } from "fs"; -import { join as pathJoin } from "path"; -import { runTest } from "./harness"; - -describe("wasi-test-suite-libc", () => { - const suiteDir = pathJoin( - __dirname, - "../../third_party/wasi-test-suite/libc", - ); - const entries = readdirSync(suiteDir); - const UNSUPPORTED = ["clock_gettime-monotonic.wasm", "ftruncate.wasm"]; - - for (const entry of entries) { - const filePath = pathJoin(suiteDir, entry); - const stat = statSync(filePath); - if (!entry.endsWith(".wasm") || !stat.isFile()) { - continue; - } - const defineCase = UNSUPPORTED.includes(entry) ? it.skip : it; - defineCase(entry, async () => { - await runTest(filePath); - }); - } -}); diff --git a/test/wasi-test-suite/libstd.test.ts b/test/wasi-test-suite/libstd.test.ts deleted file mode 100644 index 34b4e55..0000000 --- a/test/wasi-test-suite/libstd.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { readdirSync, statSync } from "fs"; -import { join as pathJoin } from "path"; -import { runTest } from "./harness"; - -describe("wasi-test-suite-libstd", () => { - const suiteDir = pathJoin( - __dirname, - "../../third_party/wasi-test-suite/libstd", - ); - const entries = readdirSync(suiteDir); - const UNSUPPORTED = [ - "fs_create_dir-new-directory.wasm", - "fs_file_create.wasm", - "fs_metadata-directory.wasm", - "fs_metadata-file.wasm", - "fs_rename-directory.wasm", - "fs_rename-file.wasm", - ]; - - for (const entry of entries) { - const filePath = pathJoin(suiteDir, entry); - const stat = statSync(filePath); - if (!entry.endsWith(".wasm") || !stat.isFile()) { - continue; - } - const defineCase = UNSUPPORTED.includes(entry) ? it.skip : it; - defineCase(entry, async () => { - await runTest(filePath); - }); - } -}); diff --git a/test/wasi.skip.json b/test/wasi.skip.json new file mode 100644 index 0000000..b2f476d --- /dev/null +++ b/test/wasi.skip.json @@ -0,0 +1,54 @@ +{ + "WASI Rust tests": { + "sched_yield": "not implemented yet", + "poll_oneoff_stdio": "not implemented yet", + "path_rename": "not implemented yet", + "fd_advise": "not implemented yet", + "path_exists": "not implemented yet", + "path_open_dirfd_not_dir": "not implemented yet", + "fd_filestat_set": "not implemented yet", + "symlink_create": "not implemented yet", + "overwrite_preopen": "not implemented yet", + "path_open_read_write": "not implemented yet", + "path_rename_dir_trailing_slashes": "not implemented yet", + "fd_flags_set": "not implemented yet", + "path_filestat": "not implemented yet", + "path_link": "not implemented yet", + "fd_fdstat_set_rights": "not implemented yet", + "readlink": "not implemented yet", + "unlink_file_trailing_slashes": "not implemented yet", + "path_symlink_trailing_slashes": "not implemented yet", + "dangling_symlink": "not implemented yet", + "dir_fd_op_failures": "not implemented yet", + "file_allocate": "not implemented yet", + "nofollow_errors": "not implemented yet", + "path_open_preopen": "not implemented yet", + "fd_readdir": "not implemented yet", + "directory_seek": "not implemented yet", + "symlink_filestat": "not implemented yet", + "symlink_loop": "not implemented yet", + "interesting_paths": "not implemented yet", + + "stdio": "fail", + "renumber": "fail", + "remove_nonempty_directory": "fail", + "remove_directory_trailing_slashes": "fail", + "fstflags_validate": "fail", + "file_unbuffered_write": "fail", + "file_seek_tell": "fail", + "file_pread_pwrite": "fail", + "close_preopen": "fail" + }, + "WASI C tests": { + "sock_shutdown-invalid_fd": "not implemented yet", + "stat-dev-ino": "not implemented yet", + "sock_shutdown-not_sock": "not implemented yet", + "fdopendir-with-access": "not implemented yet", + + "pwrite-with-append": "fail", + "pwrite-with-access": "fail", + "pread-with-access": "fail" + }, + "WASI AssemblyScript tests": { + } +} diff --git a/test/wasi.test.mjs b/test/wasi.test.mjs new file mode 100644 index 0000000..33fc4a2 --- /dev/null +++ b/test/wasi.test.mjs @@ -0,0 +1,213 @@ +// @ts-check +import fs from 'fs/promises'; +import fsSync from 'fs'; +import path from 'path'; +import { useAll, WASI, MemoryFileSystem } from '../lib/esm/index.js'; +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +/** + * @typedef {{ exit_code?: number, args?: string[], env?: Record<string, string>, dirs?: string[] }} TestCaseConfig + * @typedef {{ suite: string, wasmFile: string, testName: string, config: TestCaseConfig }} TestCase + */ + +/** + * Helper function to find test cases directory and files + * + * @param {string} testDir - The directory to search for test cases + * @returns {Array<TestCase>} An array of test cases + */ +function findTestCases(testDir) { + const testSuites = [ + { path: 'rust/testsuite', name: 'WASI Rust tests' }, + { path: 'c/testsuite', name: 'WASI C tests' }, + { path: 'assemblyscript/testsuite', name: 'WASI AssemblyScript tests' }, + ]; + + /** @type {Array<TestCase>} */ + const allTests = []; + + for (const suite of testSuites) { + const suitePath = path.join(testDir, suite.path); + try { + const files = fsSync.readdirSync(suitePath); + const wasmFiles = files.filter(file => file.endsWith('.wasm')); + + for (const wasmFile of wasmFiles) { + // Find corresponding JSON config file + const jsonFile = wasmFile.replace('.wasm', '.json'); + const jsonPath = path.join(suitePath, jsonFile); + + let config = {}; + try { + const jsonContent = fsSync.readFileSync(jsonPath, 'utf8'); + config = JSON.parse(jsonContent); + } catch (e) { + // Use default config if no JSON config file found + config = {}; + } + + allTests.push({ + suite: suite.name, + wasmFile: path.join(suitePath, wasmFile), + testName: path.basename(wasmFile, '.wasm'), + config + }); + } + } catch (err) { + console.warn(`Test suite ${suite.name} is not available: ${err.message}`); + } + } + + return allTests; +} + +// Helper function to run a test +async function runTest(testCase) { + /** @type {string[]} */ + const args = []; + /** @type {Record<string, string>} */ + const env = {}; + + // Add args if specified + if (testCase.config.args) { + args.push(...testCase.config.args); + } + + // Add env if specified + if (testCase.config.env) { + for (const [key, value] of Object.entries(testCase.config.env)) { + env[key] = value; + } + } + + // Setup file system + const fileSystem = new MemoryFileSystem((testCase.config.dirs || []).reduce((obj, dir) => { + obj[dir] = dir; + return obj; + }, {})); + + // Clone directories to memory file system + if (testCase.config.dirs) { + for (const dir of testCase.config.dirs) { + const dirPath = path.join(path.dirname(testCase.wasmFile), dir); + await cloneDirectoryToMemFS(fileSystem, dirPath, "/" + dir); + } + } + + // Create stdout and stderr buffers + let stdoutData = ''; + let stderrData = ''; + + // Create WASI instance + const wasi = new WASI({ + args: [path.basename(testCase.wasmFile), ...args], + env: env, + features: [ + useAll({ + withFileSystem: fileSystem, + withStdio: { + stdout: (data) => { + if (typeof data === 'string') { + stdoutData += data; + } else { + stdoutData += new TextDecoder().decode(data); + } + }, + stderr: (data) => { + if (typeof data === 'string') { + stderrData += data; + } else { + stderrData += new TextDecoder().decode(data); + } + } + } + }), + ], + }); + + try { + const wasmBytes = await fs.readFile(testCase.wasmFile); + const wasmModule = await WebAssembly.compile(wasmBytes); + const importObject = { wasi_snapshot_preview1: wasi.wasiImport }; + const instance = await WebAssembly.instantiate(wasmModule, importObject); + + // Start WASI + const exitCode = wasi.start(instance); + + return { + exitCode, + stdout: stdoutData, + stderr: stderrData + }; + } catch (error) { + return { + error: error.message, + exitCode: 1, + stdout: stdoutData, + stderr: stderrData + }; + } +} + +/** + * Helper function to clone a directory to memory file system + * + * @param {MemoryFileSystem} fileSystem + * @param {string} sourceDir + * @param {string} targetPath + * @returns {Promise<void>} + */ +async function cloneDirectoryToMemFS(fileSystem, sourceDir, targetPath) { + // Check if directory exists + const stats = await fs.stat(sourceDir); + if (!stats.isDirectory()) { + return; + } + + // Create directory in file system + fileSystem.ensureDir(targetPath); + + // Read directory contents + const entries = await fs.readdir(sourceDir, { withFileTypes: true }); + + // Process each entry + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + const targetFilePath = path.join(targetPath, entry.name); + + if (entry.isDirectory()) { + // Recursively clone directory + await cloneDirectoryToMemFS(fileSystem, sourcePath, targetFilePath); + } else if (entry.isFile()) { + // Read file content and add to file system + const content = await fs.readFile(sourcePath); + fileSystem.addFile(targetFilePath, content); + } + } +} + +// Main test setup +describe('WASI Test Suite', () => { + const __dirname = path.dirname(new URL(import.meta.url).pathname); + const testDir = path.join(__dirname, '../third_party/wasi-testsuite/tests'); + const testCases = findTestCases(testDir); + // Load the skip tests list + let skipTests = {}; + try { + skipTests = JSON.parse(fsSync.readFileSync(path.join(__dirname, './wasi.skip.json'), 'utf8')); + } catch (err) { + console.warn('Could not load skip tests file. All tests will be run.'); + } + + // This test will dynamically create and run tests for each test case + for (const testCase of testCases) { + const isSkipped = skipTests[testCase.suite] && skipTests[testCase.suite][testCase.testName]; + const defineTest = isSkipped ? it.skip : it; + defineTest(`${testCase.suite} - ${testCase.testName}`, async () => { + const result = await runTest(testCase); + assert.strictEqual(result.error, undefined, result.stderr); + assert.strictEqual(result.exitCode, testCase.config.exit_code || 0, result.stderr); + }); + } +}); diff --git a/third_party/wasi-test-suite b/third_party/wasi-test-suite deleted file mode 160000 index 89cd4de..0000000 --- a/third_party/wasi-test-suite +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 89cd4de0a260931308999750259ad760cd2db23d diff --git a/third_party/wasi-testsuite b/third_party/wasi-testsuite new file mode 160000 index 0000000..946ee51 --- /dev/null +++ b/third_party/wasi-testsuite @@ -0,0 +1 @@ +Subproject commit 946ee51fcefe1b561f87e0a1799458a803634f1f