diff --git a/tests/browser/fixtures/dedicated.ts b/tests/browser/fixtures/dedicated.ts
index d0bb162..77ee72a 100644
--- a/tests/browser/fixtures/dedicated.ts
+++ b/tests/browser/fixtures/dedicated.ts
@@ -1,5 +1,3 @@
-///
-
import { openFileSystem, probeOpfs } from "../../../mod.ts";
/** DedicatedWorker global used to exercise worker-only OPFS capabilities. */
diff --git a/tests/browser/fixtures/service.ts b/tests/browser/fixtures/service.ts
index a78fd00..b25871d 100644
--- a/tests/browser/fixtures/service.ts
+++ b/tests/browser/fixtures/service.ts
@@ -1,5 +1,3 @@
-///
-
import { openFileSystem, probeOpfs } from "../../../mod.ts";
/** ServiceWorker global used to verify OPFS without relying on Playwright-only worker instrumentation. */
@@ -25,8 +23,8 @@ async function runServiceRequest(
}
}
-self.addEventListener("install", (event: ExtendableEvent) => event.waitUntil(self.skipWaiting()));
-self.addEventListener("activate", (event: ExtendableEvent) => event.waitUntil(self.clients.claim()));
+self.addEventListener("install", (event) => event.waitUntil(self.skipWaiting()));
+self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
self.addEventListener("message", (event: ExtendableMessageEvent) => {
const port = event.ports[0];
if (port === undefined) return;
diff --git a/tests/browser/fixtures/shared.ts b/tests/browser/fixtures/shared.ts
index 1e84514..9c6657a 100644
--- a/tests/browser/fixtures/shared.ts
+++ b/tests/browser/fixtures/shared.ts
@@ -1,5 +1,3 @@
-///
-
import { openFileSystem, probeOpfs } from "../../../mod.ts";
/** SharedWorker global used to exercise storage shared by connected documents. */
diff --git a/tests/deno-kv-partition.test.ts b/tests/deno-kv-partition.test.ts
index 5e9ba1c..b54e2f4 100644
--- a/tests/deno-kv-partition.test.ts
+++ b/tests/deno-kv-partition.test.ts
@@ -1,5 +1,3 @@
-///
-
import { describe, it } from "node:test";
import { expect } from "@std/expect";
@@ -38,23 +36,22 @@ class FakeDenoKv implements DenoKvType {
partGets = 0;
listMatches = 0;
- async get(key: Deno.KvKey): Promise> {
+ async get(key: readonly unknown[]): Promise> {
if (key[1] === "part") this.partGets += 1;
const found = this.values.get(id(key));
return { key, value: (found?.value as T | undefined) ?? null };
}
- async set(key: Deno.KvKey, value: unknown): Promise {
+ async set(key: readonly unknown[], value: unknown): Promise {
if (size(value) > DENO_KV_MAX_VALUE_BYTES) throw new RangeError("Deno KV value exceeds 64 KiB");
this.values.set(id(key), { key: [...key], value });
}
- async delete(key: Deno.KvKey): Promise {
+ async delete(key: readonly unknown[]): Promise {
this.values.delete(id(key));
}
- async *list(selector: Deno.KvListSelector, _options?: Deno.KvListOptions): AsyncIterable> {
- if (!("prefix" in selector)) return;
+ async *list(selector: { readonly prefix: readonly unknown[] }): AsyncIterable> {
for (const entry of this.values.values()) {
if (!starts(entry.key, selector.prefix)) continue;
this.listMatches += 1;
diff --git a/tests/ecosystems.test.ts b/tests/ecosystems.test.ts
index 2e197b9..bed1489 100644
--- a/tests/ecosystems.test.ts
+++ b/tests/ecosystems.test.ts
@@ -190,34 +190,14 @@ function createFakeDrizzle() {
mediaType: { name: "mediaType" },
};
const rows: Array> = [];
- const getCondition = (condition: unknown): { name: string; value: unknown } => {
- if (typeof condition === "object" && condition !== null && "column" in condition && "value" in condition) {
- const typed = condition as { column: { name: string }; value: unknown };
- return { name: typed.column.name, value: typed.value };
- }
-
- if (typeof condition === "object" && condition !== null && "queryChunks" in condition) {
- const chunks = (condition as { queryChunks?: readonly unknown[] }).queryChunks ?? [];
- const column = chunks.find((chunk): chunk is { name: string } =>
- typeof chunk === "object" && chunk !== null && "name" in chunk && typeof (chunk as { name?: unknown }).name === "string"
- );
- const value = chunks.find((chunk) =>
- typeof chunk === "string" || typeof chunk === "number" || typeof chunk === "boolean"
- );
- if (column !== undefined) return { name: column.name, value };
- }
-
- throw new TypeError("Unsupported Drizzle condition shape in test double.");
- };
const database = {
select() {
return {
from() {
return {
- where(condition: unknown) {
- const selectedCondition = getCondition(condition);
+ where(condition: { column: { name: string }; value: unknown }) {
const selected = () => rows
- .filter((row) => row[selectedCondition.name] === selectedCondition.value)
+ .filter((row) => row[condition.column.name] === condition.value)
.map((row) => ({ ...row }));
return {
then(resolve: (value: Record[]) => unknown, reject: (reason: unknown) => unknown) {
@@ -234,10 +214,9 @@ function createFakeDrizzle() {
},
delete() {
return {
- where(condition: unknown) {
- const selectedCondition = getCondition(condition);
+ where(condition: { column: { name: string }; value: unknown }) {
for (let index = rows.length - 1; index >= 0; index -= 1) {
- if (rows[index]?.[selectedCondition.name] === selectedCondition.value) rows.splice(index, 1);
+ if (rows[index]?.[condition.column.name] === condition.value) rows.splice(index, 1);
}
return Promise.resolve();
},
diff --git a/tests/node.test.ts b/tests/node.test.ts
index bbeaa8e..dd62ad2 100644
--- a/tests/node.test.ts
+++ b/tests/node.test.ts
@@ -84,9 +84,9 @@ describe("Node adapter", () => {
prepare(sql) {
const statement = database.prepare(sql);
return {
- all: async (...params: never[]) => statement.all(...params),
- get: async (...params: never[]) => statement.get(...params),
- run: async (...params: never[]) => statement.run(...params),
+ all: (...params) => statement.all(...params),
+ get: (...params) => statement.get(...params),
+ run: (...params) => statement.run(...params),
};
},
close() { database.close(); },
diff --git a/tests/package/verify.mjs b/tests/package/verify.mjs
new file mode 100644
index 0000000..521a821
--- /dev/null
+++ b/tests/package/verify.mjs
@@ -0,0 +1,136 @@
+import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { spawn } from "node:child_process";
+
+const tarball = resolve(process.argv[2] ?? "");
+if (!tarball) throw new Error("Pass the npm tarball path.");
+
+/** Runs one child command and rejects when it exits unsuccessfully. */
+function command(file, args, options = {}) {
+ return new Promise((resolvePromise, reject) => {
+ const child = spawn(file, args, { stdio: "inherit", ...options });
+ child.on("error", reject);
+ child.on("exit", (code) => code === 0
+ ? resolvePromise()
+ : reject(new Error(`${file} exited with ${code}.`)));
+ });
+}
+
+/** Returns true when the executable can be started in this environment. */
+async function hasCommand(file) {
+ try {
+ await command(file, ["--version"], { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Lists every regular file below one extracted package directory. */
+async function walk(directory) {
+ const files = [];
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const path = join(directory, entry.name);
+ if (entry.isDirectory()) files.push(...await walk(path));
+ else files.push(path);
+ }
+ return files;
+}
+
+const workspace = await mkdtemp(join(tmpdir(), "okikio-opfs-package-"));
+try {
+ const extracted = join(workspace, "extracted");
+ await command("mkdir", ["-p", extracted]);
+ await command("tar", ["-xzf", tarball, "-C", extracted]);
+ const packageRoot = join(extracted, "package");
+ const manifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
+
+ if (manifest.name !== "@okikio/opfs") throw new Error(`Unexpected npm package name: ${manifest.name}`);
+ if (manifest.dependencies?.["drizzle-orm"]) throw new Error("drizzle-orm must not be a normal npm dependency.");
+ if (!manifest.peerDependencies?.["drizzle-orm"]) throw new Error("drizzle-orm optional peer is missing.");
+ if (manifest.peerDependenciesMeta?.["drizzle-orm"]?.optional !== true) {
+ throw new Error("drizzle-orm must be marked as an optional peer.");
+ }
+ if (!manifest.dependencies?.zod) throw new Error("zod runtime dependency is missing.");
+
+ for (const [subpath, target] of Object.entries(manifest.exports ?? {})) {
+ const entry = typeof target === "string" ? { default: target } : target;
+ for (const field of ["types", "import", "default"]) {
+ const path = entry?.[field];
+ if (path === undefined) continue;
+ if (field === "types" && !path.endsWith(".d.ts")) {
+ throw new Error(`${subpath} types do not point to .d.ts: ${path}`);
+ }
+ if (field !== "types" && !path.endsWith(".js")) {
+ throw new Error(`${subpath} runtime does not point to .js: ${path}`);
+ }
+ await stat(join(packageRoot, path));
+ }
+ }
+
+ const publishedFiles = await walk(packageRoot);
+ const rawTs = publishedFiles.filter((path) => path.endsWith(".ts") && !path.endsWith(".d.ts"));
+ if (rawTs.length > 0) throw new Error(`npm tarball contains raw TypeScript: ${rawTs.join(", ")}`);
+
+ const consumer = join(workspace, "consumer");
+ await command("mkdir", ["-p", consumer]);
+ await writeFile(join(consumer, "package.json"), `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`);
+ await command("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], { cwd: consumer });
+
+ await writeFile(join(consumer, "smoke.mjs"), `
+import { createFileSystem } from '@okikio/opfs';
+import { createMemoryAdapter } from '@okikio/opfs/adapter/memory';
+import { normalizePath } from '@okikio/opfs/path';
+const fileSystem = createFileSystem(createMemoryAdapter(), { coordination: 'local' });
+await fileSystem.writeFile('/smoke.txt', 'ok', { parents: true });
+if (await fileSystem.readText('/smoke.txt') !== 'ok') throw new Error('npm memory adapter smoke failed');
+if (normalizePath('a/../b') !== '/b') throw new Error('npm path smoke failed');
+await import('@okikio/opfs/adapter/node');
+await import('@okikio/opfs/adapter/deno');
+await import('@okikio/opfs/adapter/bun');
+`);
+ await command("node", ["smoke.mjs"], { cwd: consumer });
+
+ const hasDeno = await hasCommand("deno");
+ const hasBun = await hasCommand("bun");
+ if (hasDeno) await command("deno", ["run", "--node-modules-dir=manual", "smoke.mjs"], { cwd: consumer });
+ if (hasBun) await command("bun", ["smoke.mjs"], { cwd: consumer });
+
+ await writeFile(join(consumer, "consumer.ts"), `
+import { createFileSystem, type FileSystemType } from '@okikio/opfs';
+import { createMemoryAdapter } from '@okikio/opfs/adapter/memory';
+const fileSystem: FileSystemType = createFileSystem(createMemoryAdapter());
+await fileSystem.writeFile('/types.txt', 'ok', { parents: true });
+`);
+ if (hasDeno) await command("deno", ["check", "--node-modules-dir=manual", "consumer.ts"], { cwd: consumer });
+
+ await writeFile(join(consumer, "browser.mjs"), `
+import { createFileSystem } from '@okikio/opfs';
+import { createMemoryAdapter } from '@okikio/opfs/adapter/memory';
+import { normalizePath } from '@okikio/opfs/path';
+export const smoke = () => [createFileSystem(createMemoryAdapter()), normalizePath('a/../b')];
+`);
+ if (hasDeno) {
+ await command("deno", [
+ "bundle",
+ "--platform=browser",
+ "--node-modules-dir=manual",
+ "--output",
+ "browser-bundle.js",
+ "browser.mjs",
+ ], { cwd: consumer });
+ }
+
+ await command(
+ "npm",
+ ["install", "--ignore-scripts", "--no-audit", "--no-fund", "drizzle-orm@0.45.2"],
+ { cwd: consumer },
+ );
+ await writeFile(join(consumer, "drizzle.mjs"), `await import('@okikio/opfs/adapter/drizzle');\n`);
+ await command("node", ["drizzle.mjs"], { cwd: consumer });
+ if (hasDeno) await command("deno", ["run", "--node-modules-dir=manual", "drizzle.mjs"], { cwd: consumer });
+ if (hasBun) await command("bun", ["drizzle.mjs"], { cwd: consumer });
+} finally {
+ await rm(workspace, { recursive: true, force: true });
+}
diff --git a/tests/provider.test.ts b/tests/provider.test.ts
index d70d97c..c03675a 100644
--- a/tests/provider.test.ts
+++ b/tests/provider.test.ts
@@ -1,4 +1,4 @@
-import { describe, it } from "node:test";
+import { after, before, describe, it } from "node:test";
import { expect } from "@std/expect";
import { toBytes } from "@std/streams/to-bytes";
@@ -6,46 +6,57 @@ import { createFileSystem } from "../mod.ts";
import { createObjectAdapter } from "../src/adapter/object.ts";
import { createAzureClient } from "../src/azure.ts";
import { createS3Client } from "../src/s3.ts";
+import {
+ AZURE_ACCOUNT,
+ AZURE_KEY,
+ openProviders,
+ type ProviderFixture,
+ S3_ACCESS_KEY,
+ S3_SECRET_KEY,
+ STORAGE_NAME,
+} from "./provider/fixture.ts";
import { streamBytes } from "./stream.ts";
-/** Official Azurite development account used only by the local emulator. */
-const AZURITE_ACCOUNT = "devstoreaccount1";
-/** Official Azurite development key documented by the Azurite project. */
-const AZURITE_KEY = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
-/** Provider container/bucket created by the Docker fixture. */
-const STORAGE_NAME = "opfs-test";
/** Exact S3 multipart minimum used to force multipart behavior with a small fixture. */
const S3_PART_SIZE = 5 * 1024 * 1024;
+/** Provider resources are shared across the suite so container startup is not repeated per assertion. */
+let providers: ProviderFixture | undefined;
+
+/** Returns the active provider fixture or fails if suite setup did not complete. */
+function getProviders(): ProviderFixture {
+ if (providers === undefined) throw new Error("Provider fixture is not open.");
+ return providers;
+}
/** Returns a unique object-key prefix so failed test cleanup cannot collide with another run. */
function getPrefix(provider: string): string {
return `integration/${provider}/${crypto.randomUUID()}`;
}
-/** Creates the local SeaweedFS S3 client used by Docker-backed integration tests. */
+/** Creates the SeaweedFS S3 client for the current Testcontainers endpoint. */
function getS3Client() {
return createS3Client({
- endpoint: "http://127.0.0.1:8333",
+ endpoint: getProviders().s3Endpoint,
bucket: STORAGE_NAME,
region: "us-east-1",
- credentials: { accessKeyId: "admin", secretAccessKey: "secret" },
+ credentials: { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY },
partSize: S3_PART_SIZE,
concurrency: 2,
});
}
-/** Creates the local Azurite client with Shared Key authentication. */
+/** Creates the Azurite client for the current Testcontainers endpoint. */
function getAzureClient() {
return createAzureClient({
- endpoint: `http://127.0.0.1:10000/${AZURITE_ACCOUNT}`,
+ endpoint: getProviders().azureEndpoint,
container: STORAGE_NAME,
- credential: { kind: "shared-key", account: AZURITE_ACCOUNT, key: AZURITE_KEY },
+ credential: { kind: "shared-key", account: AZURE_ACCOUNT, key: AZURE_KEY },
blockSize: 1024 * 1024,
concurrency: 2,
});
}
-/** Ensures the Azurite container exists before blob operations begin. */
+/** Ensures the logical Azure container exists before blob operations begin. */
async function ensureAzureContainer(): Promise {
const client = getAzureClient();
const response = await client.request({ method: "PUT", query: { restype: "container" } });
@@ -53,7 +64,17 @@ async function ensureAzureContainer(): Promise {
throw new Error(`Azurite container setup failed with HTTP ${response.status}: ${await response.text()}`);
}
-describe("Docker-backed object providers", () => {
+before(async () => {
+ providers = await openProviders();
+});
+
+after(async () => {
+ const fixture = providers;
+ providers = undefined;
+ if (fixture !== undefined) await fixture.close();
+});
+
+describe("Testcontainers-backed object providers", () => {
it("exercises S3 signing, ranges, conditions, multipart upload, copy, listing, and filesystem translation", async () => {
const client = getS3Client();
const prefix = getPrefix("s3");
diff --git a/tests/provider/compose.yml b/tests/provider/compose.yml
deleted file mode 100644
index fba3928..0000000
--- a/tests/provider/compose.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-services:
- s3:
- image: chrislusf/seaweedfs:4.41
- command: ["mini", "-dir=/data"]
- environment:
- AWS_ACCESS_KEY_ID: admin
- AWS_SECRET_ACCESS_KEY: secret
- S3_BUCKET: opfs-test
- ports:
- - "8333:8333"
- volumes:
- - s3-data:/data
-
- azure:
- image: mcr.microsoft.com/azure-storage/azurite:3.36.0
- command:
- - azurite-blob
- - --blobHost
- - 0.0.0.0
- - --skipApiVersionCheck
- - --disableTelemetry
- - --inMemoryPersistence
- ports:
- - "10000:10000"
-
-volumes:
- s3-data:
diff --git a/tests/provider/fixture.ts b/tests/provider/fixture.ts
new file mode 100644
index 0000000..ae9a217
--- /dev/null
+++ b/tests/provider/fixture.ts
@@ -0,0 +1,123 @@
+import { AzuriteContainer, type StartedAzuriteContainer } from "@testcontainers/azurite";
+import { GenericContainer, type StartedTestContainer, Wait } from "testcontainers";
+
+/** SeaweedFS image used for the S3-compatible provider fixture. */
+export const S3_IMAGE = "chrislusf/seaweedfs:4.41";
+/** Azurite image used for the Azure Blob provider fixture. */
+export const AZURE_IMAGE = "mcr.microsoft.com/azure-storage/azurite:3.36.0";
+/** Bucket and container name shared by provider integration tests and benchmarks. */
+export const STORAGE_NAME = "opfs-test";
+/** Access key exposed by the SeaweedFS test fixture. */
+export const S3_ACCESS_KEY = "admin";
+/** Secret key exposed by the SeaweedFS test fixture. */
+export const S3_SECRET_KEY = "secret";
+/** Account name exposed by the Azurite test fixture. */
+export const AZURE_ACCOUNT = "devstoreaccount1";
+/** Development-only Shared Key used by the isolated Azurite fixture. */
+export const AZURE_KEY = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
+/** S3 API port inside the SeaweedFS container. */
+const S3_PORT = 8333;
+
+/**
+ * Owns one S3-compatible service and one Azure Blob emulator for a test run.
+ *
+ * Testcontainers chooses free host ports, waits for the services, and removes
+ * the containers. Callers receive only provider endpoints and credentials, so
+ * tests do not depend on Docker Compose names or fixed host ports.
+ */
+export class ProviderFixture implements AsyncDisposable {
+ /** Host endpoint for the S3-compatible API. */
+ readonly s3Endpoint: string;
+ /** Host endpoint for the Azurite Blob service, including the account path. */
+ readonly azureEndpoint: string;
+ /** Containers are retained only so this fixture can release what it started. */
+ readonly #containers: readonly StartedTestContainer[];
+ /** Prevents a second close from asking Testcontainers to stop resources twice. */
+ #closed = false;
+
+ /** Creates an owned fixture from already-started provider containers. */
+ constructor(
+ s3: StartedTestContainer,
+ azure: StartedAzuriteContainer,
+ azureEndpoint: string,
+ ) {
+ this.s3Endpoint = `http://${s3.getHost()}:${s3.getMappedPort(S3_PORT)}`;
+ this.azureEndpoint = azureEndpoint;
+ this.#containers = [azure, s3];
+ }
+
+ /** Stops and removes both provider containers in reverse acquisition order. */
+ async close(): Promise {
+ if (this.#closed) return;
+ this.#closed = true;
+
+ const failures: unknown[] = [];
+ for (const container of this.#containers) {
+ try {
+ await container.stop();
+ } catch (error) {
+ failures.push(error);
+ }
+ }
+
+ if (failures.length > 0) {
+ throw new AggregateError(failures, "One or more provider test containers could not be stopped.");
+ }
+ }
+
+ /** Releases the provider containers when used with `await using`. */
+ async [Symbol.asyncDispose](): Promise {
+ await this.close();
+ }
+}
+
+/** Starts the SeaweedFS S3-compatible fixture and waits for its HTTP surface. */
+async function openS3(): Promise {
+ return await new GenericContainer(S3_IMAGE)
+ .withCommand(["mini", "-dir=/data"])
+ .withEnvironment({
+ AWS_ACCESS_KEY_ID: S3_ACCESS_KEY,
+ AWS_SECRET_ACCESS_KEY: S3_SECRET_KEY,
+ S3_BUCKET: STORAGE_NAME,
+ })
+ .withExposedPorts(S3_PORT)
+ .withWaitStrategy(
+ Wait.forAll([
+ Wait.forListeningPorts(),
+ Wait.forHttp("/", S3_PORT).forStatusCodeMatching((status) => status >= 200 && status < 500),
+ ]),
+ )
+ .withStartupTimeout(90_000)
+ .start();
+}
+
+/** Starts the official Azurite Testcontainers module with isolated in-memory state. */
+async function openAzure(): Promise<{ container: StartedAzuriteContainer; endpoint: string }> {
+ const container = await new AzuriteContainer(AZURE_IMAGE)
+ .withSkipApiVersionCheck()
+ .withInMemoryPersistence()
+ .withAccountName(AZURE_ACCOUNT)
+ .withAccountKey(AZURE_KEY)
+ .withStartupTimeout(90_000)
+ .start();
+
+ return { container, endpoint: container.getBlobEndpoint() };
+}
+
+/**
+ * Starts all provider fixtures and cleans up partial construction on failure.
+ *
+ * Startup is deliberately sequential. A provider failure therefore has one
+ * unambiguous owner to stop, and diagnostics remain easier to attribute than a
+ * partially successful parallel startup race.
+ */
+export async function openProviders(): Promise {
+ const s3 = await openS3();
+ try {
+ const azure = await openAzure();
+ return new ProviderFixture(s3, azure.container, azure.endpoint);
+ } catch (error) {
+ await s3.stop().catch(() => undefined);
+ throw error;
+ }
+}