1import { SnapshotExtractionError, Snapshotter } from "@effect/api-diff/Snapshot"
2import * as NodeServices from "@effect/platform-node/NodeServices"
3import { assert, describe, it } from "@effect/vitest"
4import * as Effect from "effect/Effect"
5import * as FileSystem from "effect/FileSystem"
6import * as Layer from "effect/Layer"
7import { writeFixturePackage } from "./utils.ts"
8
9const MainLayer = Snapshotter.layer.pipe(
10 Layer.provideMerge(NodeServices.layer)
11)
12
13const source = `
14export declare function overloaded<A extends string>(value: A): A
15export declare function overloaded(value: number, radix?: number): string
16
17export interface Parent {
18 readonly parent: string
19}
20
21export interface Service<A = string> extends Parent {
22 readonly value?: A
23 run(input: A): Promise<A>
24 [key: \`item-\${string}\`]: unknown
25}
26
27export 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
35export type Recursive<A> = A | ReadonlyArray<Recursive<A>>
36export type Conditional<A> = A extends readonly [infer Head, ...infer Tail] ? Head | Tail[number] : never
37export type Mapped<A> = { readonly [K in keyof A as \`get\${Capitalize<string & K>}\`]?: A[K] }
38export type Imported = import("node:fs").PathLike
39export declare const token: unique symbol
40
41export declare namespace merged {
42 const value: string
43 interface Options {
44 readonly enabled: boolean
45 }
46}
47export declare function merged(value: string): string
48`
49
50describe("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 */
58export declare const current: string
59/** @stability experimental */
60export declare const experimental: string
61/** @unstable */
62export declare const legacy: string
63/** A stable declaration. */
64export 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": `
131declare namespace Other {}
132export 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})