Open History to see the last commit
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
50// Each extraction builds a fresh TypeScript program. Concurrent CI workers can
51// push these compiler-backed tests past Vitest's default five-second timeout.
52describe("canonical snapshot", { timeout: 30_000 }, () => {
53 it.effect("preserves stability from both current and historical JSDoc tags", () =>
54 Effect.gen(function*() {
55 const fs = yield* FileSystem.FileSystem
56 const snapshotter = yield* Snapshotter
57 const root = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" })
58 yield* writeFixturePackage(root, {
59 "Stability.d.ts": `/** @stability unstable */
60export declare const current: string
61/** @stability experimental */
62export declare const experimental: string
63/** @unstable */
64export declare const legacy: string
65/** A stable declaration. */
66export declare const stable: string
67`
68 })
69 const snapshot = yield* snapshotter.extract({
70 repoRoot: root,
71 ref: "fixture",
72 sha: "0000000000000000000000000000000000000000",
73 modules: ["@fixture/sample/Stability"]
74 })
75 const stability = Object.fromEntries(snapshot.entities.map((entity) => [
76 entity.path.join("."),
77 entity.documentation.stability
78 ]))
79 assert.deepStrictEqual(stability, {
80 current: "unstable",
81 experimental: "experimental",
82 legacy: "unstable",
83 stable: "stable"
84 })
85 }).pipe(Effect.provide(MainLayer)))
86
87 it.effect("extracts declarations, overloads, namespaces, re-exports, and canonical types deterministically", () =>
88 Effect.gen(function*() {
89 const fs = yield* FileSystem.FileSystem
90 const snapshotter = yield* Snapshotter
91 const root = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" })
92 yield* writeFixturePackage(root, {
93 "index.d.ts": `export * as Foo from "./Foo.js"\n`,
94 "Foo.d.ts": source
95 })
96
97 const options = {
98 repoRoot: root,
99 ref: "fixture",
100 sha: "0000000000000000000000000000000000000000",
101 modules: ["@fixture/sample", "@fixture/sample/Foo"]
102 }
103 const first = yield* snapshotter.extract(options)
104 const second = yield* snapshotter.extract(options)
105 assert.deepStrictEqual(first, second)
106 assert(first.entities.some((entity) =>
107 entity.module === "@fixture/sample/Foo" &&
108 entity.path.join(".") === "overloaded" &&
109 entity.declarations.length === 2
110 ))
111 const overloaded = first.entities.find((entity) => entity.id === "@fixture/sample/Foo#overloaded#value")
112 assert.deepStrictEqual(overloaded?.importRoutes, [
113 { module: "@fixture/sample", path: ["Foo", "overloaded"] },
114 { module: "@fixture/sample/Foo", path: ["overloaded"] }
115 ])
116 const client = first.entities.filter((entity) => entity.path.join(".") === "Client")
117 assert.deepStrictEqual(client.map((entity) => entity.bucket), ["type", "value"])
118 assert(first.entities.some((entity) => entity.path.join(".") === "merged.Options" && entity.bucket === "type"))
119 assert(first.entities.some((entity) => JSON.stringify(entity.declarations).includes("\"kind\":\"conditional\"")))
120 assert(first.entities.some((entity) => JSON.stringify(entity.declarations).includes("\"kind\":\"mapped\"")))
121 const imported = first.entities.find((entity) => entity.path.join(".") === "Imported")
122 assert(imported !== undefined, JSON.stringify(first.entities.map((entity) => entity.id)))
123 assert(JSON.stringify(imported.declarations).includes("\"externalPackage\":\"node:fs\""))
124 }).pipe(Effect.provide(MainLayer)))
125
126 it.effect("fails rather than silently omitting unsupported public declarations", () =>
127 Effect.gen(function*() {
128 const fs = yield* FileSystem.FileSystem
129 const snapshotter = yield* Snapshotter
130 const root = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" })
131 yield* writeFixturePackage(root, {
132 "Bad.d.ts": `
133declare namespace Other {}
134export declare namespace Bad {
135 export import Alias = Other
136}
137`
138 })
139 const failure = yield* Effect.flip(snapshotter.extract({
140 repoRoot: root,
141 ref: "fixture",
142 sha: "0000000000000000000000000000000000000000",
143 modules: ["@fixture/sample/Bad"]
144 }))
145 assert(failure instanceof SnapshotExtractionError)
146 assert(failure.diagnostics.some((diagnostic) => diagnostic.code === "unsupported-public-declaration"))
147 }).pipe(Effect.provide(MainLayer)))
148
149 it.effect("normalizes union order in structural fingerprints", () =>
150 Effect.gen(function*() {
151 const fs = yield* FileSystem.FileSystem
152 const snapshotter = yield* Snapshotter
153 const leftRoot = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" })
154 const rightRoot = yield* fs.makeTempDirectoryScoped({ prefix: "api-diff-snapshot-" })
155 yield* writeFixturePackage(leftRoot, { "Choice.d.ts": "export type Choice = string | number\n" })
156 yield* writeFixturePackage(rightRoot, { "Choice.d.ts": "export type Choice = number | string\n" })
157 const extract = (repoRoot: string) =>
158 snapshotter.extract({
159 repoRoot,
160 ref: "fixture",
161 sha: "0000000000000000000000000000000000000000",
162 modules: ["@fixture/sample/Choice"]
163 }).pipe(Effect.map((snapshot) => snapshot.entities[0]?.fingerprint))
164 assert.strictEqual(yield* extract(leftRoot), yield* extract(rightRoot))
165 }).pipe(Effect.provide(MainLayer)))
166})