import { expect } from "@std/expect"; import { describe, it } from "@std/testing/bdd"; import ts from "typescript"; import { generateCommonfabricTypes, inlineWorkspaceModules, moduleSpecifierOf, readInlinedModule, } from "./generate-commonfabric-types.ts"; /** * Tests for the `commonfabric` type generator. * * The first is a contract test: the checked-in * `assets/types/commonfabric.d.ts` is what the pattern compiler serves, and it * has to be what this generator produces. The rest pin the shape of that * output. * * These tests read files, so they run under the Deno test runner only. They * live beside the generator under `scripts/`, where the recursive deno-test * task finds them but the browser-bundled `test/*.test.ts` pass does not. */ const GENERATED_URL = new URL( "../assets/types/commonfabric.d.ts", import.meta.url, ); const SOURCE_URL = new URL("../../api/index.ts", import.meta.url); const INLINED_URL = new URL( "../../data-model/src/api.ts", import.meta.url, ); const LIBRARY_URL = new URL("../assets/types/es2023.d.ts", import.meta.url); /** * Type-checks `source` as a module beside the checked-in type module, which it * imports as `./commonfabric.d.ts`, the way the pattern compiler serves it: a * declaration file the checker reads and does not check. Returns the messages * of the diagnostics `source` gets and the number of type instantiations the * check made. */ function checkAgainstTypeModule( source: string, ): { messages: string[]; instantiations: number } { const files = new Map([ ["/commonfabric.d.ts", Deno.readTextFileSync(GENERATED_URL)], ["/lib.d.ts", Deno.readTextFileSync(LIBRARY_URL)], ["/main.ts", source], ]); const program = ts.createProgram(["/main.ts"], { target: ts.ScriptTarget.ES2023, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, allowImportingTsExtensions: true, noEmit: true, strict: true, skipLibCheck: true, }, { getSourceFile: (name) => { const text = files.get(name); return text === undefined ? undefined : ts.createSourceFile(name, text, ts.ScriptTarget.ES2023, true); }, writeFile: () => {}, getCurrentDirectory: () => "/", fileExists: (name) => files.has(name), readFile: (name) => files.get(name), getCanonicalFileName: (name) => name, useCaseSensitiveFileNames: () => true, getNewLine: () => "\n", getDefaultLibFileName: () => "/lib.d.ts", }); const messages = program .getSemanticDiagnostics(program.getSourceFile("/main.ts")) .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n") ); return { messages, instantiations: program.getInstantiationCount() }; } describe("generate-commonfabric-types", () => { describe("generateCommonfabricTypes()", () => { it("returns the text of the checked-in type module", async () => { expect(await generateCommonfabricTypes()).toBe( Deno.readTextFileSync(GENERATED_URL), ); }); it("returns text opening with the script's path and a do-not-edit line", async () => { const lines = (await generateCommonfabricTypes()).split("\n"); expect(lines[0]).toBe( "// Generated by" + " packages/static/scripts/generate-commonfabric-types.ts.", ); expect(lines[1]).toBe("// Do not edit by hand."); }); it("returns text naming both the regenerate and the check task", async () => { const generated = await generateCommonfabricTypes(); expect(generated).toContain("deno task gen-commonfabric-types"); expect(generated).toContain("deno task check-commonfabric-types"); }); it("returns text opening with the header and then the API module", async () => { const generated = await generateCommonfabricTypes(); const source = Deno.readTextFileSync(SOURCE_URL); // The header is a run of comment lines and a blank; the API module's own // doc comment starts immediately after, with nothing interposed. const [, header, rest] = generated.match( /^((?:\/\/[^\n]*\n)+\n)([^]*)$/, )!; expect(header).toContain("Do not edit by hand"); expect(rest.startsWith(source.slice(0, source.indexOf("\nimport")))) .toBe(true); }); it("returns text whose every module specifier is relative", async () => { // The pattern compiler resolves a relative specifier by path-join and a // bare one not at all, so a bare specifier surviving into the generated // file names a module the sandbox cannot load. Parsed rather than // grepped: the interned-symbol brands are made from strings such as // "@commonfabric/FabricPrimitive", which a substring test reads as a // specifier. const source = ts.createSourceFile( "commonfabric.d.ts", await generateCommonfabricTypes(), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS, ); const specifiers = source.statements .map((statement) => moduleSpecifierOf(statement)) .filter((specifier) => specifier !== undefined); expect(specifiers.length).toBeGreaterThan(0); expect(specifiers.filter((s) => !s.startsWith("."))).toEqual([]); }); it("returns text carrying the inlined module's declarations", async () => { const generated = await generateCommonfabricTypes(); const inlined = Deno.readTextFileSync(INLINED_URL); expect(generated).toContain( "export declare const FabricHash: FabricHashConstructor;", ); expect(generated).toContain(inlined.trimEnd()); }); it("reads the module named by its argument", async () => { const path = await Deno.makeTempFile({ suffix: ".ts" }); try { await Deno.writeTextFile(path, "export type Fake = never;\n"); expect(await generateCommonfabricTypes(path)).toContain( "export type Fake = never;\n", ); } finally { await Deno.remove(path); } }); }); describe("the checked-in type module", () => { // Relating two instantiations of an interface needs the interface's // variance. Where the interface declares none, the checker measures it by // comparing two instantiations of the whole interface member by member, // which for a cell is about 80,000 type instantiations, made once in every // compile that passes a cell where a cell of another type is expected. A // declared variance is read instead. for (const cell of ["Cell", "OpaqueCell"]) { const relating = (from: string, to: string) => ` import type { ${cell} } from "./commonfabric.d.ts"; type Narrow = { id: string; label: string }; type Wide = { id: string }; declare const held: ${cell}<${from}>; export const passed: ${cell}<${to}> = held; `; it(`relates \`${cell}\` to \`${cell}\` in under 1,000 type instantiations`, () => { const { messages, instantiations } = checkAgainstTypeModule( relating("Narrow", "Wide"), ); expect(messages).toEqual([]); expect(instantiations).toBeLessThan(1000); }); it(`reports \`${cell}\` assigned to \`${cell}\``, () => { const { messages } = checkAgainstTypeModule( relating("Wide", "Narrow"), ); expect(messages).toHaveLength(1); expect(messages[0]).toContain( `Type '${cell}' is not assignable to type '${cell}'`, ); }); } }); describe("moduleSpecifierOf()", () => { function firstStatement(text: string): ts.Statement { const source = ts.createSourceFile( "x.ts", text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS, ); return source.statements[0]!; } it("returns the specifier of an import declaration", () => { expect(moduleSpecifierOf(firstStatement('import "./a.ts";'))) .toBe("./a.ts"); }); it("returns the specifier of an export declaration", () => { expect(moduleSpecifierOf(firstStatement('export * from "./b.ts";'))) .toBe("./b.ts"); }); it("returns `undefined` for an export with no specifier", () => { expect(moduleSpecifierOf(firstStatement("export {};"))).toBe(undefined); }); it("returns `undefined` for a statement that is not a module edge", () => { expect(moduleSpecifierOf(firstStatement("const a = 1;"))).toBe(undefined); }); }); describe("readInlinedModule()", () => { it("returns the named module's text without its trailing newlines", async () => { const text = await readInlinedModule("@commonfabric/data-model/api"); expect(text.endsWith("\n")).toBe(false); expect(text).toContain("export declare const FabricHash"); }); it("throws when the named module names a specifier of its own", async () => { // A barrel re-exports, so its text carries a specifier the pattern // compiler could not resolve once inlined. await expect(readInlinedModule("@commonfabric/static")).rejects.toThrow( /Cannot inline/, ); }); it("throws when the specifier names no export", async () => { await expect(readInlinedModule("@commonfabric/data-model/nope")).rejects .toThrow(); }); }); describe("inlineWorkspaceModules()", () => { it("returns the text unchanged when it names no workspace specifier", async () => { const text = 'import type { A } from "./a.ts";\n\nexport type B = A;\n'; expect(await inlineWorkspaceModules(text, "x.ts")).toBe(text); }); it("replaces a workspace `export * from` with that module's text", async () => { const text = 'export * from "@commonfabric/data-model/api";\n'; const result = await inlineWorkspaceModules(text, "x.ts"); expect(result).toContain("export declare const FabricHash"); expect(result).not.toContain("@commonfabric/data-model/api"); }); it("drops an `import type` from a module being inlined", async () => { const text = "import type { FabricHash } from" + ' "@commonfabric/data-model/api";\n\n' + 'export * from "@commonfabric/data-model/api";\n'; const result = await inlineWorkspaceModules(text, "x.ts"); expect(result.startsWith("/**")).toBe(true); expect(result).not.toContain("import type"); }); it("throws on a named re-export from a workspace specifier", async () => { const text = 'export { FabricHash } from "@commonfabric/data-model/api";\n'; await expect(inlineWorkspaceModules(text, "x.ts")).rejects.toThrow( /neither an `export \* from` to inline/, ); }); it("throws on a value import from a workspace specifier", async () => { const text = 'import { deepFreeze } from "@commonfabric/data-model/api";' + '\n\nexport * from "@commonfabric/data-model/api";\n'; await expect(inlineWorkspaceModules(text, "x.ts")).rejects.toThrow( /cannot be generated/, ); }); it("throws on an `import type` from a module not being inlined", async () => { const text = 'import type { A } from "@commonfabric/data-model/api";\n'; await expect(inlineWorkspaceModules(text, "x.ts")).rejects.toThrow( /cannot be generated/, ); }); }); });