import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as Generator from "./generate.ts"; const isDownloadError = Schema.is(Generator.AcpGeneratorDownloadError); const isDownloadFileError = Schema.is(Generator.AcpGeneratorDownloadFileError); const isDocumentDecodeError = Schema.is(Generator.AcpGeneratorDocumentDecodeError); const isFormatExitError = Schema.is(Generator.AcpGeneratorFormatExitError); const isSchemaNameParseError = Schema.is(Generator.AcpGeneratorSchemaNameParseError); const isSchemaValueDeclarationMissingError = Schema.is( Generator.AcpGeneratorSchemaValueDeclarationMissingError, ); const httpClient = (response: Response) => HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response))); function processHandle(exitCode: number) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, stdout: Stream.empty, stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); } describe("ACP schema generator errors", () => { it.effect("retains safe URL diagnostics, output path, and HTTP cause when a download fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const directory = yield* fs.makeTempDirectoryScoped({ prefix: "acp-generator-test-" }); const url = "https://generator-user:generator-password@example.test/private/schema.json?token=generator-secret#fragment"; const outputPath = `${directory}/schema.json`; const error = yield* Generator.downloadFile(url, outputPath).pipe( Effect.provideService( HttpClient.HttpClient, httpClient(new Response("unavailable", { status: 503 })), ), Effect.flip, ); assert(isDownloadError(error)); expect(error).toMatchObject({ urlInputLength: url.length, urlProtocol: "https:", urlHostname: "example.test", }); expect(error).not.toHaveProperty("url"); expect(error.outputPath).toBe(outputPath); expect(error.stage).toBe("request"); expect(error.cause).toBeDefined(); expect(error.message).toContain(outputPath); const { cause: _, ...directDiagnostics } = error; expect(directDiagnostics).not.toHaveProperty("url"); expect(directDiagnostics.urlProtocol).toBe("https:"); expect(directDiagnostics.urlHostname).toBe("example.test"); expect(error.message).not.toMatch( /generator-user|generator-password|private|token|generator-secret|fragment/, ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); it.effect("retains download context when the response cannot be written", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const outputPath = yield* fs.makeTempDirectoryScoped({ prefix: "acp-generator-test-" }); const url = "https://example.test/schema.json"; const error = yield* Generator.downloadFile(url, outputPath).pipe( Effect.provideService( HttpClient.HttpClient, httpClient(new Response("{}", { status: 200 })), ), Effect.flip, ); assert(isDownloadFileError(error)); expect(error).toMatchObject({ urlInputLength: url.length, urlProtocol: "https:", urlHostname: "example.test", }); expect(error).not.toHaveProperty("url"); expect(error.outputPath).toBe(outputPath); expect(error.stage).toBe("write-file"); expect(error.cause).toBeDefined(); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); it.effect("adds source file context to upstream document decode failures", () => Effect.gen(function* () { const schemaPath = "/tmp/upstream-schema.json"; const schemaError = yield* Generator.decodeUpstreamSchemaDocument( "not-json", schemaPath, ).pipe(Effect.flip); assert(isDocumentDecodeError(schemaError)); expect(schemaError.document).toBe("schema"); expect(schemaError.filePath).toBe(schemaPath); expect(schemaError.cause).toBeDefined(); const metadataPath = "/tmp/upstream-meta.json"; const metadataError = yield* Generator.decodeMetaDocument("not-json", metadataPath).pipe( Effect.flip, ); assert(isDocumentDecodeError(metadataError)); expect(metadataError.document).toBe("metadata"); expect(metadataError.filePath).toBe(metadataPath); expect(metadataError.cause).toBeDefined(); }), ); it.effect("reports formatter commands and nonzero exit codes structurally", () => { let spawned: ChildProcess.StandardCommand | undefined; const spawner = ChildProcessSpawner.make((command) => { if (ChildProcess.isStandardCommand(command)) { spawned = command; } return Effect.succeed(processHandle(23)); }); return Effect.gen(function* () { const generatedDir = "/tmp/acp-generated"; const error = yield* Generator.formatGeneratedFiles(generatedDir).pipe(Effect.flip); assert(isFormatExitError(error)); expect(error.command).toBe("bun"); expect(error.argumentCount).toBe(2); expect(error).not.toHaveProperty("args"); expect(error.generatedDir).toBe(generatedDir); expect(error.exitCode).toBe(23); expect(error.message).toContain("23"); expect(spawned?.command).toBe("bun"); expect(spawned?.args).toEqual(["oxfmt", generatedDir]); }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); }); it.effect("returns malformed generated schema declarations as typed failures", () => Effect.gen(function* () { const missingValueError = yield* Generator.collectSchemaEntries( "export type Session = string;", ).pipe(Effect.flip); assert(isSchemaValueDeclarationMissingError(missingValueError)); expect(missingValueError).toMatchObject({ lineIndex: 0, typeDeclarationLength: 29, nextLinePresent: false, }); expect(missingValueError).not.toHaveProperty("typeDeclaration"); const nameParseError = yield* Generator.collectSchemaEntries( "export type @ = string;\nexport const invalid = Schema.String;", ).pipe(Effect.flip); assert(isSchemaNameParseError(nameParseError)); expect(nameParseError).toMatchObject({ lineIndex: 0, typeDeclarationLength: 23, }); expect(nameParseError).not.toHaveProperty("typeDeclaration"); }), ); });