| 1 | import { SnapshotExtractionError, Snapshotter } from "@effect/api-diff/Snapshot" |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices" |
| 3 | import { assert, describe, it } from "@effect/vitest" |
| 4 | import * as Effect from "effect/Effect" |
| 5 | import * as FileSystem from "effect/FileSystem" |
| 6 | import * as Layer from "effect/Layer" |
| 7 | import { writeFixturePackage } from "./utils.ts" |
| 8 | |
| 9 | const MainLayer = Snapshotter.layer.pipe( |
| 10 | Layer.provideMerge(NodeServices.layer) |
| 11 | ) |
| 12 | |
| 13 | const source = ` |
| 14 | export declare function overloaded<A extends string>(value: A): A |
| 15 | export declare function overloaded(value: number, radix?: number): string |
| 16 | |
| 17 | export interface Parent { |
| 18 | readonly parent: string |
| 19 | } |
| 20 | |
| 21 | export interface Service<A = string> extends Parent { |
| 22 | readonly value?: A |
| 23 | run(input: A): Promise<A> |
| 24 | [key: \`item-\${string}\`]: unknown |
| 25 | } |
| 26 | |
| 27 | export declare class Client<A> implements Service<A> { |
| 28 | static readonly version: string |
| 29 | readonly parent: string |
| 30 | readonly value?: A |
| 31 | constructor(value?: A) |
| 32 | run(input: A): Promise<A> |
| 33 | } |
| 34 | |
| 35 | export type Recursive<A> = A | ReadonlyArray<Recursive<A>> |
| 36 | export type Conditional<A> = A extends readonly [infer Head, ...infer Tail] ? Head | Tail[number] : never |
| 37 | export type Mapped<A> = { readonly [K in keyof A as \`get\${Capitalize<string & K>}\`]?: A[K] } |
| 38 | export type Imported = import("node:fs").PathLike |
| 39 | export declare const token: unique symbol |
| 40 | |
| 41 | export declare namespace merged { |
| 42 | const value: string |
| 43 | interface Options { |
| 44 | readonly enabled: boolean |
| 45 | } |
| 46 | } |
| 47 | export declare function merged(value: string): string |
| 48 | ` |
| 49 | |
| 50 | describe("canonical snapshot", () => { |
| 51 | it.effect("preserves stability from both current and historical JSDoc tags", () => |
| 52 | Effect.gen(function*() { |
| 53 | const fs = yield* FileSystem.FileSystem |
| 54 | const snapshotter = yield* Snapshotter |
| 55 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" }) |
| 56 | yield* writeFixturePackage(root, { |
| 57 | "Stability.d.ts": `/** @stability unstable */ |
| 58 | export declare const current: string |
| 59 | /** @stability experimental */ |
| 60 | export declare const experimental: string |
| 61 | /** @unstable */ |
| 62 | export declare const legacy: string |
| 63 | /** A stable declaration. */ |
| 64 | export declare const stable: string |
| 65 | ` |
| 66 | }) |
| 67 | const snapshot = yield* snapshotter.extract({ |
| 68 | repoRoot: root, |
| 69 | ref: "fixture", |
| 70 | sha: "0000000000000000000000000000000000000000", |
| 71 | modules: ["@fixture/sample/Stability"] |
| 72 | }) |
| 73 | const stability = Object.fromEntries(snapshot.entities.map((entity) => [ |
| 74 | entity.path.join("."), |
| 75 | entity.documentation.stability |
| 76 | ])) |
| 77 | assert.deepStrictEqual(stability, { |
| 78 | current: "unstable", |
| 79 | experimental: "experimental", |
| 80 | legacy: "unstable", |
| 81 | stable: "stable" |
| 82 | }) |
| 83 | }).pipe(Effect.provide(MainLayer))) |
| 84 | |
| 85 | it.effect("extracts declarations, overloads, namespaces, re-exports, and canonical types deterministically", () => |
| 86 | Effect.gen(function*() { |
| 87 | const fs = yield* FileSystem.FileSystem |
| 88 | const snapshotter = yield* Snapshotter |
| 89 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" }) |
| 90 | yield* writeFixturePackage(root, { |
| 91 | "index.d.ts": `export * as Foo from "./Foo.js"\n`, |
| 92 | "Foo.d.ts": source |
| 93 | }) |
| 94 | |
| 95 | const options = { |
| 96 | repoRoot: root, |
| 97 | ref: "fixture", |
| 98 | sha: "0000000000000000000000000000000000000000", |
| 99 | modules: ["@fixture/sample", "@fixture/sample/Foo"] |
| 100 | } |
| 101 | const first = yield* snapshotter.extract(options) |
| 102 | const second = yield* snapshotter.extract(options) |
| 103 | assert.deepStrictEqual(first, second) |
| 104 | assert(first.entities.some((entity) => |
| 105 | entity.module === "@fixture/sample/Foo" && |
| 106 | entity.path.join(".") === "overloaded" && |
| 107 | entity.declarations.length === 2 |
| 108 | )) |
| 109 | const overloaded = first.entities.find((entity) => entity.id === "@fixture/sample/Foo#overloaded#value") |
| 110 | assert.deepStrictEqual(overloaded?.importRoutes, [ |
| 111 | { module: "@fixture/sample", path: ["Foo", "overloaded"] }, |
| 112 | { module: "@fixture/sample/Foo", path: ["overloaded"] } |
| 113 | ]) |
| 114 | const client = first.entities.filter((entity) => entity.path.join(".") === "Client") |
| 115 | assert.deepStrictEqual(client.map((entity) => entity.bucket), ["type", "value"]) |
| 116 | assert(first.entities.some((entity) => entity.path.join(".") === "merged.Options" && entity.bucket === "type")) |
| 117 | assert(first.entities.some((entity) => JSON.stringify(entity.declarations).includes("\"kind\":\"conditional\""))) |
| 118 | assert(first.entities.some((entity) => JSON.stringify(entity.declarations).includes("\"kind\":\"mapped\""))) |
| 119 | const imported = first.entities.find((entity) => entity.path.join(".") === "Imported") |
| 120 | assert(imported !== undefined, JSON.stringify(first.entities.map((entity) => entity.id))) |
| 121 | assert(JSON.stringify(imported.declarations).includes("\"externalPackage\":\"node:fs\"")) |
| 122 | }).pipe(Effect.provide(MainLayer))) |
| 123 | |
| 124 | it.effect("fails rather than silently omitting unsupported public declarations", () => |
| 125 | Effect.gen(function*() { |
| 126 | const fs = yield* FileSystem.FileSystem |
| 127 | const snapshotter = yield* Snapshotter |
| 128 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" }) |
| 129 | yield* writeFixturePackage(root, { |
| 130 | "Bad.d.ts": ` |
| 131 | declare namespace Other {} |
| 132 | export declare namespace Bad { |
| 133 | export import Alias = Other |
| 134 | } |
| 135 | ` |
| 136 | }) |
| 137 | const failure = yield* Effect.flip(snapshotter.extract({ |
| 138 | repoRoot: root, |
| 139 | ref: "fixture", |
| 140 | sha: "0000000000000000000000000000000000000000", |
| 141 | modules: ["@fixture/sample/Bad"] |
| 142 | })) |
| 143 | assert(failure instanceof SnapshotExtractionError) |
| 144 | assert(failure.diagnostics.some((diagnostic) => diagnostic.code === "unsupported-public-declaration")) |
| 145 | }).pipe(Effect.provide(MainLayer))) |
| 146 | |
| 147 | it.effect("normalizes union order in structural fingerprints", () => |
| 148 | Effect.gen(function*() { |
| 149 | const fs = yield* FileSystem.FileSystem |
| 150 | const snapshotter = yield* Snapshotter |
| 151 | const leftRoot = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" }) |
| 152 | const rightRoot = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" }) |
| 153 | yield* writeFixturePackage(leftRoot, { "Choice.d.ts": "export type Choice = string | number\n" }) |
| 154 | yield* writeFixturePackage(rightRoot, { "Choice.d.ts": "export type Choice = number | string\n" }) |
| 155 | const extract = (repoRoot: string) => |
| 156 | snapshotter.extract({ |
| 157 | repoRoot, |
| 158 | ref: "fixture", |
| 159 | sha: "0000000000000000000000000000000000000000", |
| 160 | modules: ["@fixture/sample/Choice"] |
| 161 | }).pipe(Effect.map((snapshot) => snapshot.entities[0]?.fingerprint)) |
| 162 | assert.strictEqual(yield* extract(leftRoot), yield* extract(rightRoot)) |
| 163 | }).pipe(Effect.provide(MainLayer))) |
| 164 | }) |