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; + } +}