From dec22bd6ef6aa5e49a98aa7e9039f6412517b69a Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 26 Apr 2025 14:06:37 -0700 Subject: [PATCH 01/55] Initial host builder --- packages/compiler/src/testing/index.ts | 3 + packages/compiler/src/testing/test-host-v2.ts | 129 ++++++++++++++++++ packages/compiler/src/testing/test-host.ts | 2 +- packages/openapi/test/helpers.test.ts | 1 + packages/openapi/test/test-host.ts | 11 +- packages/openapi/test/test-new-host.test.ts | 17 +++ 6 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/src/testing/test-host-v2.ts create mode 100644 packages/openapi/test/test-new-host.test.ts diff --git a/packages/compiler/src/testing/index.ts b/packages/compiler/src/testing/index.ts index edcd6c82f9a..d8ae912cc6c 100644 --- a/packages/compiler/src/testing/index.ts +++ b/packages/compiler/src/testing/index.ts @@ -34,3 +34,6 @@ export type { TypeSpecTestLibrary, TypeSpecTestLibraryInit, } from "./types.js"; + +// TODO: use named imports +export * from "./test-host-v2.js"; diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts new file mode 100644 index 00000000000..fdad626968b --- /dev/null +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -0,0 +1,129 @@ +import { + CompilerOptions, + Diagnostic, + NoTarget, + NodeHost, + Program, + SourceFile, + Type, + compile, + compilerAssert, + getRelativePathFromDirectory, + joinPaths, + resolvePath, +} from "@typespec/compiler"; +import { readFile } from "fs/promises"; +import { createSourceLoader } from "../core/source-loader.js"; +import { StandardTestLibrary, createTestFileSystem } from "./test-host.js"; +import { resolveVirtualPath } from "./test-utils.js"; + +export interface TestCompileResult { + readonly program: Program; + readonly types: Record; +} + +export interface JsFileDef { + [key: string]: string | unknown; +} + +interface TestCompileOptions { + readonly files?: Record; + readonly options?: CompilerOptions; +} + +interface Testable { + // compile( + // main: string, + // options?: TestCompileOptions + // ): Promise; + // diagnose( + // main: string, + // options?: TestCompileOptions + // ): Promise; + compileAndDiagnose( + main: string, + options?: TestCompileOptions, + ): Promise<[TestCompileResult, readonly Diagnostic[]]>; +} + +// Immutable structure meant to be reused +export interface TestHostBuilder extends Testable { + // addImports(): TestHostBuilder; + // addUsing(...names: string[]): TestHostBuilder; + // wrap(fn: (x: string) => string): TestHostBuilder; + // createHost(): TestHostV2; +} + +export function createTestHostBuilder( + base: string, + options: { libraries: string[] }, +): TestHostBuilder { + let loaded: Promise | undefined; + const fs = createTestFileSystem(); + fs.addTypeSpecFile(".keep", ""); // dummy so it knows / is a directory TODO: better way to do this? + + return { + compileAndDiagnose, + }; + + function load(): Promise { + if (loaded) return loaded; + + loaded = loadInternal(); + return loaded; + + async function loadInternal() { + const sl = await createSourceLoader({ ...NodeHost, realpath: async (x) => x }); + const selfName = JSON.parse(await readFile(resolvePath(base, "package.json"), "utf8")).name; + for (const lib of options.libraries) { + await sl.importPath(lib, NoTarget, base); + } + + await fs.addTypeSpecLibrary(StandardTestLibrary); + fs.addTypeSpecFile(".tsp/test-lib/main.tsp", 'import "./test.js";'); + fs.addJsFile(".tsp/test-lib/test.js", { + namespace: "TypeSpec", + $test(_: any, target: Type) {}, + }); + + function computeVirtualPath(file: SourceFile): string { + const context = sl.resolution.locationContexts.get(file); + compilerAssert( + context?.type === "library", + `Unexpected: all source files should be in a library but ${file.path} was in '${context?.type}'`, + ); + const relativePath = getRelativePathFromDirectory(base, file.path, false); + if (context.metadata.name === selfName) { + return joinPaths("node_modules", selfName, relativePath); + } else { + return relativePath; + } + } + + for (const file of sl.resolution.sourceFiles.values()) { + const relativePath = computeVirtualPath(file.file); + fs.addTypeSpecFile(resolveVirtualPath(relativePath), file.file.text); + } + for (const file of sl.resolution.jsSourceFiles.values()) { + const relativePath = computeVirtualPath(file.file); + fs.addJsFile(resolveVirtualPath(relativePath), file.esmExports); + } + for (const [path, lib] of sl.resolution.loadedLibraries) { + fs.addTypeSpecFile( + resolvePath("node_modules", path, "package.json"), + (lib.manifest as any).file.text, + ); + } + } + } + + async function compileAndDiagnose( + main: string, + options?: TestCompileOptions, + ): Promise<[TestCompileResult, readonly Diagnostic[]]> { + await load(); + fs.addTypeSpecFile("main.tsp", main); + const program = await compile(fs.compilerHost, resolveVirtualPath("main.tsp")); + return [{ program, types: {} }, program.diagnostics]; + } +} diff --git a/packages/compiler/src/testing/test-host.ts b/packages/compiler/src/testing/test-host.ts index eb851dad263..f1cf06572bd 100644 --- a/packages/compiler/src/testing/test-host.ts +++ b/packages/compiler/src/testing/test-host.ts @@ -152,7 +152,7 @@ function createTestCompilerHost( }; } -export async function createTestFileSystem(options?: TestHostOptions): Promise { +export function createTestFileSystem(options?: TestHostOptions): TestFileSystem { const virtualFs = createStringMap(!!options?.caseInsensitiveFileSystem); const jsImports = createStringMap>(!!options?.caseInsensitiveFileSystem); diff --git a/packages/openapi/test/helpers.test.ts b/packages/openapi/test/helpers.test.ts index 4328ba0eeb5..bee423b4ca3 100644 --- a/packages/openapi/test/helpers.test.ts +++ b/packages/openapi/test/helpers.test.ts @@ -3,6 +3,7 @@ import { BasicTestRunner, createTestRunner } from "@typespec/compiler/testing"; import { strictEqual } from "assert"; import { beforeEach, describe, it } from "vitest"; import { resolveOperationId } from "../src/helpers.js"; + describe("openapi: helpers", () => { let runner: BasicTestRunner; diff --git a/packages/openapi/test/test-host.ts b/packages/openapi/test/test-host.ts index c3656a17113..9c6583f036a 100644 --- a/packages/openapi/test/test-host.ts +++ b/packages/openapi/test/test-host.ts @@ -1,8 +1,17 @@ -import { createTestHost, createTestWrapper } from "@typespec/compiler/testing"; +import { resolvePath } from "@typespec/compiler"; +import { + createTestHost, + createTestHostBuilder, + createTestWrapper, +} from "@typespec/compiler/testing"; import { HttpTestLibrary } from "@typespec/http/testing"; import { RestTestLibrary } from "@typespec/rest/testing"; import { OpenAPITestLibrary } from "../src/testing/index.js"; +export const HostBuilder = createTestHostBuilder(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/http", "@typespec/rest", "@typespec/openapi"], +}); + export async function createOpenAPITestHost() { return createTestHost({ libraries: [HttpTestLibrary, RestTestLibrary, OpenAPITestLibrary], diff --git a/packages/openapi/test/test-new-host.test.ts b/packages/openapi/test/test-new-host.test.ts new file mode 100644 index 00000000000..5a100821c93 --- /dev/null +++ b/packages/openapi/test/test-new-host.test.ts @@ -0,0 +1,17 @@ +import { expectDiagnostics } from "@typespec/compiler/testing"; +import { it } from "vitest"; +import { HostBuilder } from "./test-host.js"; + +it("works", async () => { + const [, diagnostics] = await HostBuilder.compileAndDiagnose(` + import "@typespec/openapi"; + using OpenAPI; + @operationId("foo") + model Foo {} + `); + + expectDiagnostics(diagnostics, { + code: "decorator-wrong-target", + message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", + }); +}); From 9a669a7f975099f86068d4dade278fb48efc99c4 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 26 Apr 2025 19:55:06 -0700 Subject: [PATCH 02/55] wrap --- packages/compiler/src/testing/test-host-v2.ts | 109 ++++++++++++------ packages/openapi/test/test-host.ts | 8 +- packages/openapi/test/test-new-host.test.ts | 21 +++- 3 files changed, 92 insertions(+), 46 deletions(-) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index fdad626968b..eb18c14d43c 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -1,21 +1,15 @@ -import { - CompilerOptions, - Diagnostic, - NoTarget, - NodeHost, - Program, - SourceFile, - Type, - compile, - compilerAssert, - getRelativePathFromDirectory, - joinPaths, - resolvePath, -} from "@typespec/compiler"; import { readFile } from "fs/promises"; +import { compilerAssert } from "../core/diagnostics.js"; +import { NodeHost } from "../core/node-host.js"; +import { CompilerOptions } from "../core/options.js"; +import { getRelativePathFromDirectory, joinPaths, resolvePath } from "../core/path-utils.js"; +import { Program, compile as coreCompile } from "../core/program.js"; import { createSourceLoader } from "../core/source-loader.js"; +import { Diagnostic, NoTarget, SourceFile, Type } from "../core/types.js"; +import { expectDiagnosticEmpty } from "./expect.js"; import { StandardTestLibrary, createTestFileSystem } from "./test-host.js"; import { resolveVirtualPath } from "./test-utils.js"; +import { TestFileSystem } from "./types.js"; export interface TestCompileResult { readonly program: Program; @@ -32,14 +26,8 @@ interface TestCompileOptions { } interface Testable { - // compile( - // main: string, - // options?: TestCompileOptions - // ): Promise; - // diagnose( - // main: string, - // options?: TestCompileOptions - // ): Promise; + compile(main: string, options?: TestCompileOptions): Promise; + diagnose(main: string, options?: TestCompileOptions): Promise; compileAndDiagnose( main: string, options?: TestCompileOptions, @@ -47,26 +35,28 @@ interface Testable { } // Immutable structure meant to be reused -export interface TestHostBuilder extends Testable { +export interface Tester extends Testable { // addImports(): TestHostBuilder; // addUsing(...names: string[]): TestHostBuilder; - // wrap(fn: (x: string) => string): TestHostBuilder; + wrap(fn: (x: string) => string): Tester; // createHost(): TestHostV2; } -export function createTestHostBuilder( - base: string, - options: { libraries: string[] }, -): TestHostBuilder { - let loaded: Promise | undefined; +export interface TesterInstance extends Testable {} + +export function createTester(base: string, options: { libraries: string[] }): Tester { const fs = createTestFileSystem(); - fs.addTypeSpecFile(".keep", ""); // dummy so it knows / is a directory TODO: better way to do this? + let loaded: Promise | undefined; - return { - compileAndDiagnose, - }; + return createTesterInternal({ + fs: async () => { + await load(); + return fs; + }, + }); function load(): Promise { + fs.addTypeSpecFile(".keep", ""); // dummy so it knows / is a directory TODO: better way to do this? if (loaded) return loaded; loaded = loadInternal(); @@ -116,14 +106,59 @@ export function createTestHostBuilder( } } } +} + +interface TesterInternalParams { + fs: () => Promise; + wraps?: ((code: string) => string)[]; +} +function createTesterInternal(params: TesterInternalParams) { + const testable = createTesterInstance(params); + return { + ...testable, + wrap, + }; + + function wrap(fn: (x: string) => string): Tester { + return createTesterInternal({ + ...params, + wraps: [...(params.wraps ?? []), fn], + }); + } +} + +function createTesterInstance(params: TesterInternalParams): TesterInstance { + return { + compileAndDiagnose, + compile, + diagnose, + }; async function compileAndDiagnose( - main: string, + code: string, options?: TestCompileOptions, ): Promise<[TestCompileResult, readonly Diagnostic[]]> { - await load(); - fs.addTypeSpecFile("main.tsp", main); - const program = await compile(fs.compilerHost, resolveVirtualPath("main.tsp")); + const fs = await params.fs(); + if (params.wraps) { + for (const wrap of params.wraps) { + code = wrap(code); + } + } + fs.addTypeSpecFile("main.tsp", code); + const program = await coreCompile(fs.compilerHost, resolveVirtualPath("main.tsp")); return [{ program, types: {} }, program.diagnostics]; } + + async function compile(code: string, options?: TestCompileOptions): Promise { + const [result, diagnostics] = await compileAndDiagnose(code, options); + expectDiagnosticEmpty(diagnostics); + return result; + } + async function diagnose( + code: string, + options?: TestCompileOptions, + ): Promise { + const [_, diagnostics] = await compileAndDiagnose(code, options); + return diagnostics; + } } diff --git a/packages/openapi/test/test-host.ts b/packages/openapi/test/test-host.ts index 9c6583f036a..440fa5e8be8 100644 --- a/packages/openapi/test/test-host.ts +++ b/packages/openapi/test/test-host.ts @@ -1,14 +1,10 @@ import { resolvePath } from "@typespec/compiler"; -import { - createTestHost, - createTestHostBuilder, - createTestWrapper, -} from "@typespec/compiler/testing"; +import { createTester, createTestHost, createTestWrapper } from "@typespec/compiler/testing"; import { HttpTestLibrary } from "@typespec/http/testing"; import { RestTestLibrary } from "@typespec/rest/testing"; import { OpenAPITestLibrary } from "../src/testing/index.js"; -export const HostBuilder = createTestHostBuilder(resolvePath(import.meta.dirname, ".."), { +export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { libraries: ["@typespec/http", "@typespec/rest", "@typespec/openapi"], }); diff --git a/packages/openapi/test/test-new-host.test.ts b/packages/openapi/test/test-new-host.test.ts index 5a100821c93..210c7b564eb 100644 --- a/packages/openapi/test/test-new-host.test.ts +++ b/packages/openapi/test/test-new-host.test.ts @@ -1,9 +1,9 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { it } from "vitest"; -import { HostBuilder } from "./test-host.js"; +import { Tester } from "./test-host.js"; -it("works", async () => { - const [, diagnostics] = await HostBuilder.compileAndDiagnose(` +it("case 1: pure", async () => { + const [, diagnostics] = await Tester.compileAndDiagnose(` import "@typespec/openapi"; using OpenAPI; @operationId("foo") @@ -15,3 +15,18 @@ it("works", async () => { message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", }); }); + +const ImportWrap = Tester.wrap((c) => `import "@typespec/openapi";${c}`); + +it("case 2: wraps", async () => { + const diagnostics = await ImportWrap.diagnose(` + using OpenAPI; + @operationId("foo") + model Foo {} + `); + + expectDiagnostics(diagnostics, { + code: "decorator-wrong-target", + message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", + }); +}); From 69b55ffebdf1da819bfe286a0b9b601cb81577f4 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 26 Apr 2025 22:05:29 -0700 Subject: [PATCH 03/55] Clone fs --- packages/compiler/src/testing/test-host-v2.ts | 14 ++++++-- packages/compiler/src/testing/test-host.ts | 34 +++++++++++++++++++ packages/compiler/src/testing/types.ts | 6 ++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index eb18c14d43c..0ef4410a65e 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -56,7 +56,6 @@ export function createTester(base: string, options: { libraries: string[] }): Te }); function load(): Promise { - fs.addTypeSpecFile(".keep", ""); // dummy so it knows / is a directory TODO: better way to do this? if (loaded) return loaded; loaded = loadInternal(); @@ -104,6 +103,7 @@ export function createTester(base: string, options: { libraries: string[] }): Te (lib.manifest as any).file.text, ); } + fs.freeze(); } } } @@ -113,7 +113,7 @@ interface TesterInternalParams { wraps?: ((code: string) => string)[]; } function createTesterInternal(params: TesterInternalParams) { - const testable = createTesterInstance(params); + const testable = createInstance(); return { ...testable, wrap, @@ -125,6 +125,16 @@ function createTesterInternal(params: TesterInternalParams) { wraps: [...(params.wraps ?? []), fn], }); } + + function createInstance(): TesterInstance { + return createTesterInstance({ + ...params, + fs: async () => { + const fs = await params.fs(); + return fs.clone(); + }, + }); + } } function createTesterInstance(params: TesterInternalParams): TesterInstance { diff --git a/packages/compiler/src/testing/test-host.ts b/packages/compiler/src/testing/test-host.ts index f1cf06572bd..43d0c8c54bb 100644 --- a/packages/compiler/src/testing/test-host.ts +++ b/packages/compiler/src/testing/test-host.ts @@ -155,7 +155,15 @@ function createTestCompilerHost( export function createTestFileSystem(options?: TestHostOptions): TestFileSystem { const virtualFs = createStringMap(!!options?.caseInsensitiveFileSystem); const jsImports = createStringMap>(!!options?.caseInsensitiveFileSystem); + return createTestFileSystemInternal(virtualFs, jsImports, options); +} +function createTestFileSystemInternal( + virtualFs: Map, + jsImports: Map>, + options?: TestHostOptions, +): TestFileSystem { + let frozen = false; const compilerHost = createTestCompilerHost(virtualFs, jsImports, options); return { addTypeSpecFile, @@ -166,23 +174,37 @@ export function createTestFileSystem(options?: TestHostOptions): TestFileSystem addTypeSpecLibrary, compilerHost, fs: virtualFs, + freeze, + clone, }; + function assertNotFrozen() { + if (frozen) { + throw new Error("Cannot modify the file system after it has been frozen."); + } + } function addTypeSpecFile(path: string, contents: string) { + assertNotFrozen(); virtualFs.set(resolveVirtualPath(path), contents); } function addJsFile(path: string, contents: any) { + assertNotFrozen(); + const key = resolveVirtualPath(path); virtualFs.set(key, ""); // don't need contents jsImports.set(key, new Promise((r) => r(contents))); } async function addRealTypeSpecFile(path: string, existingPath: string) { + assertNotFrozen(); + virtualFs.set(resolveVirtualPath(path), await readFile(existingPath, "utf8")); } async function addRealFolder(folder: string, existingFolder: string) { + assertNotFrozen(); + const entries = await readdir(existingFolder); for (const entry of entries) { const existingPath = join(existingFolder, entry); @@ -202,6 +224,8 @@ export function createTestFileSystem(options?: TestHostOptions): TestFileSystem } async function addRealJsFile(path: string, existingPath: string) { + assertNotFrozen(); + const key = resolveVirtualPath(path); const exports = await import(pathToFileURL(existingPath).href); @@ -210,6 +234,8 @@ export function createTestFileSystem(options?: TestHostOptions): TestFileSystem } async function addTypeSpecLibrary(testLibrary: TypeSpecTestLibrary) { + assertNotFrozen(); + for (const { realDir, pattern, virtualPath } of testLibrary.files) { const lookupDir = resolvePath(testLibrary.packageRoot, realDir); const entries = await findFilesFromPattern(lookupDir, pattern); @@ -230,6 +256,14 @@ export function createTestFileSystem(options?: TestHostOptions): TestFileSystem } } } + + function freeze() { + frozen = true; + } + + function clone() { + return createTestFileSystemInternal(new Map(virtualFs), new Map(jsImports), options); + } } export const StandardTestLibrary: TypeSpecTestLibrary = { diff --git a/packages/compiler/src/testing/types.ts b/packages/compiler/src/testing/types.ts index 0ae7aa45068..60a82386c04 100644 --- a/packages/compiler/src/testing/types.ts +++ b/packages/compiler/src/testing/types.ts @@ -12,6 +12,12 @@ export interface TestFileSystem { addRealJsFile(path: string, realPath: string): Promise; addRealFolder(path: string, realPath: string): Promise; addTypeSpecLibrary(testLibrary: TypeSpecTestLibrary): Promise; + + /** @internal */ + freeze(): void; + + /** @internal */ + clone(): TestFileSystem; } export interface TestHost extends TestFileSystem { From 4452fcd8997c017dc046552ac3249ca86a82c184 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 26 Apr 2025 22:10:37 -0700 Subject: [PATCH 04/55] cleanup --- packages/compiler/src/testing/test-host-v2.ts | 108 +++++++++--------- 1 file changed, 55 insertions(+), 53 deletions(-) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index 0ef4410a65e..8187c1f4411 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -44,68 +44,70 @@ export interface Tester extends Testable { export interface TesterInstance extends Testable {} -export function createTester(base: string, options: { libraries: string[] }): Tester { - const fs = createTestFileSystem(); - let loaded: Promise | undefined; - +export interface TesterOptions { + libraries: string[]; +} +export function createTester(base: string, options: TesterOptions): Tester { return createTesterInternal({ - fs: async () => { - await load(); - return fs; - }, + fs: once(() => createTesterFs(base, options)), }); +} - function load(): Promise { - if (loaded) return loaded; +function once(fn: () => Promise): () => Promise { + let load: Promise | undefined; + return () => { + if (load) return load; + load = fn(); + return load; + }; +} - loaded = loadInternal(); - return loaded; +async function createTesterFs(base: string, options: TesterOptions) { + const fs = createTestFileSystem(); - async function loadInternal() { - const sl = await createSourceLoader({ ...NodeHost, realpath: async (x) => x }); - const selfName = JSON.parse(await readFile(resolvePath(base, "package.json"), "utf8")).name; - for (const lib of options.libraries) { - await sl.importPath(lib, NoTarget, base); - } + const sl = await createSourceLoader({ ...NodeHost, realpath: async (x) => x }); + const selfName = JSON.parse(await readFile(resolvePath(base, "package.json"), "utf8")).name; + for (const lib of options.libraries) { + await sl.importPath(lib, NoTarget, base); + } - await fs.addTypeSpecLibrary(StandardTestLibrary); - fs.addTypeSpecFile(".tsp/test-lib/main.tsp", 'import "./test.js";'); - fs.addJsFile(".tsp/test-lib/test.js", { - namespace: "TypeSpec", - $test(_: any, target: Type) {}, - }); - - function computeVirtualPath(file: SourceFile): string { - const context = sl.resolution.locationContexts.get(file); - compilerAssert( - context?.type === "library", - `Unexpected: all source files should be in a library but ${file.path} was in '${context?.type}'`, - ); - const relativePath = getRelativePathFromDirectory(base, file.path, false); - if (context.metadata.name === selfName) { - return joinPaths("node_modules", selfName, relativePath); - } else { - return relativePath; - } - } + await fs.addTypeSpecLibrary(StandardTestLibrary); + fs.addTypeSpecFile(".tsp/test-lib/main.tsp", 'import "./test.js";'); + fs.addJsFile(".tsp/test-lib/test.js", { + namespace: "TypeSpec", + $test(_: any, target: Type) {}, + }); - for (const file of sl.resolution.sourceFiles.values()) { - const relativePath = computeVirtualPath(file.file); - fs.addTypeSpecFile(resolveVirtualPath(relativePath), file.file.text); - } - for (const file of sl.resolution.jsSourceFiles.values()) { - const relativePath = computeVirtualPath(file.file); - fs.addJsFile(resolveVirtualPath(relativePath), file.esmExports); - } - for (const [path, lib] of sl.resolution.loadedLibraries) { - fs.addTypeSpecFile( - resolvePath("node_modules", path, "package.json"), - (lib.manifest as any).file.text, - ); - } - fs.freeze(); + function computeVirtualPath(file: SourceFile): string { + const context = sl.resolution.locationContexts.get(file); + compilerAssert( + context?.type === "library", + `Unexpected: all source files should be in a library but ${file.path} was in '${context?.type}'`, + ); + const relativePath = getRelativePathFromDirectory(base, file.path, false); + if (context.metadata.name === selfName) { + return joinPaths("node_modules", selfName, relativePath); + } else { + return relativePath; } } + + for (const file of sl.resolution.sourceFiles.values()) { + const relativePath = computeVirtualPath(file.file); + fs.addTypeSpecFile(resolveVirtualPath(relativePath), file.file.text); + } + for (const file of sl.resolution.jsSourceFiles.values()) { + const relativePath = computeVirtualPath(file.file); + fs.addJsFile(resolveVirtualPath(relativePath), file.esmExports); + } + for (const [path, lib] of sl.resolution.loadedLibraries) { + fs.addTypeSpecFile( + resolvePath("node_modules", path, "package.json"), + (lib.manifest as any).file.text, + ); + } + fs.freeze(); + return fs; } interface TesterInternalParams { From 97e51a7b4834f3d07cd9af0c55821c8b09f55875 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sun, 27 Apr 2025 19:20:59 -0700 Subject: [PATCH 05/55] tweaks --- packages/compiler/src/testing/test-host-v2.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index 8187c1f4411..4fae123446f 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -39,7 +39,7 @@ export interface Tester extends Testable { // addImports(): TestHostBuilder; // addUsing(...names: string[]): TestHostBuilder; wrap(fn: (x: string) => string): Tester; - // createHost(): TestHostV2; + createInstance(): TesterInstance; } export interface TesterInstance extends Testable {} @@ -114,11 +114,13 @@ interface TesterInternalParams { fs: () => Promise; wraps?: ((code: string) => string)[]; } -function createTesterInternal(params: TesterInternalParams) { + +function createTesterInternal(params: TesterInternalParams): Tester { const testable = createInstance(); return { ...testable, wrap, + createInstance, }; function wrap(fn: (x: string) => string): Tester { From a534b40508f592f4a311b59156f00848dbd43a59 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sun, 27 Apr 2025 20:20:48 -0700 Subject: [PATCH 06/55] auto import and using --- packages/compiler/src/testing/test-host-v2.ts | 56 ++++++++++++++++--- packages/openapi/test/test-new-host.test.ts | 29 ++++++++++ 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index 4fae123446f..00e32024e11 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -36,8 +36,10 @@ interface Testable { // Immutable structure meant to be reused export interface Tester extends Testable { - // addImports(): TestHostBuilder; - // addUsing(...names: string[]): TestHostBuilder; + /** Auto import all libraries defined in this tester. */ + importLibraries(): Tester; + import(...imports: string[]): Tester; + using(...names: string[]): Tester; wrap(fn: (x: string) => string): Tester; createInstance(): TesterInstance; } @@ -50,6 +52,7 @@ export interface TesterOptions { export function createTester(base: string, options: TesterOptions): Tester { return createTesterInternal({ fs: once(() => createTesterFs(base, options)), + libraries: options.libraries, }); } @@ -112,7 +115,10 @@ async function createTesterFs(base: string, options: TesterOptions) { interface TesterInternalParams { fs: () => Promise; + libraries: string[]; wraps?: ((code: string) => string)[]; + imports?: string[]; + usings?: string[]; } function createTesterInternal(params: TesterInternalParams): Tester { @@ -120,6 +126,9 @@ function createTesterInternal(params: TesterInternalParams): Tester { return { ...testable, wrap, + importLibraries, + import: importFn, + using, createInstance, }; @@ -130,6 +139,27 @@ function createTesterInternal(params: TesterInternalParams): Tester { }); } + function importLibraries(): Tester { + return createTesterInternal({ + ...params, + imports: [...(params.imports ?? []), ...params.libraries], + }); + } + + function importFn(...imports: string[]): Tester { + return createTesterInternal({ + ...params, + imports: [...(params.imports ?? []), ...imports], + }); + } + + function using(...usings: string[]): Tester { + return createTesterInternal({ + ...params, + usings: [...(params.usings ?? []), ...usings], + }); + } + function createInstance(): TesterInstance { return createTesterInstance({ ...params, @@ -148,17 +178,27 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { diagnose, }; + function applyWraps(code: string, wraps: ((code: string) => string)[]): string { + for (const wrap of wraps) { + code = wrap(code); + } + return code; + } async function compileAndDiagnose( code: string, options?: TestCompileOptions, ): Promise<[TestCompileResult, readonly Diagnostic[]]> { const fs = await params.fs(); - if (params.wraps) { - for (const wrap of params.wraps) { - code = wrap(code); - } - } - fs.addTypeSpecFile("main.tsp", code); + + const imports = (params.imports ?? []).map((x) => `import "${x}";`); + const usings = (params.usings ?? []).map((x) => `using ${x};`); + + const actualCode = [ + ...imports, + ...usings, + params.wraps ? applyWraps(code, params.wraps) : code, + ].join("\n"); + fs.addTypeSpecFile("main.tsp", actualCode); const program = await coreCompile(fs.compilerHost, resolveVirtualPath("main.tsp")); return [{ program, types: {} }, program.diagnostics]; } diff --git a/packages/openapi/test/test-new-host.test.ts b/packages/openapi/test/test-new-host.test.ts index 210c7b564eb..6262c3a365d 100644 --- a/packages/openapi/test/test-new-host.test.ts +++ b/packages/openapi/test/test-new-host.test.ts @@ -30,3 +30,32 @@ it("case 2: wraps", async () => { message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", }); }); + +const LibsImported = Tester.importLibraries(); + +it("case 3: auto imports", async () => { + const diagnostics = await LibsImported.diagnose(` + using OpenAPI; + @operationId("foo") + model Foo {} + `); + + expectDiagnostics(diagnostics, { + code: "decorator-wrong-target", + message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", + }); +}); + +const LibsAndUsing = Tester.importLibraries().using("OpenAPI"); + +it("case 4: auto imports and auto using", async () => { + const diagnostics = await LibsAndUsing.diagnose(` + @operationId("foo") + model Foo {} + `); + + expectDiagnostics(diagnostics, { + code: "decorator-wrong-target", + message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", + }); +}); From 48fc2c2c47e9f92d2910894c141a89d32291e8d5 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 28 Apr 2025 08:39:15 -0700 Subject: [PATCH 07/55] . --- packages/compiler/src/testing/test-host-v2.ts | 67 ++++++++-- packages/openapi/test/decorators.test.ts | 4 +- packages/openapi/test/test-host.ts | 30 +---- packages/openapi/test/test-new-host.test.ts | 122 +++++++++--------- 4 files changed, 121 insertions(+), 102 deletions(-) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index 00e32024e11..dcea3af561d 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -5,16 +5,14 @@ import { CompilerOptions } from "../core/options.js"; import { getRelativePathFromDirectory, joinPaths, resolvePath } from "../core/path-utils.js"; import { Program, compile as coreCompile } from "../core/program.js"; import { createSourceLoader } from "../core/source-loader.js"; -import { Diagnostic, NoTarget, SourceFile, Type } from "../core/types.js"; +import { Diagnostic, NoTarget, SourceFile, StringLiteral, Type } from "../core/types.js"; import { expectDiagnosticEmpty } from "./expect.js"; import { StandardTestLibrary, createTestFileSystem } from "./test-host.js"; import { resolveVirtualPath } from "./test-utils.js"; import { TestFileSystem } from "./types.js"; -export interface TestCompileResult { - readonly program: Program; - readonly types: Record; -} +// Need a way to combine that with `program` +export type TestCompileResult = Record; export interface JsFileDef { [key: string]: string | unknown; @@ -44,7 +42,9 @@ export interface Tester extends Testable { createInstance(): TesterInstance; } -export interface TesterInstance extends Testable {} +export interface TesterInstance extends Testable { + readonly program: Program; +} export interface TesterOptions { libraries: string[]; @@ -75,11 +75,6 @@ async function createTesterFs(base: string, options: TesterOptions) { } await fs.addTypeSpecLibrary(StandardTestLibrary); - fs.addTypeSpecFile(".tsp/test-lib/main.tsp", 'import "./test.js";'); - fs.addJsFile(".tsp/test-lib/test.js", { - namespace: "TypeSpec", - $test(_: any, target: Type) {}, - }); function computeVirtualPath(file: SourceFile): string { const context = sl.resolution.locationContexts.get(file); @@ -122,9 +117,11 @@ interface TesterInternalParams { } function createTesterInternal(params: TesterInternalParams): Tester { - const testable = createInstance(); + const { compile, compileAndDiagnose, diagnose } = createInstance(); return { - ...testable, + compile, + compileAndDiagnose, + diagnose, wrap, importLibraries, import: importFn, @@ -172,10 +169,18 @@ function createTesterInternal(params: TesterInternalParams): Tester { } function createTesterInstance(params: TesterInternalParams): TesterInstance { + let savedProgram: Program | undefined; + return { compileAndDiagnose, compile, diagnose, + get program() { + if (!savedProgram) { + throw new Error("Program not initialized. Call compile first."); + } + return savedProgram; + }, }; function applyWraps(code: string, wraps: ((code: string) => string)[]): string { @@ -189,6 +194,7 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { options?: TestCompileOptions, ): Promise<[TestCompileResult, readonly Diagnostic[]]> { const fs = await params.fs(); + const types = await addTestLib(fs); const imports = (params.imports ?? []).map((x) => `import "${x}";`); const usings = (params.usings ?? []).map((x) => `using ${x};`); @@ -200,7 +206,8 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { ].join("\n"); fs.addTypeSpecFile("main.tsp", actualCode); const program = await coreCompile(fs.compilerHost, resolveVirtualPath("main.tsp")); - return [{ program, types: {} }, program.diagnostics]; + savedProgram = program; + return [{ program, ...types } as any, program.diagnostics]; } async function compile(code: string, options?: TestCompileOptions): Promise { @@ -216,3 +223,35 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { return diagnostics; } } + +function addTestLib(fs: TestFileSystem): Record { + const testTypes: Record = {}; + // add test decorators + fs.addTypeSpecFile(".tsp/test-lib/main.tsp", 'import "./test.js";'); + fs.addJsFile(".tsp/test-lib/test.js", { + namespace: "TypeSpec", + $test(_: any, target: Type, nameLiteral?: StringLiteral) { + let name = nameLiteral?.value; + if (!name) { + if ( + target.kind === "Model" || + target.kind === "Scalar" || + target.kind === "Namespace" || + target.kind === "Enum" || + target.kind === "Operation" || + target.kind === "ModelProperty" || + target.kind === "EnumMember" || + target.kind === "Interface" || + (target.kind === "Union" && !target.expression) + ) { + name = target.name!; + } else { + throw new Error("Need to specify a name for test type"); + } + } + + testTypes[name] = target; + }, + }); + return testTypes; +} diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 6841ebd6611..4927335e6e7 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -1,5 +1,5 @@ import { Namespace } from "@typespec/compiler"; -import { BasicTestRunner, expectDiagnostics } from "@typespec/compiler/testing"; +import { expectDiagnostics, TesterInstance } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; import { beforeEach, describe, it } from "vitest"; import { @@ -13,7 +13,7 @@ import { import { createOpenAPITestRunner } from "./test-host.js"; describe("openapi: decorators", () => { - let runner: BasicTestRunner; + let runner: TesterInstance; beforeEach(async () => { runner = await createOpenAPITestRunner(); diff --git a/packages/openapi/test/test-host.ts b/packages/openapi/test/test-host.ts index 440fa5e8be8..197cc6cbf26 100644 --- a/packages/openapi/test/test-host.ts +++ b/packages/openapi/test/test-host.ts @@ -1,32 +1,12 @@ import { resolvePath } from "@typespec/compiler"; -import { createTester, createTestHost, createTestWrapper } from "@typespec/compiler/testing"; -import { HttpTestLibrary } from "@typespec/http/testing"; -import { RestTestLibrary } from "@typespec/rest/testing"; -import { OpenAPITestLibrary } from "../src/testing/index.js"; +import { createTester } from "@typespec/compiler/testing"; export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { libraries: ["@typespec/http", "@typespec/rest", "@typespec/openapi"], -}); +}) + .importLibraries() + .using("OpenAPI"); -export async function createOpenAPITestHost() { - return createTestHost({ - libraries: [HttpTestLibrary, RestTestLibrary, OpenAPITestLibrary], - }); -} export async function createOpenAPITestRunner() { - const host = await createOpenAPITestHost(); - return createTestWrapper(host, { autoUsings: ["TypeSpec.OpenAPI"] }); -} - -export async function createOpenAPITestRunnerWithDecorators(decorators: Record) { - const host = await createOpenAPITestHost(); - host.addJsFile("dec.js", decorators); - return createTestWrapper(host, { - wrapper(code) { - return ` - import "./dec.js"; - using OpenAPI; - ${code}`; - }, - }); + return Tester.createInstance(); } diff --git a/packages/openapi/test/test-new-host.test.ts b/packages/openapi/test/test-new-host.test.ts index 6262c3a365d..da623bd6780 100644 --- a/packages/openapi/test/test-new-host.test.ts +++ b/packages/openapi/test/test-new-host.test.ts @@ -1,61 +1,61 @@ -import { expectDiagnostics } from "@typespec/compiler/testing"; -import { it } from "vitest"; -import { Tester } from "./test-host.js"; - -it("case 1: pure", async () => { - const [, diagnostics] = await Tester.compileAndDiagnose(` - import "@typespec/openapi"; - using OpenAPI; - @operationId("foo") - model Foo {} - `); - - expectDiagnostics(diagnostics, { - code: "decorator-wrong-target", - message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", - }); -}); - -const ImportWrap = Tester.wrap((c) => `import "@typespec/openapi";${c}`); - -it("case 2: wraps", async () => { - const diagnostics = await ImportWrap.diagnose(` - using OpenAPI; - @operationId("foo") - model Foo {} - `); - - expectDiagnostics(diagnostics, { - code: "decorator-wrong-target", - message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", - }); -}); - -const LibsImported = Tester.importLibraries(); - -it("case 3: auto imports", async () => { - const diagnostics = await LibsImported.diagnose(` - using OpenAPI; - @operationId("foo") - model Foo {} - `); - - expectDiagnostics(diagnostics, { - code: "decorator-wrong-target", - message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", - }); -}); - -const LibsAndUsing = Tester.importLibraries().using("OpenAPI"); - -it("case 4: auto imports and auto using", async () => { - const diagnostics = await LibsAndUsing.diagnose(` - @operationId("foo") - model Foo {} - `); - - expectDiagnostics(diagnostics, { - code: "decorator-wrong-target", - message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", - }); -}); +// import { expectDiagnostics } from "@typespec/compiler/testing"; +// import { it } from "vitest"; +// import { Tester } from "./test-host.js"; + +// it("case 1: pure", async () => { +// const [, diagnostics] = await Tester.compileAndDiagnose(` +// import "@typespec/openapi"; +// using OpenAPI; +// @operationId("foo") +// model Foo {} +// `); + +// expectDiagnostics(diagnostics, { +// code: "decorator-wrong-target", +// message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", +// }); +// }); + +// const ImportWrap = Tester.wrap((c) => `import "@typespec/openapi";${c}`); + +// it("case 2: wraps", async () => { +// const diagnostics = await ImportWrap.diagnose(` +// using OpenAPI; +// @operationId("foo") +// model Foo {} +// `); + +// expectDiagnostics(diagnostics, { +// code: "decorator-wrong-target", +// message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", +// }); +// }); + +// const LibsImported = Tester.importLibraries(); + +// it("case 3: auto imports", async () => { +// const diagnostics = await LibsImported.diagnose(` +// using OpenAPI; +// @operationId("foo") +// model Foo {} +// `); + +// expectDiagnostics(diagnostics, { +// code: "decorator-wrong-target", +// message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", +// }); +// }); + +// const LibsAndUsing = Tester.importLibraries().using("OpenAPI"); + +// it("case 4: auto imports and auto using", async () => { +// const diagnostics = await LibsAndUsing.diagnose(` +// @operationId("foo") +// model Foo {} +// `); + +// expectDiagnostics(diagnostics, { +// code: "decorator-wrong-target", +// message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", +// }); +// }); From 8242a64a79fa33f65993113795a522ecea227c4c Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 28 Apr 2025 08:47:58 -0700 Subject: [PATCH 08/55] Simplify --- packages/openapi/src/testing/index.ts | 1 + packages/openapi/test/decorators.test.ts | 6 +++--- packages/openapi/test/test-host.ts | 4 ---- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/openapi/src/testing/index.ts b/packages/openapi/src/testing/index.ts index 0659d03b35f..706829790f7 100644 --- a/packages/openapi/src/testing/index.ts +++ b/packages/openapi/src/testing/index.ts @@ -4,6 +4,7 @@ import { TypeSpecTestLibrary, } from "@typespec/compiler/testing"; +/** @deprecated use new Tester */ export const OpenAPITestLibrary: TypeSpecTestLibrary = createTestLibrary({ name: "@typespec/openapi", packageRoot: await findTestPackageRoot(import.meta.url), diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 4927335e6e7..5cea34d5394 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -10,13 +10,13 @@ import { resolveInfo, setInfo, } from "../src/decorators.js"; -import { createOpenAPITestRunner } from "./test-host.js"; +import { Tester } from "./test-host.js"; describe("openapi: decorators", () => { let runner: TesterInstance; beforeEach(async () => { - runner = await createOpenAPITestRunner(); + runner = Tester.createInstance(); }); describe("@operationId", () => { @@ -543,7 +543,7 @@ describe("openapi: decorators", () => { ], ]; it.each(testCases)("%s", async (_, tagMetaDecorator, expected) => { - const runner = await createOpenAPITestRunner(); + const runner = Tester.createInstance(); const { PetStore } = await runner.compile( ` @service() diff --git a/packages/openapi/test/test-host.ts b/packages/openapi/test/test-host.ts index 197cc6cbf26..521899c6e4e 100644 --- a/packages/openapi/test/test-host.ts +++ b/packages/openapi/test/test-host.ts @@ -6,7 +6,3 @@ export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { }) .importLibraries() .using("OpenAPI"); - -export async function createOpenAPITestRunner() { - return Tester.createInstance(); -} From 4af3ea09d073f0f2d5759e085f39934371784c1f Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 29 Apr 2025 08:34:43 -0700 Subject: [PATCH 09/55] marked template v1 --- .../compiler/src/testing/marked-template.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 packages/compiler/src/testing/marked-template.ts diff --git a/packages/compiler/src/testing/marked-template.ts b/packages/compiler/src/testing/marked-template.ts new file mode 100644 index 00000000000..c69ed0c9c1f --- /dev/null +++ b/packages/compiler/src/testing/marked-template.ts @@ -0,0 +1,81 @@ +import { + Enum, + EnumMember, + Interface, + Model, + ModelProperty, + Operation, + Type, + Union, +} from "../core/types.js"; + +export interface Marker { + kind: T["kind"]; + name: N; +} + +function marker(kind: T["kind"]) { + return (name: N): Marker => { + return { + kind, + name, + }; + }; +} + +export const m = { + model: marker("Model"), + enum: marker("Enum"), + union: marker("Union"), + interface: marker("Interface"), + op: marker("Operation"), + enumMember: marker("EnumMember"), + modelProperty: marker("ModelProperty"), +}; + +export type MarkerConfig> = { + [K in keyof T]: T[K]["kind"]; +}; + +export interface TemplateWithMarkers> { + readonly code: string; + readonly markers: MarkerConfig; +} + +type Prettify = { + [K in keyof T]: T[K]; +} & {}; + +type PickMarkers | string)[]> = T extends (infer U)[] + ? U extends Marker + ? { [key in N]: K } + : never + : never; + +type UnionToIntersection = (U extends any ? (x: U) => void : never) extends (x: infer I) => void + ? I + : never; + +/** Specify that this value is dynamic and needs to be interpolated with the given keys */ +export function extract | string)[]>( + strings: TemplateStringsArray, + ...keys: T +): TemplateWithMarkers>> & Record> { + const markers: Marker[] = []; + const result: string[] = [strings[0]]; + keys.forEach((key, i) => { + if (typeof key === "string") { + result.push(key); + } else { + result.push(key.name); + markers.push(key as Marker); + } + result.push(strings[i + 1]); + }); + return { + code: result.join(""), + markers: markers as any, + }; +} + +const a = extract`foo ${m.model("bar")} ${"regular"} ${m.enum("def")}`; From 5d22b31d5a9b372bb7b583641e39e6d89b3d100e Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 29 Apr 2025 10:44:29 -0700 Subject: [PATCH 10/55] Simpler? --- .../compiler/src/testing/marked-template.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/compiler/src/testing/marked-template.ts b/packages/compiler/src/testing/marked-template.ts index c69ed0c9c1f..96d47f8c16c 100644 --- a/packages/compiler/src/testing/marked-template.ts +++ b/packages/compiler/src/testing/marked-template.ts @@ -45,22 +45,15 @@ export interface TemplateWithMarkers> { type Prettify = { [K in keyof T]: T[K]; } & {}; - -type PickMarkers | string)[]> = T extends (infer U)[] - ? U extends Marker - ? { [key in N]: K } - : never - : never; - -type UnionToIntersection = (U extends any ? (x: U) => void : never) extends (x: infer I) => void - ? I - : never; - +type InferType = T extends Marker ? K : never; +type CollectType | string>> = { + [K in T[number] as K extends Marker ? N : never]: InferType; +}; /** Specify that this value is dynamic and needs to be interpolated with the given keys */ export function extract | string)[]>( strings: TemplateStringsArray, ...keys: T -): TemplateWithMarkers>> & Record> { +): TemplateWithMarkers> & Record> { const markers: Marker[] = []; const result: string[] = [strings[0]]; keys.forEach((key, i) => { From 219a2d9bd8f0883a290d0170a4cb1ba3e9a3e507 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 09:50:10 -0700 Subject: [PATCH 11/55] connect marker --- .../compiler/src/testing/marked-template.ts | 24 ++++- packages/compiler/src/testing/test-host-v2.ts | 57 +++++++++-- .../test/testing/test-host-v2.test.ts | 94 +++++++++++++++++++ packages/openapi/test/test-new-host.test.ts | 61 ------------ 4 files changed, 162 insertions(+), 74 deletions(-) create mode 100644 packages/compiler/test/testing/test-host-v2.test.ts delete mode 100644 packages/openapi/test/test-new-host.test.ts diff --git a/packages/compiler/src/testing/marked-template.ts b/packages/compiler/src/testing/marked-template.ts index 96d47f8c16c..37e8f1384fd 100644 --- a/packages/compiler/src/testing/marked-template.ts +++ b/packages/compiler/src/testing/marked-template.ts @@ -4,6 +4,7 @@ import { Interface, Model, ModelProperty, + Namespace, Operation, Type, Union, @@ -31,10 +32,20 @@ export const m = { op: marker("Operation"), enumMember: marker("EnumMember"), modelProperty: marker("ModelProperty"), + namespace: marker("Namespace"), + scalar: marker("Scalar"), + unionVariant: marker("UnionVariant"), + boolean: marker("Boolean"), + number: marker("Number"), + string: marker("String"), }; export type MarkerConfig> = { - [K in keyof T]: T[K]["kind"]; + [K in keyof T]: { + pos: number; + end: number; + kind: T[K]["kind"]; + }; }; export interface TemplateWithMarkers> { @@ -54,14 +65,21 @@ export function extract | string)[]>( strings: TemplateStringsArray, ...keys: T ): TemplateWithMarkers> & Record> { - const markers: Marker[] = []; + const markers: MarkerConfig = {}; const result: string[] = [strings[0]]; + let pos = strings[0].length; keys.forEach((key, i) => { if (typeof key === "string") { result.push(key); + pos += key.length; } else { result.push(key.name); - markers.push(key as Marker); + markers[key.name] = { + pos, + end: pos + key.name.length, + kind: key.kind, + }; + pos += key.name.length; } result.push(strings[i + 1]); }); diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index dcea3af561d..54abc2e7207 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -1,18 +1,22 @@ import { readFile } from "fs/promises"; +import { getSymNode } from "../core/binder.js"; import { compilerAssert } from "../core/diagnostics.js"; +import { getTypeName } from "../core/helpers/type-name-utils.js"; import { NodeHost } from "../core/node-host.js"; import { CompilerOptions } from "../core/options.js"; +import { getNodeAtPosition } from "../core/parser.js"; import { getRelativePathFromDirectory, joinPaths, resolvePath } from "../core/path-utils.js"; import { Program, compile as coreCompile } from "../core/program.js"; import { createSourceLoader } from "../core/source-loader.js"; import { Diagnostic, NoTarget, SourceFile, StringLiteral, Type } from "../core/types.js"; import { expectDiagnosticEmpty } from "./expect.js"; +import { TemplateWithMarkers } from "./marked-template.js"; import { StandardTestLibrary, createTestFileSystem } from "./test-host.js"; import { resolveVirtualPath } from "./test-utils.js"; import { TestFileSystem } from "./types.js"; // Need a way to combine that with `program` -export type TestCompileResult = Record; +export type TestCompileResult> = T; export interface JsFileDef { [key: string]: string | unknown; @@ -24,12 +28,15 @@ interface TestCompileOptions { } interface Testable { - compile(main: string, options?: TestCompileOptions): Promise; + compile>( + main: string | TemplateWithMarkers, + options?: TestCompileOptions, + ): Promise>; diagnose(main: string, options?: TestCompileOptions): Promise; - compileAndDiagnose( - main: string, + compileAndDiagnose>( + main: string | TemplateWithMarkers, options?: TestCompileOptions, - ): Promise<[TestCompileResult, readonly Diagnostic[]]>; + ): Promise<[TestCompileResult, readonly Diagnostic[]]>; } // Immutable structure meant to be reused @@ -189,28 +196,58 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { } return code; } - async function compileAndDiagnose( - code: string, + async function compileAndDiagnose>( + code: string | TemplateWithMarkers, options?: TestCompileOptions, - ): Promise<[TestCompileResult, readonly Diagnostic[]]> { + ): Promise<[TestCompileResult, readonly Diagnostic[]]> { const fs = await params.fs(); const types = await addTestLib(fs); const imports = (params.imports ?? []).map((x) => `import "${x}";`); const usings = (params.usings ?? []).map((x) => `using ${x};`); + // Support TemplateWithMarkers + const codeStr = typeof code === "string" ? code : code.code; const actualCode = [ ...imports, ...usings, - params.wraps ? applyWraps(code, params.wraps) : code, + params.wraps ? applyWraps(codeStr, params.wraps) : codeStr, ].join("\n"); fs.addTypeSpecFile("main.tsp", actualCode); const program = await coreCompile(fs.compilerHost, resolveVirtualPath("main.tsp")); savedProgram = program; + + if (typeof code !== "string") { + const file = program.sourceFiles.get(resolveVirtualPath("main.tsp")); + if (!file) { + throw new Error(`Couldn't find main.tsp in program`); + } + for (const marker of Object.entries(code.markers)) { + const [name, { pos, end, kind }] = marker; + const node = getNodeAtPosition(file, pos); + if (!node) { + throw new Error(`Could not find node at ${pos}-${end}`); + } + const sym = program.checker.resolveRelatedSymbols(node as any)?.[0]; + if (sym === undefined) { + throw new Error(`Could not find symbol for ${name} at ${pos}-${end}`); + } + const type = program.checker.getTypeForNode(getSymNode(sym)); + if (type.kind !== kind) { + throw new Error( + `Expected ${name} to be of kind ${kind} but got (${type.kind}) ${getTypeName(type)} at ${pos}-${end}`, + ); + } + types[name] = type; + } + } return [{ program, ...types } as any, program.diagnostics]; } - async function compile(code: string, options?: TestCompileOptions): Promise { + async function compile>( + code: string | TemplateWithMarkers, + options?: TestCompileOptions, + ): Promise> { const [result, diagnostics] = await compileAndDiagnose(code, options); expectDiagnosticEmpty(diagnostics); return result; diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/test-host-v2.test.ts new file mode 100644 index 00000000000..6395951ab0c --- /dev/null +++ b/packages/compiler/test/testing/test-host-v2.test.ts @@ -0,0 +1,94 @@ +// TODO: rename? + +import { describe, expect, it } from "vitest"; +import { resolvePath } from "../../src/core/path-utils.js"; +import { extract, m } from "../../src/testing/marked-template.js"; +import { createTester } from "../../src/testing/test-host-v2.js"; + +const Tester = createTester(resolvePath(import.meta.dirname, "../.."), { libraries: [] }); + +describe("extract types", () => { + it("model", async () => { + const foo = await Tester.compile(extract` + model ${m.model("Foo")} {} + `); + expect(foo.Foo.kind).toBe("Model"); + }); + + it("enum", async () => { + const foo = await Tester.compile(extract` + enum ${m.enum("Foo")} {} + `); + expect(foo.Foo.kind).toBe("Enum"); + }); + + it("union", async () => { + const foo = await Tester.compile(extract` + union ${m.union("Foo")} {} + `); + expect(foo.Foo.kind).toBe("Union"); + }); + + it("interface", async () => { + const foo = await Tester.compile(extract` + interface ${m.interface("Foo")} {} + `); + expect(foo.Foo.kind).toBe("Interface"); + }); + + it("operation", async () => { + const foo = await Tester.compile(extract` + op ${m.op("Foo")}(): void; + `); + expect(foo.Foo.kind).toBe("Operation"); + }); + + it("namespace", async () => { + const foo = await Tester.compile(extract` + namespace ${m.namespace("Foo")} {} + `); + expect(foo.Foo.kind).toBe("Namespace"); + }); + + it("scalar", async () => { + const foo = await Tester.compile(extract` + scalar ${m.scalar("Foo")}; + `); + expect(foo.Foo.kind).toBe("Scalar"); + }); + + it("model property", async () => { + const foo = await Tester.compile(extract` + model Bar { + ${m.modelProperty("prop")}: string; + } + `); + expect(foo.prop.kind).toBe("ModelProperty"); + }); + + it("union variant", async () => { + const foo = await Tester.compile(extract` + union Bar { + ${m.unionVariant("A")}: string; + } + `); + expect(foo.A.kind).toBe("UnionVariant"); + }); + + it("enum member", async () => { + const foo = await Tester.compile(extract` + enum Bar { + ${m.enumMember("A")} + } + `); + expect(foo.A.kind).toBe("EnumMember"); + }); +}); + +it("validate type match", async () => { + await expect(() => + Tester.compile(extract` + enum ${m.model("Foo")} {} + `), + ).rejects.toThrowError("Expected Foo to be of kind Model but got (Enum) Foo at 10-13"); +}); diff --git a/packages/openapi/test/test-new-host.test.ts b/packages/openapi/test/test-new-host.test.ts deleted file mode 100644 index da623bd6780..00000000000 --- a/packages/openapi/test/test-new-host.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -// import { expectDiagnostics } from "@typespec/compiler/testing"; -// import { it } from "vitest"; -// import { Tester } from "./test-host.js"; - -// it("case 1: pure", async () => { -// const [, diagnostics] = await Tester.compileAndDiagnose(` -// import "@typespec/openapi"; -// using OpenAPI; -// @operationId("foo") -// model Foo {} -// `); - -// expectDiagnostics(diagnostics, { -// code: "decorator-wrong-target", -// message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", -// }); -// }); - -// const ImportWrap = Tester.wrap((c) => `import "@typespec/openapi";${c}`); - -// it("case 2: wraps", async () => { -// const diagnostics = await ImportWrap.diagnose(` -// using OpenAPI; -// @operationId("foo") -// model Foo {} -// `); - -// expectDiagnostics(diagnostics, { -// code: "decorator-wrong-target", -// message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", -// }); -// }); - -// const LibsImported = Tester.importLibraries(); - -// it("case 3: auto imports", async () => { -// const diagnostics = await LibsImported.diagnose(` -// using OpenAPI; -// @operationId("foo") -// model Foo {} -// `); - -// expectDiagnostics(diagnostics, { -// code: "decorator-wrong-target", -// message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", -// }); -// }); - -// const LibsAndUsing = Tester.importLibraries().using("OpenAPI"); - -// it("case 4: auto imports and auto using", async () => { -// const diagnostics = await LibsAndUsing.diagnose(` -// @operationId("foo") -// model Foo {} -// `); - -// expectDiagnostics(diagnostics, { -// code: "decorator-wrong-target", -// message: "Cannot apply @operationId decorator to Foo since it is not assignable to Operation", -// }); -// }); From a6885a4b2dc6e12f68f29d14b549078a8239d1c0 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 10:10:20 -0700 Subject: [PATCH 12/55] works with values --- .../compiler/src/testing/marked-template.ts | 85 +++++++++++++------ packages/compiler/src/testing/test-host-v2.ts | 40 ++++++--- .../test/testing/test-host-v2.test.ts | 70 ++++++++++----- 3 files changed, 134 insertions(+), 61 deletions(-) diff --git a/packages/compiler/src/testing/marked-template.ts b/packages/compiler/src/testing/marked-template.ts index 37e8f1384fd..457508fc331 100644 --- a/packages/compiler/src/testing/marked-template.ts +++ b/packages/compiler/src/testing/marked-template.ts @@ -1,4 +1,5 @@ import { + Entity, Enum, EnumMember, Interface, @@ -8,47 +9,78 @@ import { Operation, Type, Union, + Value, } from "../core/types.js"; -export interface Marker { - kind: T["kind"]; +export type Marker = T extends Type + ? TypeMarker + : T extends Value + ? ValueMarker + : never; + +export interface TypeMarker { + entityKind: "Type"; + kind?: T["kind"]; + name: N; +} + +export interface ValueMarker { + entityKind: "Value"; + valueKind?: T["valueKind"]; name: N; } -function marker(kind: T["kind"]) { - return (name: N): Marker => { +function typeMarker(kind?: T["kind"]) { + return (name: N): TypeMarker => { return { + entityKind: "Type", kind, name, }; }; } +function valueMarker(valueKind?: T["valueKind"]) { + return (name: N): ValueMarker => { + return { + entityKind: "Value", + valueKind, + name, + }; + }; +} + export const m = { - model: marker("Model"), - enum: marker("Enum"), - union: marker("Union"), - interface: marker("Interface"), - op: marker("Operation"), - enumMember: marker("EnumMember"), - modelProperty: marker("ModelProperty"), - namespace: marker("Namespace"), - scalar: marker("Scalar"), - unionVariant: marker("UnionVariant"), - boolean: marker("Boolean"), - number: marker("Number"), - string: marker("String"), + // Types + type: typeMarker(), + model: typeMarker("Model"), + enum: typeMarker("Enum"), + union: typeMarker("Union"), + interface: typeMarker("Interface"), + op: typeMarker("Operation"), + enumMember: typeMarker("EnumMember"), + modelProperty: typeMarker("ModelProperty"), + namespace: typeMarker("Namespace"), + scalar: typeMarker("Scalar"), + unionVariant: typeMarker("UnionVariant"), + boolean: typeMarker("Boolean"), + number: typeMarker("Number"), + string: typeMarker("String"), + + // Values + value: valueMarker(), + object: valueMarker("ObjectValue"), + array: valueMarker("ArrayValue"), }; -export type MarkerConfig> = { +export type MarkerConfig> = { [K in keyof T]: { pos: number; end: number; - kind: T[K]["kind"]; - }; + } & Marker; }; -export interface TemplateWithMarkers> { +export interface TemplateWithMarkers> { readonly code: string; readonly markers: MarkerConfig; } @@ -57,14 +89,14 @@ type Prettify = { [K in keyof T]: T[K]; } & {}; type InferType = T extends Marker ? K : never; -type CollectType | string>> = { +type CollectType | string>> = { [K in T[number] as K extends Marker ? N : never]: InferType; }; /** Specify that this value is dynamic and needs to be interpolated with the given keys */ -export function extract | string)[]>( +export function extract | string)[]>( strings: TemplateStringsArray, ...keys: T -): TemplateWithMarkers> & Record> { +): TemplateWithMarkers> & Record> { const markers: MarkerConfig = {}; const result: string[] = [strings[0]]; let pos = strings[0].length; @@ -77,7 +109,10 @@ export function extract | string)[]>( markers[key.name] = { pos, end: pos + key.name.length, - kind: key.kind, + entityKind: key.entityKind, + name: key.name, + kind: (key as any).kind, + valueKind: (key as any).valueKind, }; pos += key.name.length; } diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index 54abc2e7207..d42df8e2206 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -1,14 +1,14 @@ import { readFile } from "fs/promises"; import { getSymNode } from "../core/binder.js"; import { compilerAssert } from "../core/diagnostics.js"; -import { getTypeName } from "../core/helpers/type-name-utils.js"; +import { getEntityName } from "../core/helpers/type-name-utils.js"; import { NodeHost } from "../core/node-host.js"; import { CompilerOptions } from "../core/options.js"; import { getNodeAtPosition } from "../core/parser.js"; import { getRelativePathFromDirectory, joinPaths, resolvePath } from "../core/path-utils.js"; import { Program, compile as coreCompile } from "../core/program.js"; import { createSourceLoader } from "../core/source-loader.js"; -import { Diagnostic, NoTarget, SourceFile, StringLiteral, Type } from "../core/types.js"; +import { Diagnostic, Entity, NoTarget, SourceFile, StringLiteral, Type } from "../core/types.js"; import { expectDiagnosticEmpty } from "./expect.js"; import { TemplateWithMarkers } from "./marked-template.js"; import { StandardTestLibrary, createTestFileSystem } from "./test-host.js"; @@ -16,7 +16,7 @@ import { resolveVirtualPath } from "./test-utils.js"; import { TestFileSystem } from "./types.js"; // Need a way to combine that with `program` -export type TestCompileResult> = T; +export type TestCompileResult> = T; export interface JsFileDef { [key: string]: string | unknown; @@ -28,12 +28,12 @@ interface TestCompileOptions { } interface Testable { - compile>( + compile>( main: string | TemplateWithMarkers, options?: TestCompileOptions, ): Promise>; diagnose(main: string, options?: TestCompileOptions): Promise; - compileAndDiagnose>( + compileAndDiagnose>( main: string | TemplateWithMarkers, options?: TestCompileOptions, ): Promise<[TestCompileResult, readonly Diagnostic[]]>; @@ -196,7 +196,7 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { } return code; } - async function compileAndDiagnose>( + async function compileAndDiagnose>( code: string | TemplateWithMarkers, options?: TestCompileOptions, ): Promise<[TestCompileResult, readonly Diagnostic[]]> { @@ -222,8 +222,8 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { if (!file) { throw new Error(`Couldn't find main.tsp in program`); } - for (const marker of Object.entries(code.markers)) { - const [name, { pos, end, kind }] = marker; + for (const marker of Object.values(code.markers)) { + const { pos, end, name, kind, entityKind, valueKind } = marker; const node = getNodeAtPosition(file, pos); if (!node) { throw new Error(`Could not find node at ${pos}-${end}`); @@ -232,19 +232,33 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { if (sym === undefined) { throw new Error(`Could not find symbol for ${name} at ${pos}-${end}`); } - const type = program.checker.getTypeForNode(getSymNode(sym)); - if (type.kind !== kind) { + const entity = program.checker.getTypeOrValueForNode(getSymNode(sym)); + if (entity === null) { throw new Error( - `Expected ${name} to be of kind ${kind} but got (${type.kind}) ${getTypeName(type)} at ${pos}-${end}`, + `Expected ${name} to be of entity kind ${entityKind} but got null (Means a value failed to resolve) at ${pos}-${end}`, ); } - types[name] = type; + if (entity.entityKind !== entityKind) { + throw new Error( + `Expected ${name} to be of entity kind ${entityKind} but got (${entity?.entityKind}) ${getEntityName(entity)} at ${pos}-${end}`, + ); + } + if (entity.entityKind === "Type" && entity.kind !== kind) { + throw new Error( + `Expected ${name} to be of kind ${kind} but got (${entity.kind}) ${getEntityName(entity)} at ${pos}-${end}`, + ); + } else if (entity?.entityKind === "Value" && entity.valueKind !== valueKind) { + throw new Error( + `Expected ${name} to be of value kind ${valueKind} but got (${entity.valueKind}) ${getEntityName(entity)} at ${pos}-${end}`, + ); + } + (types as any)[name] = entity; } } return [{ program, ...types } as any, program.diagnostics]; } - async function compile>( + async function compile>( code: string | TemplateWithMarkers, options?: TestCompileOptions, ): Promise> { diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/test-host-v2.test.ts index 6395951ab0c..b25d7843c40 100644 --- a/packages/compiler/test/testing/test-host-v2.test.ts +++ b/packages/compiler/test/testing/test-host-v2.test.ts @@ -10,81 +10,105 @@ const Tester = createTester(resolvePath(import.meta.dirname, "../.."), { librari describe("extract types", () => { it("model", async () => { const foo = await Tester.compile(extract` - model ${m.model("Foo")} {} - `); + model ${m.model("Foo")} {} + `); expect(foo.Foo.kind).toBe("Model"); }); + it("alias", async () => { + const foo = await Tester.compile(extract` + model Foo {} + alias ${m.model("Bar")} = Foo; + `); + expect(foo.Bar.kind).toBe("Model"); + }); + it("enum", async () => { const foo = await Tester.compile(extract` - enum ${m.enum("Foo")} {} - `); + enum ${m.enum("Foo")} {} + `); expect(foo.Foo.kind).toBe("Enum"); }); it("union", async () => { const foo = await Tester.compile(extract` - union ${m.union("Foo")} {} - `); + union ${m.union("Foo")} {} + `); expect(foo.Foo.kind).toBe("Union"); }); it("interface", async () => { const foo = await Tester.compile(extract` - interface ${m.interface("Foo")} {} - `); + interface ${m.interface("Foo")} {} + `); expect(foo.Foo.kind).toBe("Interface"); }); it("operation", async () => { const foo = await Tester.compile(extract` - op ${m.op("Foo")}(): void; - `); + op ${m.op("Foo")}(): void; + `); expect(foo.Foo.kind).toBe("Operation"); }); it("namespace", async () => { const foo = await Tester.compile(extract` - namespace ${m.namespace("Foo")} {} - `); + namespace ${m.namespace("Foo")} {} + `); expect(foo.Foo.kind).toBe("Namespace"); }); it("scalar", async () => { const foo = await Tester.compile(extract` - scalar ${m.scalar("Foo")}; - `); + scalar ${m.scalar("Foo")}; + `); expect(foo.Foo.kind).toBe("Scalar"); }); it("model property", async () => { const foo = await Tester.compile(extract` - model Bar { - ${m.modelProperty("prop")}: string; - } + model Bar { + ${m.modelProperty("prop")}: string; + } `); expect(foo.prop.kind).toBe("ModelProperty"); }); it("union variant", async () => { const foo = await Tester.compile(extract` - union Bar { - ${m.unionVariant("A")}: string; - } + union Bar { + ${m.unionVariant("A")}: string; + } `); expect(foo.A.kind).toBe("UnionVariant"); }); it("enum member", async () => { const foo = await Tester.compile(extract` - enum Bar { - ${m.enumMember("A")} - } + enum Bar { + ${m.enumMember("A")} + } `); expect(foo.A.kind).toBe("EnumMember"); }); }); +describe("extract values", () => { + it("object", async () => { + const foo = await Tester.compile(extract` + const ${m.object("foo")} = #{}; + `); + expect(foo.foo.valueKind).toBe("ObjectValue"); + }); + + it("array", async () => { + const foo = await Tester.compile(extract` + const ${m.array("foo")} = #[]; + `); + expect(foo.foo.valueKind).toBe("ArrayValue"); + }); +}); + it("validate type match", async () => { await expect(() => Tester.compile(extract` From dcf71e281aba49bde48d1c2fbb58eceb38219a68 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 10:49:02 -0700 Subject: [PATCH 13/55] Better --- packages/compiler/src/testing/index.ts | 1 + .../compiler/src/testing/marked-template.ts | 70 +++++----- packages/compiler/src/testing/test-host-v2.ts | 71 ++++++++--- .../test/testing/test-host-v2.test.ts | 120 +++++++++++------- 4 files changed, 162 insertions(+), 100 deletions(-) diff --git a/packages/compiler/src/testing/index.ts b/packages/compiler/src/testing/index.ts index d8ae912cc6c..be55847e5c2 100644 --- a/packages/compiler/src/testing/index.ts +++ b/packages/compiler/src/testing/index.ts @@ -36,4 +36,5 @@ export type { } from "./types.js"; // TODO: use named imports +export { t } from "./marked-template.js"; export * from "./test-host-v2.js"; diff --git a/packages/compiler/src/testing/marked-template.ts b/packages/compiler/src/testing/marked-template.ts index 457508fc331..e97b6d7ef03 100644 --- a/packages/compiler/src/testing/marked-template.ts +++ b/packages/compiler/src/testing/marked-template.ts @@ -50,34 +50,8 @@ function valueMarker(valueKind?: T["valueKind"]) { }; } -export const m = { - // Types - type: typeMarker(), - model: typeMarker("Model"), - enum: typeMarker("Enum"), - union: typeMarker("Union"), - interface: typeMarker("Interface"), - op: typeMarker("Operation"), - enumMember: typeMarker("EnumMember"), - modelProperty: typeMarker("ModelProperty"), - namespace: typeMarker("Namespace"), - scalar: typeMarker("Scalar"), - unionVariant: typeMarker("UnionVariant"), - boolean: typeMarker("Boolean"), - number: typeMarker("Number"), - string: typeMarker("String"), - - // Values - value: valueMarker(), - object: valueMarker("ObjectValue"), - array: valueMarker("ArrayValue"), -}; - export type MarkerConfig> = { - [K in keyof T]: { - pos: number; - end: number; - } & Marker; + [K in keyof T]: Marker; }; export interface TemplateWithMarkers> { @@ -85,36 +59,32 @@ export interface TemplateWithMarkers> { readonly markers: MarkerConfig; } -type Prettify = { - [K in keyof T]: T[K]; +type Prettify> = { + [K in keyof T]: T[K] & Entity; } & {}; + type InferType = T extends Marker ? K : never; type CollectType | string>> = { [K in T[number] as K extends Marker ? N : never]: InferType; }; /** Specify that this value is dynamic and needs to be interpolated with the given keys */ -export function extract | string)[]>( +function extract | string)[]>( strings: TemplateStringsArray, ...keys: T -): TemplateWithMarkers> & Record> { +): TemplateWithMarkers>> { const markers: MarkerConfig = {}; const result: string[] = [strings[0]]; - let pos = strings[0].length; keys.forEach((key, i) => { if (typeof key === "string") { result.push(key); - pos += key.length; } else { - result.push(key.name); + result.push(`/*${key.name}*/${key.name}`); markers[key.name] = { - pos, - end: pos + key.name.length, entityKind: key.entityKind, name: key.name, kind: (key as any).kind, valueKind: (key as any).valueKind, }; - pos += key.name.length; } result.push(strings[i + 1]); }); @@ -124,4 +94,28 @@ export function extract | string)[]>( }; } -const a = extract`foo ${m.model("bar")} ${"regular"} ${m.enum("def")}`; +/** TypeSpec template marker */ +export const t = { + code: extract, + + // Types + type: typeMarker(), + model: typeMarker("Model"), + enum: typeMarker("Enum"), + union: typeMarker("Union"), + interface: typeMarker("Interface"), + op: typeMarker("Operation"), + enumMember: typeMarker("EnumMember"), + modelProperty: typeMarker("ModelProperty"), + namespace: typeMarker("Namespace"), + scalar: typeMarker("Scalar"), + unionVariant: typeMarker("UnionVariant"), + boolean: typeMarker("Boolean"), + number: typeMarker("Number"), + string: typeMarker("String"), + + // Values + value: valueMarker(), + object: valueMarker("ObjectValue"), + array: valueMarker("ArrayValue"), +}; diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index d42df8e2206..33b2dee98cd 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -213,6 +213,9 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { ...usings, params.wraps ? applyWraps(codeStr, params.wraps) : codeStr, ].join("\n"); + + const markerPositions = extractMarkers(actualCode); + fs.addTypeSpecFile("main.tsp", actualCode); const program = await coreCompile(fs.compilerHost, resolveVirtualPath("main.tsp")); savedProgram = program; @@ -222,8 +225,9 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { if (!file) { throw new Error(`Couldn't find main.tsp in program`); } - for (const marker of Object.values(code.markers)) { - const { pos, end, name, kind, entityKind, valueKind } = marker; + for (const marker of markerPositions) { + const { name, pos, end } = marker; + const markerConfig = code.markers[name]; const node = getNodeAtPosition(file, pos); if (!node) { throw new Error(`Could not find node at ${pos}-${end}`); @@ -235,23 +239,31 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { const entity = program.checker.getTypeOrValueForNode(getSymNode(sym)); if (entity === null) { throw new Error( - `Expected ${name} to be of entity kind ${entityKind} but got null (Means a value failed to resolve) at ${pos}-${end}`, + `Expected ${name} to be of entity kind ${markerConfig?.entityKind} but got null (Means a value failed to resolve) at ${pos}-${end}`, ); } - if (entity.entityKind !== entityKind) { - throw new Error( - `Expected ${name} to be of entity kind ${entityKind} but got (${entity?.entityKind}) ${getEntityName(entity)} at ${pos}-${end}`, - ); - } - if (entity.entityKind === "Type" && entity.kind !== kind) { - throw new Error( - `Expected ${name} to be of kind ${kind} but got (${entity.kind}) ${getEntityName(entity)} at ${pos}-${end}`, - ); - } else if (entity?.entityKind === "Value" && entity.valueKind !== valueKind) { - throw new Error( - `Expected ${name} to be of value kind ${valueKind} but got (${entity.valueKind}) ${getEntityName(entity)} at ${pos}-${end}`, - ); + if (markerConfig) { + const { entityKind, kind, valueKind } = markerConfig as any; + if (entity.entityKind !== entityKind) { + throw new Error( + `Expected ${name} to be of entity kind ${entityKind} but got (${entity?.entityKind}) ${getEntityName(entity)} at ${pos}-${end}`, + ); + } + if (entity.entityKind === "Type" && kind !== undefined && entity.kind !== kind) { + throw new Error( + `Expected ${name} to be of kind ${kind} but got (${entity.kind}) ${getEntityName(entity)} at ${pos}-${end}`, + ); + } else if ( + entity?.entityKind === "Value" && + valueKind !== undefined && + entity.valueKind !== valueKind + ) { + throw new Error( + `Expected ${name} to be of value kind ${valueKind} but got (${entity.valueKind}) ${getEntityName(entity)} at ${pos}-${end}`, + ); + } } + (types as any)[name] = entity; } } @@ -306,3 +318,30 @@ function addTestLib(fs: TestFileSystem): Record { }); return testTypes; } + +export interface PositionedMarker { + name: string; + pos: number; + end: number; +} +function extractMarkers(code: string): PositionedMarker[] { + // Extract TypeScript fourslash-style markers: /*markerName*/ + // Returns an array of Marker objects with name, pos, and end + const markerRegex = /\/\*([a-zA-Z0-9_]+)\*\//g; + const markers: PositionedMarker[] = []; + let match: RegExpExecArray | null; + while ((match = markerRegex.exec(code)) !== null) { + const markerName = match[1]; + // The marker is immediately followed by the identifier + // Find the next word after the marker + const afterMarker = code.slice(markerRegex.lastIndex); + const idMatch = /([a-zA-Z0-9_]+)/.exec(afterMarker); + if (idMatch) { + const id = idMatch[1]; + const pos = markerRegex.lastIndex; + const end = pos + id.length; + markers.push({ name: markerName, pos, end }); + } + } + return markers; +} diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/test-host-v2.test.ts index b25d7843c40..80baeee9734 100644 --- a/packages/compiler/test/testing/test-host-v2.test.ts +++ b/packages/compiler/test/testing/test-host-v2.test.ts @@ -2,117 +2,145 @@ import { describe, expect, it } from "vitest"; import { resolvePath } from "../../src/core/path-utils.js"; -import { extract, m } from "../../src/testing/marked-template.js"; +import { t } from "../../src/testing/marked-template.js"; import { createTester } from "../../src/testing/test-host-v2.js"; const Tester = createTester(resolvePath(import.meta.dirname, "../.."), { libraries: [] }); describe("extract types", () => { + it("generic type", async () => { + const res = await Tester.compile(t.code` + model ${t.type("Foo")} {} + enum ${t.type("Bar")} {} + `); + expect(res.Foo.kind).toBe("Model"); + expect(res.Bar.kind).toBe("Enum"); + }); + it("model", async () => { - const foo = await Tester.compile(extract` - model ${m.model("Foo")} {} + const res = await Tester.compile(t.code` + model ${t.model("Foo")} {} `); - expect(foo.Foo.kind).toBe("Model"); + expect(res.Foo.kind).toBe("Model"); }); it("alias", async () => { - const foo = await Tester.compile(extract` + const res = await Tester.compile(t.code` model Foo {} - alias ${m.model("Bar")} = Foo; + alias ${t.model("Bar")} = Foo; `); - expect(foo.Bar.kind).toBe("Model"); + expect(res.Bar.kind).toBe("Model"); }); it("enum", async () => { - const foo = await Tester.compile(extract` - enum ${m.enum("Foo")} {} + const res = await Tester.compile(t.code` + enum ${t.enum("Foo")} {} `); - expect(foo.Foo.kind).toBe("Enum"); + expect(res.Foo.kind).toBe("Enum"); }); it("union", async () => { - const foo = await Tester.compile(extract` - union ${m.union("Foo")} {} + const res = await Tester.compile(t.code` + union ${t.union("Foo")} {} `); - expect(foo.Foo.kind).toBe("Union"); + expect(res.Foo.kind).toBe("Union"); }); it("interface", async () => { - const foo = await Tester.compile(extract` - interface ${m.interface("Foo")} {} + const res = await Tester.compile(t.code` + interface ${t.interface("Foo")} {} `); - expect(foo.Foo.kind).toBe("Interface"); + expect(res.Foo.kind).toBe("Interface"); }); it("operation", async () => { - const foo = await Tester.compile(extract` - op ${m.op("Foo")}(): void; + const res = await Tester.compile(t.code` + op ${t.op("Foo")}(): void; `); - expect(foo.Foo.kind).toBe("Operation"); + expect(res.Foo.kind).toBe("Operation"); }); it("namespace", async () => { - const foo = await Tester.compile(extract` - namespace ${m.namespace("Foo")} {} + const res = await Tester.compile(t.code` + namespace ${t.namespace("Foo")} {} `); - expect(foo.Foo.kind).toBe("Namespace"); + expect(res.Foo.kind).toBe("Namespace"); }); it("scalar", async () => { - const foo = await Tester.compile(extract` - scalar ${m.scalar("Foo")}; + const res = await Tester.compile(t.code` + scalar ${t.scalar("Foo")}; `); - expect(foo.Foo.kind).toBe("Scalar"); + expect(res.Foo.kind).toBe("Scalar"); }); it("model property", async () => { - const foo = await Tester.compile(extract` + const res = await Tester.compile(t.code` model Bar { - ${m.modelProperty("prop")}: string; + ${t.modelProperty("prop")}: string; } `); - expect(foo.prop.kind).toBe("ModelProperty"); + expect(res.prop.kind).toBe("ModelProperty"); }); it("union variant", async () => { - const foo = await Tester.compile(extract` + const res = await Tester.compile(t.code` union Bar { - ${m.unionVariant("A")}: string; + ${t.unionVariant("A")}: string; } `); - expect(foo.A.kind).toBe("UnionVariant"); + expect(res.A.kind).toBe("UnionVariant"); }); it("enum member", async () => { - const foo = await Tester.compile(extract` + const res = await Tester.compile(t.code` enum Bar { - ${m.enumMember("A")} + ${t.enumMember("A")} } `); - expect(foo.A.kind).toBe("EnumMember"); + expect(res.A.kind).toBe("EnumMember"); + }); + + it("validate type match", async () => { + await expect(() => + Tester.compile(t.code` + enum ${t.model("Foo")} {} + `), + ).rejects.toThrowError("Expected Foo to be of kind Model but got (Enum) Foo at 17-20"); }); }); describe("extract values", () => { + it("generic value", async () => { + const res = await Tester.compile(t.code` + const ${t.value("a")} = "foo"; + const ${t.value("b")} = 123; + `); + expect(res.a.valueKind).toBe("StringValue"); + expect(res.b.valueKind).toBe("NumericValue"); + }); + it("object", async () => { - const foo = await Tester.compile(extract` - const ${m.object("foo")} = #{}; + const res = await Tester.compile(t.code` + const ${t.object("foo")} = #{}; `); - expect(foo.foo.valueKind).toBe("ObjectValue"); + expect(res.foo.valueKind).toBe("ObjectValue"); }); it("array", async () => { - const foo = await Tester.compile(extract` - const ${m.array("foo")} = #[]; + const res = await Tester.compile(t.code` + const ${t.array("foo")} = #[]; `); - expect(foo.foo.valueKind).toBe("ArrayValue"); + expect(res.foo.valueKind).toBe("ArrayValue"); }); -}); -it("validate type match", async () => { - await expect(() => - Tester.compile(extract` - enum ${m.model("Foo")} {} + it("validate value match", async () => { + await expect(() => + Tester.compile(t.code` + const ${t.object("foo")} = 123; `), - ).rejects.toThrowError("Expected Foo to be of kind Model but got (Enum) Foo at 10-13"); + ).rejects.toThrowError( + "Expected foo to be of value kind ObjectValue but got (NumericValue) 123 at 18-21", + ); + }); }); From 595ff1b7c0d461600772749371f2a96da92066b3 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 10:49:41 -0700 Subject: [PATCH 14/55] . --- packages/compiler/test/testing/test-host-v2.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/test-host-v2.test.ts index 80baeee9734..8b819869414 100644 --- a/packages/compiler/test/testing/test-host-v2.test.ts +++ b/packages/compiler/test/testing/test-host-v2.test.ts @@ -104,9 +104,9 @@ describe("extract types", () => { it("validate type match", async () => { await expect(() => Tester.compile(t.code` - enum ${t.model("Foo")} {} - `), - ).rejects.toThrowError("Expected Foo to be of kind Model but got (Enum) Foo at 17-20"); + enum ${t.model("Foo")} {} + `), + ).rejects.toThrowError("Expected Foo to be of kind Model but got (Enum) Foo at 21-24"); }); }); @@ -137,10 +137,10 @@ describe("extract values", () => { it("validate value match", async () => { await expect(() => Tester.compile(t.code` - const ${t.object("foo")} = 123; - `), + const ${t.object("foo")} = 123; + `), ).rejects.toThrowError( - "Expected foo to be of value kind ObjectValue but got (NumericValue) 123 at 18-21", + "Expected foo to be of value kind ObjectValue but got (NumericValue) 123 at 22-25", ); }); }); From fd4c0ce46f218c2ef25ce360a31d7d8ad0329b21 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 10:53:16 -0700 Subject: [PATCH 15/55] type check --- packages/compiler/test/testing/test-host-v2.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/test-host-v2.test.ts index 8b819869414..0a49ef1aebc 100644 --- a/packages/compiler/test/testing/test-host-v2.test.ts +++ b/packages/compiler/test/testing/test-host-v2.test.ts @@ -1,7 +1,8 @@ // TODO: rename? -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { resolvePath } from "../../src/core/path-utils.js"; +import { Model } from "../../src/index.js"; import { t } from "../../src/testing/marked-template.js"; import { createTester } from "../../src/testing/test-host-v2.js"; @@ -21,6 +22,7 @@ describe("extract types", () => { const res = await Tester.compile(t.code` model ${t.model("Foo")} {} `); + expectTypeOf(res.Foo).toExtend(); expect(res.Foo.kind).toBe("Model"); }); From e01aec9daf171a73cba451797c49394e61c5585e Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 11:17:11 -0700 Subject: [PATCH 16/55] update openapi --- packages/compiler/src/testing/test-host-v2.ts | 5 +- .../test/testing/test-host-v2.test.ts | 43 +++++++++++- packages/openapi/test/decorators.test.ts | 68 ++++++++----------- 3 files changed, 75 insertions(+), 41 deletions(-) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index 33b2dee98cd..f62202582e0 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -16,7 +16,9 @@ import { resolveVirtualPath } from "./test-utils.js"; import { TestFileSystem } from "./types.js"; // Need a way to combine that with `program` -export type TestCompileResult> = T; +export type TestCompileResult> = T & { + readonly program: Program; +} & Record; export interface JsFileDef { [key: string]: string | unknown; @@ -214,6 +216,7 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { params.wraps ? applyWraps(codeStr, params.wraps) : codeStr, ].join("\n"); + console.log("Actual code", actualCode); const markerPositions = extractMarkers(actualCode); fs.addTypeSpecFile("main.tsp", actualCode); diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/test-host-v2.test.ts index 0a49ef1aebc..b41949c91b2 100644 --- a/packages/compiler/test/testing/test-host-v2.test.ts +++ b/packages/compiler/test/testing/test-host-v2.test.ts @@ -2,12 +2,32 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { resolvePath } from "../../src/core/path-utils.js"; -import { Model } from "../../src/index.js"; +import { Enum, Model, Program } from "../../src/index.js"; import { t } from "../../src/testing/marked-template.js"; import { createTester } from "../../src/testing/test-host-v2.js"; const Tester = createTester(resolvePath(import.meta.dirname, "../.."), { libraries: [] }); +it("generic type", async () => { + const res = await Tester.compile(t.code` + model ${t.model("Foo")} {} + enum ${t.enum("Bar")} {} + union /*Baz*/Baz {} + `); + expect(res.Foo.kind).toBe("Model"); + + expectTypeOf({ + Foo: res.Foo, + Bar: res.Bar, + Baz: res.Baz, + program: res.program, + }).toExtend<{ + Foo: Model; + Bar: Enum; + program: Program; + }>(); +}); + describe("extract types", () => { it("generic type", async () => { const res = await Tester.compile(t.code` @@ -18,6 +38,13 @@ describe("extract types", () => { expect(res.Bar.kind).toBe("Enum"); }); + // it("extract with fourslash syntax", async () => { + // const res = await Tester.compile(t.code` + // model /*ExtractedFoo*/Foo {} + // `); + // expect(res.ExtractedFoo.kind).toBe("Model"); + // }); + it("model", async () => { const res = await Tester.compile(t.code` model ${t.model("Foo")} {} @@ -146,3 +173,17 @@ describe("extract values", () => { ); }); }); + +it("still extract with additional using", async () => { + const res = await Tester.using("TypeSpec").compile(t.code` + model ${t.model("Foo")} {} + `); + expect(res.Foo.kind).toBe("Model"); +}); + +it("still extract with wrappers", async () => { + const res = await Tester.wrap((x) => `model Test {}\n${x}\nmodel Test2 {}`).compile(t.code` + model ${t.model("Foo")} {} + `); + expect(res.Foo.kind).toBe("Model"); +}); diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index ab82d94e775..03854deff68 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -1,5 +1,5 @@ import { Namespace } from "@typespec/compiler"; -import { expectDiagnostics, TesterInstance } from "@typespec/compiler/testing"; +import { expectDiagnostics, t, TesterInstance } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; import { beforeEach, describe, it } from "vitest"; import { @@ -47,10 +47,9 @@ describe("openapi: decorators", () => { describe("@extension", () => { it("apply extension on model", async () => { - const { Foo } = await runner.compile(` + const { Foo } = await runner.compile(t.code` @extension("x-custom", "Bar") - @test - model Foo { + model ${t.model("Foo")} { prop: string } `); @@ -61,10 +60,9 @@ describe("openapi: decorators", () => { }); it("apply extension with complex value", async () => { - const { Foo } = await runner.compile(` + const { Foo } = await runner.compile(t.code` @extension("x-custom", #{foo: 123, bar: "string"}) - @test - model Foo { + model ${t.model("Foo")} { prop: string } `); @@ -83,10 +81,9 @@ describe("openapi: decorators", () => { { value: `"hi"`, expected: "hi" }, { value: `null`, expected: null }, ])("treats value $value as raw value", async ({ value, expected }) => { - const { Foo } = await runner.compile(` + const { Foo } = await runner.compile(t.code` @extension("x-custom", ${value}) - @test - model Foo{} + model ${t.model("Foo")} {} `); deepStrictEqual(Object.fromEntries(getExtensions(runner.program, Foo)), { @@ -95,10 +92,9 @@ describe("openapi: decorators", () => { }); it("supports extension key not starting with `x-`", async () => { - const { Foo } = await runner.compile(` + const { Foo } = await runner.compile(t.code` @extension("foo", "Bar") - @test - model Foo { + model ${t.model("Foo")} { prop: string } `); @@ -139,7 +135,6 @@ describe("openapi: decorators", () => { const diagnostics = await runner.diagnose(` @externalDocs("https://example.com", 123) model Foo {} - `); expectDiagnostics(diagnostics, { @@ -148,20 +143,18 @@ describe("openapi: decorators", () => { }); it("set the external url", async () => { - const { Foo } = await runner.compile(` + const { Foo } = await runner.compile(t.code` @externalDocs("https://example.com") - @test - model Foo {} + model ${t.model("Foo")} {} `); deepStrictEqual(getExternalDocs(runner.program, Foo), { url: "https://example.com" }); }); it("set the external url with description", async () => { - const { Foo } = await runner.compile(` + const { Foo } = await runner.compile(t.code` @externalDocs("https://example.com", "More info there") - @test - model Foo {} + model ${t.model("Foo")} {} `); deepStrictEqual(getExternalDocs(runner.program, Foo), { @@ -220,7 +213,7 @@ describe("openapi: decorators", () => { it("emit diagnostic if termsOfService is not a valid url", async () => { const diagnostics = await runner.diagnose(` @info(#{termsOfService:"notvalidurl"}) - @test namespace Service {} + namespace Service {} `); expectDiagnostics(diagnostics, { @@ -253,7 +246,7 @@ describe("openapi: decorators", () => { }); it("set all properties", async () => { - const { Service } = (await runner.compile(` + const { Service } = (await runner.compile(t.code` @info(#{ title: "My API", version: "1.0.0", @@ -269,7 +262,7 @@ describe("openapi: decorators", () => { url: "http://www.apache.org/licenses/LICENSE-2.0.html" }, }) - @test namespace Service {} + namespace ${t.namespace("Service")} {} `)) as { Service: Namespace }; deepStrictEqual(getInfo(runner.program, Service), { @@ -290,7 +283,7 @@ describe("openapi: decorators", () => { }); it("resolveInfo() merge with data from @service and @summary", async () => { - const { Service } = (await runner.compile(` + const { Service } = await runner.compile(t.code` #suppress "deprecated" "Test" @service(#{ title: "Service API", @@ -300,8 +293,8 @@ describe("openapi: decorators", () => { version: "1.0.0", termsOfService: "http://example.com/terms/", }) - @test namespace Service {} - `)) as { Service: Namespace }; + namespace ${t.namespace("Service")} {} + `); deepStrictEqual(resolveInfo(runner.program, Service), { title: "Service API", @@ -312,16 +305,16 @@ describe("openapi: decorators", () => { }); it("resolveInfo() returns empty object if nothing is provided", async () => { - const { Service } = (await runner.compile(` - @test namespace Service {} - `)) as { Service: Namespace }; + const { Service } = await runner.compile(t.code` + namespace ${t.namespace("Service")} {} + `); deepStrictEqual(resolveInfo(runner.program, Service), {}); }); it("setInfo() function for setting info object directly", async () => { - const { Service } = (await runner.compile(` - @test namespace Service {} + const { Service } = (await runner.compile(t.code` + namespace ${t.namespace("Service")} {} `)) as { Service: Namespace }; setInfo(runner.program, Service, { title: "My API", @@ -457,11 +450,11 @@ describe("openapi: decorators", () => { it("emit diagnostic if externalDocs.url is not a valid url", async () => { const diagnostics = await runner.diagnose( ` - @service() + @service @tagMetadata("tagName", #{ externalDocs: #{ url: "notvalidurl"}, }) - @test namespace Service {} + namespace Service {} `, ); @@ -543,14 +536,11 @@ describe("openapi: decorators", () => { ]; it.each(testCases)("%s", async (_, tagMetaDecorator, expected) => { const runner = Tester.createInstance(); - const { PetStore } = await runner.compile( - ` + const { PetStore } = await runner.compile(t.code` @service() ${tagMetaDecorator} - @test - namespace PetStore {} - `, - ); + namespace ${t.namespace("PetStore")} {} + `); deepStrictEqual(getTagsMetadata(runner.program, PetStore), expected); }); }); From 59779c7c3c33786cdce9a24bcbf00c73729edfa7 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 11:17:18 -0700 Subject: [PATCH 17/55] remove log --- packages/compiler/src/testing/test-host-v2.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index f62202582e0..1296eb95da9 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -216,7 +216,6 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { params.wraps ? applyWraps(codeStr, params.wraps) : codeStr, ].join("\n"); - console.log("Actual code", actualCode); const markerPositions = extractMarkers(actualCode); fs.addTypeSpecFile("main.tsp", actualCode); From 555512aea934f5294e2ac90e8b1842f69e90a7c5 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 11:23:49 -0700 Subject: [PATCH 18/55] . --- .../compiler/test/testing/test-host-v2.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/test-host-v2.test.ts index b41949c91b2..c9db0e8061e 100644 --- a/packages/compiler/test/testing/test-host-v2.test.ts +++ b/packages/compiler/test/testing/test-host-v2.test.ts @@ -1,5 +1,6 @@ // TODO: rename? +import { strictEqual } from "assert"; import { describe, expect, expectTypeOf, it } from "vitest"; import { resolvePath } from "../../src/core/path-utils.js"; import { Enum, Model, Program } from "../../src/index.js"; @@ -38,12 +39,13 @@ describe("extract types", () => { expect(res.Bar.kind).toBe("Enum"); }); - // it("extract with fourslash syntax", async () => { - // const res = await Tester.compile(t.code` - // model /*ExtractedFoo*/Foo {} - // `); - // expect(res.ExtractedFoo.kind).toBe("Model"); - // }); + it("extract with fourslash syntax", async () => { + const res = await Tester.compile(t.code` + model /*ExtractedFoo*/Foo {} + `); + strictEqual(res.ExtractedFoo.entityKind, "Type"); + expect(res.ExtractedFoo.kind).toBe("Model"); + }); it("model", async () => { const res = await Tester.compile(t.code` From e0dd02b8dc8ad213ae5404701ec495155fb85bdb Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 11:33:02 -0700 Subject: [PATCH 19/55] simplify --- packages/compiler/src/testing/fourslash.ts | 26 ++++++++++ packages/compiler/src/testing/test-host-v2.ts | 50 +++++-------------- 2 files changed, 39 insertions(+), 37 deletions(-) create mode 100644 packages/compiler/src/testing/fourslash.ts diff --git a/packages/compiler/src/testing/fourslash.ts b/packages/compiler/src/testing/fourslash.ts new file mode 100644 index 00000000000..44c02e04494 --- /dev/null +++ b/packages/compiler/src/testing/fourslash.ts @@ -0,0 +1,26 @@ +/** + * PositionedMarker represents a marker in the code with its name and position. + */ +export interface PositionedMarker { + /** Marker name */ + readonly name: string; + /** Position of the marker */ + readonly pos: number; +} + +/** + * Extract TypeScript fourslash-style markers: /\*markerName*\/ + * @param code + * @returns an array of Marker objects with name, pos, and end + */ +export function extractMarkers(code: string): PositionedMarker[] { + const markerRegex = /\/\*([a-zA-Z0-9_]+)\*\//g; + const markers: PositionedMarker[] = []; + let match: RegExpExecArray | null; + while ((match = markerRegex.exec(code)) !== null) { + const markerName = match[1]; + const pos = markerRegex.lastIndex; + markers.push({ name: markerName, pos }); + } + return markers; +} diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index 1296eb95da9..a61936a1a01 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -10,6 +10,7 @@ import { Program, compile as coreCompile } from "../core/program.js"; import { createSourceLoader } from "../core/source-loader.js"; import { Diagnostic, Entity, NoTarget, SourceFile, StringLiteral, Type } from "../core/types.js"; import { expectDiagnosticEmpty } from "./expect.js"; +import { extractMarkers } from "./fourslash.js"; import { TemplateWithMarkers } from "./marked-template.js"; import { StandardTestLibrary, createTestFileSystem } from "./test-host.js"; import { resolveVirtualPath } from "./test-utils.js"; @@ -198,12 +199,13 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { } return code; } + async function compileAndDiagnose>( code: string | TemplateWithMarkers, options?: TestCompileOptions, ): Promise<[TestCompileResult, readonly Diagnostic[]]> { const fs = await params.fs(); - const types = await addTestLib(fs); + const typesCollected = await addTestLib(fs); const imports = (params.imports ?? []).map((x) => `import "${x}";`); const usings = (params.usings ?? []).map((x) => `using ${x};`); @@ -222,38 +224,39 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { const program = await coreCompile(fs.compilerHost, resolveVirtualPath("main.tsp")); savedProgram = program; + const entities: Record = { ...typesCollected }; if (typeof code !== "string") { const file = program.sourceFiles.get(resolveVirtualPath("main.tsp")); if (!file) { throw new Error(`Couldn't find main.tsp in program`); } for (const marker of markerPositions) { - const { name, pos, end } = marker; + const { name, pos } = marker; const markerConfig = code.markers[name]; const node = getNodeAtPosition(file, pos); if (!node) { - throw new Error(`Could not find node at ${pos}-${end}`); + throw new Error(`Could not find node at ${pos}`); } const sym = program.checker.resolveRelatedSymbols(node as any)?.[0]; if (sym === undefined) { - throw new Error(`Could not find symbol for ${name} at ${pos}-${end}`); + throw new Error(`Could not find symbol for ${name} at ${pos}`); } const entity = program.checker.getTypeOrValueForNode(getSymNode(sym)); if (entity === null) { throw new Error( - `Expected ${name} to be of entity kind ${markerConfig?.entityKind} but got null (Means a value failed to resolve) at ${pos}-${end}`, + `Expected ${name} to be of entity kind ${markerConfig?.entityKind} but got null (Means a value failed to resolve) at ${pos}`, ); } if (markerConfig) { const { entityKind, kind, valueKind } = markerConfig as any; if (entity.entityKind !== entityKind) { throw new Error( - `Expected ${name} to be of entity kind ${entityKind} but got (${entity?.entityKind}) ${getEntityName(entity)} at ${pos}-${end}`, + `Expected ${name} to be of entity kind ${entityKind} but got (${entity?.entityKind}) ${getEntityName(entity)} at ${pos}`, ); } if (entity.entityKind === "Type" && kind !== undefined && entity.kind !== kind) { throw new Error( - `Expected ${name} to be of kind ${kind} but got (${entity.kind}) ${getEntityName(entity)} at ${pos}-${end}`, + `Expected ${name} to be of kind ${kind} but got (${entity.kind}) ${getEntityName(entity)} at ${pos}`, ); } else if ( entity?.entityKind === "Value" && @@ -261,15 +264,15 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { entity.valueKind !== valueKind ) { throw new Error( - `Expected ${name} to be of value kind ${valueKind} but got (${entity.valueKind}) ${getEntityName(entity)} at ${pos}-${end}`, + `Expected ${name} to be of value kind ${valueKind} but got (${entity.valueKind}) ${getEntityName(entity)} at ${pos}`, ); } } - (types as any)[name] = entity; + (entities as any)[name] = entity; } } - return [{ program, ...types } as any, program.diagnostics]; + return [{ program, ...entities } as any, program.diagnostics]; } async function compile>( @@ -320,30 +323,3 @@ function addTestLib(fs: TestFileSystem): Record { }); return testTypes; } - -export interface PositionedMarker { - name: string; - pos: number; - end: number; -} -function extractMarkers(code: string): PositionedMarker[] { - // Extract TypeScript fourslash-style markers: /*markerName*/ - // Returns an array of Marker objects with name, pos, and end - const markerRegex = /\/\*([a-zA-Z0-9_]+)\*\//g; - const markers: PositionedMarker[] = []; - let match: RegExpExecArray | null; - while ((match = markerRegex.exec(code)) !== null) { - const markerName = match[1]; - // The marker is immediately followed by the identifier - // Find the next word after the marker - const afterMarker = code.slice(markerRegex.lastIndex); - const idMatch = /([a-zA-Z0-9_]+)/.exec(afterMarker); - if (idMatch) { - const id = idMatch[1]; - const pos = markerRegex.lastIndex; - const end = pos + id.length; - markers.push({ name: markerName, pos, end }); - } - } - return markers; -} From ccc79ca46d7d0fe84efdf050d1e6881e61bd260a Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 11:33:04 -0700 Subject: [PATCH 20/55] test --- .../compiler/test/testing/fourslash.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 packages/compiler/test/testing/fourslash.test.ts diff --git a/packages/compiler/test/testing/fourslash.test.ts b/packages/compiler/test/testing/fourslash.test.ts new file mode 100644 index 00000000000..63246ace6d9 --- /dev/null +++ b/packages/compiler/test/testing/fourslash.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { extractMarkers } from "../../src/testing/fourslash.js"; + +describe("extractMarkers", () => { + it("marks the pos right after the fourslash syntax", () => { + const code = `model /*foo*/Foo {}`; + const markers = extractMarkers(code); + expect(markers).toHaveLength(1); + expect(markers[0]).toMatchObject({ name: "foo" }); + expect(code.slice(markers[0].pos, markers[0].pos + 3)).toBe("Foo"); + }); + + it("extracts multiple markers", () => { + const code = `model /*foo*/Foo {}\nmodel /*bar*/Bar {}`; + const markers = extractMarkers(code); + expect(markers).toHaveLength(2); + expect(markers[0].name).toBe("foo"); + expect(code.slice(markers[0].pos, markers[0].pos + 3)).toBe("Foo"); + expect(markers[1].name).toBe("bar"); + expect(code.slice(markers[1].pos, markers[1].pos + 3)).toBe("Bar"); + }); + + it("extracts marker with identifier containing numbers and underscores", () => { + const code = `model /*foo*/Foo_123 {}`; + const markers = extractMarkers(code); + expect(markers).toHaveLength(1); + expect(markers[0].name).toBe("foo"); + expect(code.slice(markers[0].pos, markers[0].pos + 7)).toBe("Foo_123"); + }); +}); From 9e16b203624e58a59ce206a01cc9fe11128929c0 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 11:34:15 -0700 Subject: [PATCH 21/55] . --- packages/compiler/src/testing/test-host-v2.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index a61936a1a01..78a0f51de3b 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -18,6 +18,7 @@ import { TestFileSystem } from "./types.js"; // Need a way to combine that with `program` export type TestCompileResult> = T & { + /** The program created in this test compilation. */ readonly program: Program; } & Record; From ab0380ed6be39511337532b72e78269949e1c2a5 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 11:35:27 -0700 Subject: [PATCH 22/55] . --- packages/compiler/src/testing/test-host-v2.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index 78a0f51de3b..b38821aef3e 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -302,18 +302,8 @@ function addTestLib(fs: TestFileSystem): Record { $test(_: any, target: Type, nameLiteral?: StringLiteral) { let name = nameLiteral?.value; if (!name) { - if ( - target.kind === "Model" || - target.kind === "Scalar" || - target.kind === "Namespace" || - target.kind === "Enum" || - target.kind === "Operation" || - target.kind === "ModelProperty" || - target.kind === "EnumMember" || - target.kind === "Interface" || - (target.kind === "Union" && !target.expression) - ) { - name = target.name!; + if ("name" in target && typeof target.name === "string") { + name = target.name; } else { throw new Error("Need to specify a name for test type"); } From 342c3881bf27e55f921544cd19fa122ed38bae78 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 13:44:51 -0700 Subject: [PATCH 23/55] Works with multi file --- .../compiler/src/testing/marked-template.ts | 27 +++++ packages/compiler/src/testing/test-host-v2.ts | 110 +++++++++++++----- .../test/testing/test-host-v2.test.ts | 21 +++- 3 files changed, 124 insertions(+), 34 deletions(-) diff --git a/packages/compiler/src/testing/marked-template.ts b/packages/compiler/src/testing/marked-template.ts index e97b6d7ef03..177670a6ba4 100644 --- a/packages/compiler/src/testing/marked-template.ts +++ b/packages/compiler/src/testing/marked-template.ts @@ -55,10 +55,17 @@ export type MarkerConfig> = { }; export interface TemplateWithMarkers> { + readonly isTemplateWithMarkers: true; readonly code: string; readonly markers: MarkerConfig; } +export const TemplateWithMarkers = { + is: (value: unknown): value is TemplateWithMarkers => { + return typeof value === "object" && value !== null && "isTemplateWithMarkers" in value; + }, +}; + type Prettify> = { [K in keyof T]: T[K] & Entity; } & {}; @@ -89,6 +96,7 @@ function extract | string)[]>( result.push(strings[i + 1]); }); return { + isTemplateWithMarkers: true, code: result.join(""), markers: markers as any, }; @@ -119,3 +127,22 @@ export const t = { object: valueMarker("ObjectValue"), array: valueMarker("ArrayValue"), }; + +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void + ? I + : never; + +type FlattenRecord> = UnionToIntersection; + +type FlattenTemplates>> = FlattenRecord<{ + [K in keyof M]: M[K] extends TemplateWithMarkers ? T : never; +}>; + +export type GetMarkedEntities< + M extends string | TemplateWithMarkers | Record>, +> = + M extends Record> + ? FlattenTemplates + : M extends string | TemplateWithMarkers + ? R + : never; diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index b38821aef3e..639f1a60a0e 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -10,8 +10,8 @@ import { Program, compile as coreCompile } from "../core/program.js"; import { createSourceLoader } from "../core/source-loader.js"; import { Diagnostic, Entity, NoTarget, SourceFile, StringLiteral, Type } from "../core/types.js"; import { expectDiagnosticEmpty } from "./expect.js"; -import { extractMarkers } from "./fourslash.js"; -import { TemplateWithMarkers } from "./marked-template.js"; +import { PositionedMarker, extractMarkers } from "./fourslash.js"; +import { GetMarkedEntities, Marker, TemplateWithMarkers } from "./marked-template.js"; import { StandardTestLibrary, createTestFileSystem } from "./test-host.js"; import { resolveVirtualPath } from "./test-utils.js"; import { TestFileSystem } from "./types.js"; @@ -27,20 +27,24 @@ export interface JsFileDef { } interface TestCompileOptions { - readonly files?: Record; + /** Optional compiler options */ readonly options?: CompilerOptions; } interface Testable { - compile>( - main: string | TemplateWithMarkers, + compile< + T extends string | TemplateWithMarkers | Record>, + >( + code: T, options?: TestCompileOptions, - ): Promise>; + ): Promise>>; diagnose(main: string, options?: TestCompileOptions): Promise; - compileAndDiagnose>( - main: string | TemplateWithMarkers, + compileAndDiagnose< + T extends string | TemplateWithMarkers | Record>, + >( + code: T, options?: TestCompileOptions, - ): Promise<[TestCompileResult, readonly Diagnostic[]]>; + ): Promise<[TestCompileResult>, readonly Diagnostic[]]>; } // Immutable structure meant to be reused @@ -201,46 +205,89 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { return code; } - async function compileAndDiagnose>( - code: string | TemplateWithMarkers, - options?: TestCompileOptions, - ): Promise<[TestCompileResult, readonly Diagnostic[]]> { - const fs = await params.fs(); - const typesCollected = await addTestLib(fs); + interface PositionedMarkerInFile extends PositionedMarker { + /** The file where the marker is located */ + readonly filename: string; + } + function addCode( + fs: TestFileSystem, + code: string | TemplateWithMarkers | Record>, + ): { + markerPositions: PositionedMarkerInFile[]; + markerConfigs: Record>; + } { + const markerPositions: PositionedMarkerInFile[] = []; + const markerConfigs: Record> = {}; + + function addTsp(filename: string, value: string | TemplateWithMarkers) { + const codeStr = TemplateWithMarkers.is(value) ? value.code : value; + + const actualCode = filename === "main.tsp" ? wrapMain(codeStr) : codeStr; + if (TemplateWithMarkers.is(value)) { + const markers = extractMarkers(actualCode); + for (const marker of markers) { + markerPositions.push({ ...marker, filename }); + } + for (const [markerName, markerConfig] of Object.entries(value.markers)) { + if (markerConfig) { + markerConfigs[markerName] = markerConfig; + } + } + } + fs.addTypeSpecFile(filename, actualCode); + } + const files = + typeof code === "string" || TemplateWithMarkers.is(code) ? { "main.tsp": code } : code; + for (const [name, value] of Object.entries(files)) { + addTsp(name, value); + } + + return { markerPositions, markerConfigs }; + } + + function wrapMain(code: string): string { const imports = (params.imports ?? []).map((x) => `import "${x}";`); const usings = (params.usings ?? []).map((x) => `using ${x};`); - // Support TemplateWithMarkers - const codeStr = typeof code === "string" ? code : code.code; const actualCode = [ ...imports, ...usings, - params.wraps ? applyWraps(codeStr, params.wraps) : codeStr, + params.wraps ? applyWraps(code, params.wraps) : code, ].join("\n"); + return actualCode; + } + async function compileAndDiagnose< + T extends string | TemplateWithMarkers | Record>, + >( + code: T, + options?: TestCompileOptions, + ): Promise<[TestCompileResult>, readonly Diagnostic[]]> { + const fs = await params.fs(); + const typesCollected = addTestLib(fs); + const { markerPositions, markerConfigs } = addCode(fs, code); - const markerPositions = extractMarkers(actualCode); - - fs.addTypeSpecFile("main.tsp", actualCode); const program = await coreCompile(fs.compilerHost, resolveVirtualPath("main.tsp")); savedProgram = program; const entities: Record = { ...typesCollected }; if (typeof code !== "string") { - const file = program.sourceFiles.get(resolveVirtualPath("main.tsp")); - if (!file) { - throw new Error(`Couldn't find main.tsp in program`); - } for (const marker of markerPositions) { + const file = program.sourceFiles.get(resolveVirtualPath(marker.filename)); + if (!file) { + throw new Error(`Couldn't find ${resolveVirtualPath(marker.filename)} in program`); + } const { name, pos } = marker; - const markerConfig = code.markers[name]; + const markerConfig = markerConfigs[name]; const node = getNodeAtPosition(file, pos); if (!node) { throw new Error(`Could not find node at ${pos}`); } const sym = program.checker.resolveRelatedSymbols(node as any)?.[0]; if (sym === undefined) { - throw new Error(`Could not find symbol for ${name} at ${pos}`); + throw new Error( + `Could not find symbol for ${name} at ${pos}. File content: ${file.file.text}`, + ); } const entity = program.checker.getTypeOrValueForNode(getSymNode(sym)); if (entity === null) { @@ -276,16 +323,15 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { return [{ program, ...entities } as any, program.diagnostics]; } - async function compile>( - code: string | TemplateWithMarkers, - options?: TestCompileOptions, - ): Promise> { + async function compile< + T extends string | TemplateWithMarkers | Record>, + >(code: T, options?: TestCompileOptions): Promise>> { const [result, diagnostics] = await compileAndDiagnose(code, options); expectDiagnosticEmpty(diagnostics); return result; } async function diagnose( - code: string, + code: string | TemplateWithMarkers | Record>, options?: TestCompileOptions, ): Promise { const [_, diagnostics] = await compileAndDiagnose(code, options); diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/test-host-v2.test.ts index c9db0e8061e..f88c00ef1b4 100644 --- a/packages/compiler/test/testing/test-host-v2.test.ts +++ b/packages/compiler/test/testing/test-host-v2.test.ts @@ -137,7 +137,7 @@ describe("extract types", () => { Tester.compile(t.code` enum ${t.model("Foo")} {} `), - ).rejects.toThrowError("Expected Foo to be of kind Model but got (Enum) Foo at 21-24"); + ).rejects.toThrowError("Expected Foo to be of kind Model but got (Enum) Foo at 21"); }); }); @@ -171,7 +171,7 @@ describe("extract values", () => { const ${t.object("foo")} = 123; `), ).rejects.toThrowError( - "Expected foo to be of value kind ObjectValue but got (NumericValue) 123 at 22-25", + "Expected foo to be of value kind ObjectValue but got (NumericValue) 123 at 22", ); }); }); @@ -189,3 +189,20 @@ it("still extract with wrappers", async () => { `); expect(res.Foo.kind).toBe("Model"); }); + +it("still extract with multiple files", async () => { + const res = await Tester.using("TypeSpec").compile({ + "main.tsp": t.code` + import "./b.tsp"; + model ${t.model("A")} {} + `, + "b.tsp": t.code` + enum ${t.enum("B")} {} + `, + }); + + expectTypeOf(res.A).toExtend(); + expectTypeOf(res.B).toExtend(); + expect(res.A.kind).toBe("Model"); + expect(res.B.kind).toBe("Enum"); +}); From ad30cbfc458de4c38d7157cf320ab64947b00f73 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 9 May 2025 13:46:11 -0700 Subject: [PATCH 24/55] fix --- packages/compiler/test/testing/test-host-v2.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/test-host-v2.test.ts index f88c00ef1b4..89964d83af5 100644 --- a/packages/compiler/test/testing/test-host-v2.test.ts +++ b/packages/compiler/test/testing/test-host-v2.test.ts @@ -191,7 +191,7 @@ it("still extract with wrappers", async () => { }); it("still extract with multiple files", async () => { - const res = await Tester.using("TypeSpec").compile({ + const res = await Tester.compile({ "main.tsp": t.code` import "./b.tsp"; model ${t.model("A")} {} From e8b379220fff465d4459289ec3faf3b04e3659d0 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sun, 11 May 2025 16:46:51 -0700 Subject: [PATCH 25/55] simplify --- packages/openapi/test/decorators.test.ts | 97 +++++++++++------------- 1 file changed, 44 insertions(+), 53 deletions(-) diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 03854deff68..d3472f32048 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -1,7 +1,6 @@ -import { Namespace } from "@typespec/compiler"; -import { expectDiagnostics, t, TesterInstance } from "@typespec/compiler/testing"; +import { expectDiagnostics, t } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; -import { beforeEach, describe, it } from "vitest"; +import { describe, it } from "vitest"; import { getExtensions, getExternalDocs, @@ -13,15 +12,9 @@ import { import { Tester } from "./test-host.js"; describe("openapi: decorators", () => { - let runner: TesterInstance; - - beforeEach(async () => { - runner = Tester.createInstance(); - }); - describe("@operationId", () => { it("emit diagnostic if use on non operation", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @operationId("foo") model Foo {} `); @@ -34,7 +27,7 @@ describe("openapi: decorators", () => { }); it("emit diagnostic if operation id is not a string", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @operationId(123) op foo(): string; `); @@ -47,27 +40,27 @@ describe("openapi: decorators", () => { describe("@extension", () => { it("apply extension on model", async () => { - const { Foo } = await runner.compile(t.code` + const { program, Foo } = await Tester.compile(t.code` @extension("x-custom", "Bar") model ${t.model("Foo")} { prop: string } `); - deepStrictEqual(Object.fromEntries(getExtensions(runner.program, Foo)), { + deepStrictEqual(Object.fromEntries(getExtensions(program, Foo)), { "x-custom": "Bar", }); }); it("apply extension with complex value", async () => { - const { Foo } = await runner.compile(t.code` + const { program, Foo } = await Tester.compile(t.code` @extension("x-custom", #{foo: 123, bar: "string"}) model ${t.model("Foo")} { prop: string } `); - deepStrictEqual(Object.fromEntries(getExtensions(runner.program, Foo)), { + deepStrictEqual(Object.fromEntries(getExtensions(program, Foo)), { "x-custom": { foo: 123, bar: "string" }, }); }); @@ -81,31 +74,31 @@ describe("openapi: decorators", () => { { value: `"hi"`, expected: "hi" }, { value: `null`, expected: null }, ])("treats value $value as raw value", async ({ value, expected }) => { - const { Foo } = await runner.compile(t.code` + const { program, Foo } = await Tester.compile(t.code` @extension("x-custom", ${value}) model ${t.model("Foo")} {} `); - deepStrictEqual(Object.fromEntries(getExtensions(runner.program, Foo)), { + deepStrictEqual(Object.fromEntries(getExtensions(program, Foo)), { "x-custom": expected, }); }); it("supports extension key not starting with `x-`", async () => { - const { Foo } = await runner.compile(t.code` + const { program, Foo } = await Tester.compile(t.code` @extension("foo", "Bar") model ${t.model("Foo")} { prop: string } `); - deepStrictEqual(Object.fromEntries(getExtensions(runner.program, Foo)), { + deepStrictEqual(Object.fromEntries(getExtensions(program, Foo)), { foo: "Bar", }); }); it("emit diagnostics when passing non string extension key", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @extension(123, "Bar") @test model Foo { @@ -121,7 +114,7 @@ describe("openapi: decorators", () => { describe("@externalDocs", () => { it("emit diagnostic if url is not a string", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @externalDocs(123) model Foo {} `); @@ -132,7 +125,7 @@ describe("openapi: decorators", () => { }); it("emit diagnostic if description is not a string", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @externalDocs("https://example.com", 123) model Foo {} `); @@ -143,21 +136,21 @@ describe("openapi: decorators", () => { }); it("set the external url", async () => { - const { Foo } = await runner.compile(t.code` + const { program, Foo } = await Tester.compile(t.code` @externalDocs("https://example.com") model ${t.model("Foo")} {} `); - deepStrictEqual(getExternalDocs(runner.program, Foo), { url: "https://example.com" }); + deepStrictEqual(getExternalDocs(program, Foo), { url: "https://example.com" }); }); it("set the external url with description", async () => { - const { Foo } = await runner.compile(t.code` + const { program, Foo } = await Tester.compile(t.code` @externalDocs("https://example.com", "More info there") model ${t.model("Foo")} {} `); - deepStrictEqual(getExternalDocs(runner.program, Foo), { + deepStrictEqual(getExternalDocs(program, Foo), { url: "https://example.com", description: "More info there", }); @@ -172,7 +165,7 @@ describe("openapi: decorators", () => { ["contact", `#{ contact: #{ foo:"Bar"} }`], ["complex", `#{ contact: #{ \`x-custom\`: "string" }, foo:"Bar" }`], ])("%s", async (_, code) => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @info(${code}) @test namespace Service; `); @@ -184,7 +177,7 @@ describe("openapi: decorators", () => { }); it("multiple", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @info(#{ license: #{ name: "Apache 2.0", foo1:"Bar"}, contact: #{ \`x-custom\`: "string", foo2:"Bar" }, @@ -211,7 +204,7 @@ describe("openapi: decorators", () => { }); it("emit diagnostic if termsOfService is not a valid url", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @info(#{termsOfService:"notvalidurl"}) namespace Service {} `); @@ -223,7 +216,7 @@ describe("openapi: decorators", () => { }); it("emit diagnostic if use on non namespace", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @info(#{}) model Foo {} `); @@ -235,7 +228,7 @@ describe("openapi: decorators", () => { }); it("emit diagnostic if info parameter is not an object", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @info(123) namespace Service {} `); @@ -246,7 +239,7 @@ describe("openapi: decorators", () => { }); it("set all properties", async () => { - const { Service } = (await runner.compile(t.code` + const { program, Service } = await Tester.compile(t.code` @info(#{ title: "My API", version: "1.0.0", @@ -263,9 +256,9 @@ describe("openapi: decorators", () => { }, }) namespace ${t.namespace("Service")} {} - `)) as { Service: Namespace }; + `); - deepStrictEqual(getInfo(runner.program, Service), { + deepStrictEqual(getInfo(program, Service), { title: "My API", version: "1.0.0", summary: "My API summary", @@ -283,8 +276,7 @@ describe("openapi: decorators", () => { }); it("resolveInfo() merge with data from @service and @summary", async () => { - const { Service } = await runner.compile(t.code` - #suppress "deprecated" "Test" + const { program, Service } = await Tester.compile(t.code` @service(#{ title: "Service API", }) @@ -296,7 +288,7 @@ describe("openapi: decorators", () => { namespace ${t.namespace("Service")} {} `); - deepStrictEqual(resolveInfo(runner.program, Service), { + deepStrictEqual(resolveInfo(program, Service), { title: "Service API", version: "1.0.0", summary: "My summary", @@ -305,18 +297,18 @@ describe("openapi: decorators", () => { }); it("resolveInfo() returns empty object if nothing is provided", async () => { - const { Service } = await runner.compile(t.code` + const { program, Service } = await Tester.compile(t.code` namespace ${t.namespace("Service")} {} `); - deepStrictEqual(resolveInfo(runner.program, Service), {}); + deepStrictEqual(resolveInfo(program, Service), {}); }); it("setInfo() function for setting info object directly", async () => { - const { Service } = (await runner.compile(t.code` + const { program, Service } = await Tester.compile(t.code` namespace ${t.namespace("Service")} {} - `)) as { Service: Namespace }; - setInfo(runner.program, Service, { + `); + setInfo(program, Service, { title: "My API", version: "1.0.0", summary: "My API summary", @@ -332,7 +324,7 @@ describe("openapi: decorators", () => { }, "x-custom": "Bar", }); - deepStrictEqual(getInfo(runner.program, Service), { + deepStrictEqual(getInfo(program, Service), { title: "My API", version: "1.0.0", summary: "My API summary", @@ -353,7 +345,7 @@ describe("openapi: decorators", () => { describe("@tagMetadata", () => { it("emit an error if a non-service namespace", async () => { - const diagnostics = await runner.diagnose( + const diagnostics = await Tester.diagnose( ` @tagMetadata("tagName", #{}) namespace Test {} @@ -372,7 +364,7 @@ describe("openapi: decorators", () => { ["description is not a string", `@tagMetadata("tagName", #{ description: 123, })`], ["externalDocs is not an object", `@tagMetadata("tagName", #{ externalDocs: 123, })`], ])("%s", async (_, code) => { - const diagnostics = await runner.diagnose( + const diagnostics = await Tester.diagnose( ` ${code} namespace PetStore{}; @@ -385,7 +377,7 @@ describe("openapi: decorators", () => { }); it("emit diagnostic if dup tagName", async () => { - const diagnostics = await runner.diagnose( + const diagnostics = await Tester.diagnose( ` @service() @tagMetadata("tagName", #{}) @@ -408,7 +400,7 @@ describe("openapi: decorators", () => { `#{ externalDocs: #{ url: "https://example.com", \`x-custom\`: "string" }, foo:"Bar" }`, ], ])("%s", async (_, code) => { - const diagnostics = await runner.diagnose( + const diagnostics = await Tester.diagnose( ` @service() @tagMetadata("tagName", ${code}) @@ -423,7 +415,7 @@ describe("openapi: decorators", () => { }); it("multiple", async () => { - const diagnostics = await runner.diagnose( + const diagnostics = await Tester.diagnose( ` @service() @tagMetadata("tagName", #{ @@ -448,7 +440,7 @@ describe("openapi: decorators", () => { }); it("emit diagnostic if externalDocs.url is not a valid url", async () => { - const diagnostics = await runner.diagnose( + const diagnostics = await Tester.diagnose( ` @service @tagMetadata("tagName", #{ @@ -465,7 +457,7 @@ describe("openapi: decorators", () => { }); it("emit diagnostic if use on non namespace", async () => { - const diagnostics = await runner.diagnose( + const diagnostics = await Tester.diagnose( ` @tagMetadata("tagName", #{}) model Foo {} @@ -535,13 +527,12 @@ describe("openapi: decorators", () => { ], ]; it.each(testCases)("%s", async (_, tagMetaDecorator, expected) => { - const runner = Tester.createInstance(); - const { PetStore } = await runner.compile(t.code` + const { program, PetStore } = await Tester.compile(t.code` @service() ${tagMetaDecorator} namespace ${t.namespace("PetStore")} {} `); - deepStrictEqual(getTagsMetadata(runner.program, PetStore), expected); + deepStrictEqual(getTagsMetadata(program, PetStore), expected); }); }); }); From 776bf33f501b67f65e99255c0b6b099af1cace4f Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sun, 11 May 2025 20:10:16 -0700 Subject: [PATCH 26/55] emitter testing support --- packages/compiler/src/testing/test-host-v2.ts | 153 +++++++++++++++++- .../test/testing/test-host-v2.test.ts | 46 +++++- 2 files changed, 193 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index 639f1a60a0e..8357dea9501 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -20,10 +20,17 @@ import { TestFileSystem } from "./types.js"; export type TestCompileResult> = T & { /** The program created in this test compilation. */ readonly program: Program; + + /** File system */ + readonly fs: TestFileSystem; } & Record; -export interface JsFileDef { - [key: string]: string | unknown; +export interface TestEmitterCompileResult { + /** The program created in this test compilation. */ + readonly program: Program; + + /** Files written to the emitter output dir. */ + readonly outputs: Record; } interface TestCompileOptions { @@ -49,16 +56,47 @@ interface Testable { // Immutable structure meant to be reused export interface Tester extends Testable { + /** Extend with the given list of files */ + files(files: Record>): Tester; /** Auto import all libraries defined in this tester. */ importLibraries(): Tester; + /** Import the given paths */ import(...imports: string[]): Tester; + /** Add using statement for the given namespaces. */ using(...names: string[]): Tester; + /** Wrap the code of the `main.tsp` file */ wrap(fn: (x: string) => string): Tester; + /** Create an emitter tester */ + emit(emitter: string): EmitterTester; + /** Create an instance of the tester */ createInstance(): TesterInstance; } +export interface OutputTester { + compile( + code: string | Record, + options?: TestCompileOptions, + ): Promise; + compileAndDiagnose( + code: string | Record, + options?: TestCompileOptions, + ): Promise<[TestEmitterCompileResult, readonly Diagnostic[]]>; + diagnose( + code: string | Record, + options?: TestCompileOptions, + ): Promise; +} +/** Alternate version of the tester which runs the configured emitter */ +export interface EmitterTester extends OutputTester { + createInstance(): EmitterTesterInstance; +} + +export interface EmitterTesterInstance extends OutputTester { + get program(): Program; +} + export interface TesterInstance extends Testable { - readonly program: Program; + get program(): Program; } export interface TesterOptions { @@ -131,19 +169,52 @@ interface TesterInternalParams { usings?: string[]; } +interface EmitterTesterInternalParams extends TesterInternalParams { + emitter: string; +} + +function createEmitterTesterInternal(params: EmitterTesterInternalParams): EmitterTester { + const { compile, compileAndDiagnose, diagnose } = createEmitterTesterInstance(params); + return { + compile, + compileAndDiagnose, + diagnose, + createInstance: () => createEmitterTesterInstance(params), + }; +} + function createTesterInternal(params: TesterInternalParams): Tester { const { compile, compileAndDiagnose, diagnose } = createInstance(); return { compile, compileAndDiagnose, diagnose, + files, wrap, importLibraries, import: importFn, using, + emit, createInstance, }; + function files(files: Record>): Tester { + const fs = async () => { + const fs = (await params.fs()).clone(); + for (const [name, value] of Object.entries(files)) { + if (typeof value === "string") { + fs.addTypeSpecFile(name, value); + } else { + fs.addJsFile(name, value); + } + } + return fs; + }; + return createTesterInternal({ + ...params, + fs, + }); + } function wrap(fn: (x: string) => string): Tester { return createTesterInternal({ ...params, @@ -171,6 +242,12 @@ function createTesterInternal(params: TesterInternalParams): Tester { usings: [...(params.usings ?? []), ...usings], }); } + function emit(emitter: string): EmitterTester { + return createEmitterTesterInternal({ + ...params, + emitter, + }); + } function createInstance(): TesterInstance { return createTesterInstance({ @@ -183,6 +260,66 @@ function createTesterInternal(params: TesterInternalParams): Tester { } } +function createEmitterTesterInstance(params: EmitterTesterInternalParams): EmitterTesterInstance { + const tester = createTesterInstance(params); + return { + compile, + compileAndDiagnose, + diagnose, + get program() { + return tester.program; + }, + }; + + async function compile>( + code: T, + options?: TestCompileOptions, + ): Promise { + const [result, diagnostics] = await compileAndDiagnose(code, options); + expectDiagnosticEmpty(diagnostics); + return result; + } + async function diagnose( + code: string | Record, + options?: TestCompileOptions, + ): Promise { + const [_, diagnostics] = await compileAndDiagnose(code, options); + return diagnostics; + } + async function compileAndDiagnose( + code: string | Record, + options?: TestCompileOptions, + ): Promise<[TestEmitterCompileResult, readonly Diagnostic[]]> { + if (options?.options?.emit !== undefined) { + throw new Error("Cannot set emit in options."); + } + const resolvedOptions: TestCompileOptions = { + ...options, + options: { + ...options?.options, + outputDir: "tsp-output", + emit: [params.emitter], + }, + }; + const [result, diagnostics] = await tester.compileAndDiagnose(code, resolvedOptions); + const outputs: Record = {}; + const outputDir = resolveVirtualPath(resolvePath("tsp-output", params.emitter)); + for (const [name, value] of result.fs.fs) { + if (name.startsWith(outputDir)) { + const relativePath = name.slice(outputDir.length + 1); + outputs[relativePath] = value; + } + } + return [ + { + ...result, + outputs, + }, + diagnostics, + ]; + } +} + function createTesterInstance(params: TesterInternalParams): TesterInstance { let savedProgram: Program | undefined; @@ -209,6 +346,7 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { /** The file where the marker is located */ readonly filename: string; } + function addCode( fs: TestFileSystem, code: string | TemplateWithMarkers | Record>, @@ -257,6 +395,7 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { ].join("\n"); return actualCode; } + async function compileAndDiagnose< T extends string | TemplateWithMarkers | Record>, >( @@ -267,7 +406,11 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { const typesCollected = addTestLib(fs); const { markerPositions, markerConfigs } = addCode(fs, code); - const program = await coreCompile(fs.compilerHost, resolveVirtualPath("main.tsp")); + const program = await coreCompile( + fs.compilerHost, + resolveVirtualPath("main.tsp"), + options?.options, + ); savedProgram = program; const entities: Record = { ...typesCollected }; @@ -320,7 +463,7 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { (entities as any)[name] = entity; } } - return [{ program, ...entities } as any, program.diagnostics]; + return [{ program, fs, ...entities } as any, program.diagnostics]; } async function compile< diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/test-host-v2.test.ts index 89964d83af5..ed9c266509f 100644 --- a/packages/compiler/test/testing/test-host-v2.test.ts +++ b/packages/compiler/test/testing/test-host-v2.test.ts @@ -3,7 +3,15 @@ import { strictEqual } from "assert"; import { describe, expect, expectTypeOf, it } from "vitest"; import { resolvePath } from "../../src/core/path-utils.js"; -import { Enum, Model, Program } from "../../src/index.js"; +import { + EmitContext, + emitFile, + Enum, + getLocationContext, + Model, + navigateProgram, + Program, +} from "../../src/index.js"; import { t } from "../../src/testing/marked-template.js"; import { createTester } from "../../src/testing/test-host-v2.js"; @@ -206,3 +214,39 @@ it("still extract with multiple files", async () => { expect(res.A.kind).toBe("Model"); expect(res.B.kind).toBe("Enum"); }); + +describe("emitter", () => { + const EmitterTester = Tester.files({ + "node_modules/dummy-emitter/package.json": JSON.stringify({ + name: "dummy-emitter", + version: "1.0.0", + exports: { ".": "./index.js" }, + }), + "node_modules/dummy-emitter/index.js": { + $onEmit: (context: EmitContext) => { + navigateProgram(context.program, { + model: (model) => { + if (getLocationContext(context.program, model).type !== "project") return; + emitFile(context.program, { + path: resolvePath(context.emitterOutputDir, `${model.name}.model`), + content: model.name, + }); + }, + }); + }, + }, + }).emit("dummy-emitter"); + + it("return output", async () => { + const res = await EmitterTester.compile( + ` + model Foo {} + model Bar {} + `, + ); + expect(res.outputs).toEqual({ + "Foo.model": "Foo", + "Bar.model": "Bar", + }); + }); +}); From 9205a9862d84abed833f0f1c2e3c41e850eaaab3 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 13 May 2025 14:00:47 -0700 Subject: [PATCH 27/55] Separate FS and add generic functions --- packages/compiler/src/testing/fs.ts | 157 ++++++++++ packages/compiler/src/testing/index.ts | 13 +- .../src/testing/test-compiler-host.ts | 144 +++++++++ packages/compiler/src/testing/test-host-v2.ts | 22 +- packages/compiler/src/testing/test-host.ts | 274 +----------------- .../compiler/src/testing/test-server-host.ts | 3 +- packages/compiler/src/testing/types.ts | 28 +- .../test/testing/test-host-v2.test.ts | 5 +- 8 files changed, 349 insertions(+), 297 deletions(-) create mode 100644 packages/compiler/src/testing/fs.ts create mode 100644 packages/compiler/src/testing/test-compiler-host.ts diff --git a/packages/compiler/src/testing/fs.ts b/packages/compiler/src/testing/fs.ts new file mode 100644 index 00000000000..3bcb4d57a75 --- /dev/null +++ b/packages/compiler/src/testing/fs.ts @@ -0,0 +1,157 @@ +import { readdir, readFile, stat } from "fs/promises"; +import { join } from "path"; +import { pathToFileURL } from "url"; +import { getAnyExtensionFromPath, resolvePath } from "../core/path-utils.js"; +import { createStringMap } from "../utils/misc.js"; +import { createTestCompilerHost, TestHostOptions } from "./test-compiler-host.js"; +import { findFilesFromPattern } from "./test-host.js"; +import type { JsFile, MockFile, TestFileSystem, TypeSpecTestLibrary } from "./types.js"; + +// TODO: can we get rid of this +export function resolveVirtualPath(path: string, ...paths: string[]) { + // NB: We should always resolve an absolute path, and there is no absolute + // path that works across OSes. This ensures that we can still rely on API + // like pathToFileURL in tests. + const rootDir = process.platform === "win32" ? "Z:/test" : "/test"; + return resolvePath(rootDir, path, ...paths); +} + +/** + * Constructor for various mock files. + */ +export const mockFile = { + /** Define a JS file with the given named exports */ + js: (exports: Record): JsFile => { + return { kind: "js", exports }; + }, +}; + +export function createTestFileSystem(options?: TestHostOptions): TestFileSystem { + const virtualFs = createStringMap(!!options?.caseInsensitiveFileSystem); + const jsImports = createStringMap>(!!options?.caseInsensitiveFileSystem); + return createTestFileSystemInternal(virtualFs, jsImports, options); +} + +function createTestFileSystemInternal( + virtualFs: Map, + jsImports: Map>, + options?: TestHostOptions, +): TestFileSystem { + const compilerHost = createTestCompilerHost(virtualFs, jsImports, options); + + let frozen = false; + return { + add, + addTypeSpecFile, + addJsFile, + addRealTypeSpecFile, + addRealJsFile, + addRealFolder, + addTypeSpecLibrary, + fs: virtualFs, + compilerHost, + freeze, + clone, + }; + + function assertNotFrozen() { + if (frozen) { + throw new Error("Cannot modify the file system after it has been frozen."); + } + } + + function add(path: string, contents: MockFile) { + assertNotFrozen(); + if (typeof contents === "string") { + addRaw(path, contents); + } else { + addJsFile(path, contents.exports); + } + } + + function addRaw(path: string, contents: string) { + assertNotFrozen(); + virtualFs.set(resolveVirtualPath(path), contents); + } + + function addJsFile(path: string, contents: Record) { + assertNotFrozen(); + + const key = resolveVirtualPath(path); + virtualFs.set(key, ""); // don't need contents + jsImports.set(key, new Promise((r) => r(contents))); + } + + function addTypeSpecFile(path: string, contents: string) { + assertNotFrozen(); + virtualFs.set(resolveVirtualPath(path), contents); + } + + async function addRealTypeSpecFile(path: string, existingPath: string) { + assertNotFrozen(); + + virtualFs.set(resolveVirtualPath(path), await readFile(existingPath, "utf8")); + } + + async function addRealFolder(folder: string, existingFolder: string) { + assertNotFrozen(); + + const entries = await readdir(existingFolder); + for (const entry of entries) { + const existingPath = join(existingFolder, entry); + const virtualPath = join(folder, entry); + const s = await stat(existingPath); + if (s.isFile()) { + if (existingPath.endsWith(".js")) { + await addRealJsFile(virtualPath, existingPath); + } else { + await addRealTypeSpecFile(virtualPath, existingPath); + } + } + if (s.isDirectory()) { + await addRealFolder(virtualPath, existingPath); + } + } + } + + async function addRealJsFile(path: string, existingPath: string) { + assertNotFrozen(); + + const key = resolveVirtualPath(path); + const exports = await import(pathToFileURL(existingPath).href); + + virtualFs.set(key, ""); + jsImports.set(key, exports); + } + + async function addTypeSpecLibrary(testLibrary: TypeSpecTestLibrary) { + assertNotFrozen(); + + for (const { realDir, pattern, virtualPath } of testLibrary.files) { + const lookupDir = resolvePath(testLibrary.packageRoot, realDir); + const entries = await findFilesFromPattern(lookupDir, pattern); + for (const entry of entries) { + const fileRealPath = resolvePath(lookupDir, entry); + const fileVirtualPath = resolveVirtualPath(virtualPath, entry); + switch (getAnyExtensionFromPath(fileRealPath)) { + case ".tsp": + case ".json": + const contents = await readFile(fileRealPath, "utf-8"); + addTypeSpecFile(fileVirtualPath, contents); + break; + case ".js": + case ".mjs": + await addRealJsFile(fileVirtualPath, fileRealPath); + break; + } + } + } + } + function freeze() { + frozen = true; + } + + function clone() { + return createTestFileSystemInternal(new Map(virtualFs), new Map(jsImports), options); + } +} diff --git a/packages/compiler/src/testing/index.ts b/packages/compiler/src/testing/index.ts index be55847e5c2..6f60b550441 100644 --- a/packages/compiler/src/testing/index.ts +++ b/packages/compiler/src/testing/index.ts @@ -1,5 +1,6 @@ export { expectCodeFixOnAst } from "./code-fix-testing.js"; export { expectDiagnosticEmpty, expectDiagnostics, type DiagnosticMatch } from "./expect.js"; +export { createTestFileSystem, mockFile } from "./fs.js"; export { createLinterRuleTester, type ApplyCodeFixExpect, @@ -7,14 +8,8 @@ export { type LinterRuleTester, } from "./rule-tester.js"; export { extractCursor, extractSquiggles } from "./source-utils.js"; -export { - StandardTestLibrary, - createTestFileSystem, - createTestHost, - createTestRunner, - findFilesFromPattern, - type TestHostOptions, -} from "./test-host.js"; +export type { TestHostOptions } from "./test-compiler-host.js"; +export { createTestHost, createTestRunner, findFilesFromPattern } from "./test-host.js"; export { createTestLibrary, createTestWrapper, @@ -26,7 +21,7 @@ export { } from "./test-utils.js"; export type { BasicTestRunner, - TestFileSystem, + TestFileSystem as TestFileSystem, TestFiles, TestHost, TestHostConfig, diff --git a/packages/compiler/src/testing/test-compiler-host.ts b/packages/compiler/src/testing/test-compiler-host.ts new file mode 100644 index 00000000000..4ad412f24b4 --- /dev/null +++ b/packages/compiler/src/testing/test-compiler-host.ts @@ -0,0 +1,144 @@ +import { RmOptions } from "fs"; +import { fileURLToPath, pathToFileURL } from "url"; +import { CompilerPackageRoot, NodeHost } from "../core/node-host.js"; +import { createSourceFile, getSourceFileKindFromExt } from "../core/source-file.js"; +import { CompilerHost } from "../core/types.js"; +import { resolveVirtualPath } from "./fs.js"; +import { TestHostError, TypeSpecTestLibrary } from "./types.js"; + +export const StandardTestLibrary: TypeSpecTestLibrary = { + name: "@typespec/compiler", + packageRoot: CompilerPackageRoot, + files: [ + { virtualPath: "./.tsp/dist/src/lib", realDir: "./dist/src/lib", pattern: "**" }, + { virtualPath: "./.tsp/lib", realDir: "./lib", pattern: "**" }, + ], +}; + +export interface TestHostOptions { + caseInsensitiveFileSystem?: boolean; + excludeTestLib?: boolean; + compilerHostOverrides?: Partial; +} + +export function createTestCompilerHost( + virtualFs: Map, + jsImports: Map>, + options?: TestHostOptions, +): CompilerHost { + const libDirs = [resolveVirtualPath(".tsp/lib/std")]; + if (!options?.excludeTestLib) { + libDirs.push(resolveVirtualPath(".tsp/test-lib")); + } + + return { + async readUrl(url: string) { + const contents = virtualFs.get(url); + if (contents === undefined) { + throw new TestHostError(`File ${url} not found.`, "ENOENT"); + } + return createSourceFile(contents, url); + }, + async readFile(path: string) { + path = resolveVirtualPath(path); + const contents = virtualFs.get(path); + if (contents === undefined) { + throw new TestHostError(`File ${path} not found.`, "ENOENT"); + } + return createSourceFile(contents, path); + }, + + async writeFile(path: string, content: string) { + path = resolveVirtualPath(path); + virtualFs.set(path, content); + }, + + async readDir(path: string) { + path = resolveVirtualPath(path); + const fileFolder = [...virtualFs.keys()] + .filter((x) => x.startsWith(`${path}/`)) + .map((x) => x.replace(`${path}/`, "")) + .map((x) => { + const index = x.indexOf("/"); + return index !== -1 ? x.substring(0, index) : x; + }); + return [...new Set(fileFolder)]; + }, + + async rm(path: string, options: RmOptions) { + path = resolveVirtualPath(path); + + if (options.recursive && !virtualFs.has(path)) { + for (const key of virtualFs.keys()) { + if (key.startsWith(`${path}/`)) { + virtualFs.delete(key); + } + } + } else { + virtualFs.delete(path); + } + }, + + getLibDirs() { + return libDirs; + }, + + getExecutionRoot() { + return resolveVirtualPath(".tsp"); + }, + + async getJsImport(path) { + path = resolveVirtualPath(path); + const module = jsImports.get(path); + if (module === undefined) { + throw new TestHostError(`Module ${path} not found`, "ERR_MODULE_NOT_FOUND"); + } + return module; + }, + + async stat(path: string) { + path = resolveVirtualPath(path); + + if (virtualFs.has(path)) { + return { + isDirectory() { + return false; + }, + isFile() { + return true; + }, + }; + } + + for (const fsPath of virtualFs.keys()) { + if (fsPath.startsWith(path) && fsPath !== path) { + return { + isDirectory() { + return true; + }, + isFile() { + return false; + }, + }; + } + } + + throw new TestHostError(`File ${path} not found`, "ENOENT"); + }, + + // symlinks not supported in test-host + async realpath(path) { + return path; + }, + getSourceFileKind: getSourceFileKindFromExt, + + logSink: { log: NodeHost.logSink.log }, + mkdirp: async (path: string) => path, + fileURLToPath, + pathToFileURL(path: string) { + return pathToFileURL(path).href; + }, + + ...options?.compilerHostOverrides, + }; +} diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index 8357dea9501..105a0d915c9 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -11,10 +11,11 @@ import { createSourceLoader } from "../core/source-loader.js"; import { Diagnostic, Entity, NoTarget, SourceFile, StringLiteral, Type } from "../core/types.js"; import { expectDiagnosticEmpty } from "./expect.js"; import { PositionedMarker, extractMarkers } from "./fourslash.js"; +import { createTestFileSystem } from "./fs.js"; import { GetMarkedEntities, Marker, TemplateWithMarkers } from "./marked-template.js"; -import { StandardTestLibrary, createTestFileSystem } from "./test-host.js"; +import { StandardTestLibrary } from "./test-compiler-host.js"; import { resolveVirtualPath } from "./test-utils.js"; -import { TestFileSystem } from "./types.js"; +import { MockFile, TestFileSystem } from "./types.js"; // Need a way to combine that with `program` export type TestCompileResult> = T & { @@ -57,7 +58,7 @@ interface Testable { // Immutable structure meant to be reused export interface Tester extends Testable { /** Extend with the given list of files */ - files(files: Record>): Tester; + files(files: Record): Tester; /** Auto import all libraries defined in this tester. */ importLibraries(): Tester; /** Import the given paths */ @@ -145,17 +146,14 @@ async function createTesterFs(base: string, options: TesterOptions) { for (const file of sl.resolution.sourceFiles.values()) { const relativePath = computeVirtualPath(file.file); - fs.addTypeSpecFile(resolveVirtualPath(relativePath), file.file.text); + fs.add(resolveVirtualPath(relativePath), file.file.text); } for (const file of sl.resolution.jsSourceFiles.values()) { const relativePath = computeVirtualPath(file.file); fs.addJsFile(resolveVirtualPath(relativePath), file.esmExports); } for (const [path, lib] of sl.resolution.loadedLibraries) { - fs.addTypeSpecFile( - resolvePath("node_modules", path, "package.json"), - (lib.manifest as any).file.text, - ); + fs.add(resolvePath("node_modules", path, "package.json"), (lib.manifest as any).file.text); } fs.freeze(); return fs; @@ -198,15 +196,11 @@ function createTesterInternal(params: TesterInternalParams): Tester { createInstance, }; - function files(files: Record>): Tester { + function files(files: Record): Tester { const fs = async () => { const fs = (await params.fs()).clone(); for (const [name, value] of Object.entries(files)) { - if (typeof value === "string") { - fs.addTypeSpecFile(name, value); - } else { - fs.addJsFile(name, value); - } + fs.add(name, value); } return fs; }; diff --git a/packages/compiler/src/testing/test-host.ts b/packages/compiler/src/testing/test-host.ts index 43d0c8c54bb..a0205515265 100644 --- a/packages/compiler/src/testing/test-host.ts +++ b/packages/compiler/src/testing/test-host.ts @@ -1,279 +1,15 @@ import assert from "assert"; -import type { RmOptions } from "fs"; -import { readdir, readFile, stat } from "fs/promises"; import { globby } from "globby"; -import { join } from "path"; -import { fileURLToPath, pathToFileURL } from "url"; import { logDiagnostics, logVerboseTestOutput } from "../core/diagnostics.js"; import { createLogger } from "../core/logger/logger.js"; -import { NodeHost } from "../core/node-host.js"; import { CompilerOptions } from "../core/options.js"; -import { getAnyExtensionFromPath, resolvePath } from "../core/path-utils.js"; import { compile as compileProgram, Program } from "../core/program.js"; -import type { CompilerHost, Diagnostic, StringLiteral, Type } from "../core/types.js"; -import { createSourceFile, getSourceFileKindFromExt } from "../index.js"; -import { createStringMap } from "../utils/misc.js"; +import type { Diagnostic, StringLiteral, Type } from "../core/types.js"; import { expectDiagnosticEmpty } from "./expect.js"; -import { createTestWrapper, findTestPackageRoot, resolveVirtualPath } from "./test-utils.js"; -import { - BasicTestRunner, - TestFileSystem, - TestHost, - TestHostConfig, - TestHostError, - TypeSpecTestLibrary, -} from "./types.js"; - -export interface TestHostOptions { - caseInsensitiveFileSystem?: boolean; - excludeTestLib?: boolean; - compilerHostOverrides?: Partial; -} - -function createTestCompilerHost( - virtualFs: Map, - jsImports: Map>, - options?: TestHostOptions, -): CompilerHost { - const libDirs = [resolveVirtualPath(".tsp/lib/std")]; - if (!options?.excludeTestLib) { - libDirs.push(resolveVirtualPath(".tsp/test-lib")); - } - - return { - async readUrl(url: string) { - const contents = virtualFs.get(url); - if (contents === undefined) { - throw new TestHostError(`File ${url} not found.`, "ENOENT"); - } - return createSourceFile(contents, url); - }, - async readFile(path: string) { - path = resolveVirtualPath(path); - const contents = virtualFs.get(path); - if (contents === undefined) { - throw new TestHostError(`File ${path} not found.`, "ENOENT"); - } - return createSourceFile(contents, path); - }, - - async writeFile(path: string, content: string) { - path = resolveVirtualPath(path); - virtualFs.set(path, content); - }, - - async readDir(path: string) { - path = resolveVirtualPath(path); - const fileFolder = [...virtualFs.keys()] - .filter((x) => x.startsWith(`${path}/`)) - .map((x) => x.replace(`${path}/`, "")) - .map((x) => { - const index = x.indexOf("/"); - return index !== -1 ? x.substring(0, index) : x; - }); - return [...new Set(fileFolder)]; - }, - - async rm(path: string, options: RmOptions) { - path = resolveVirtualPath(path); - - if (options.recursive && !virtualFs.has(path)) { - for (const key of virtualFs.keys()) { - if (key.startsWith(`${path}/`)) { - virtualFs.delete(key); - } - } - } else { - virtualFs.delete(path); - } - }, - - getLibDirs() { - return libDirs; - }, - - getExecutionRoot() { - return resolveVirtualPath(".tsp"); - }, - - async getJsImport(path) { - path = resolveVirtualPath(path); - const module = jsImports.get(path); - if (module === undefined) { - throw new TestHostError(`Module ${path} not found`, "ERR_MODULE_NOT_FOUND"); - } - return module; - }, - - async stat(path: string) { - path = resolveVirtualPath(path); - - if (virtualFs.has(path)) { - return { - isDirectory() { - return false; - }, - isFile() { - return true; - }, - }; - } - - for (const fsPath of virtualFs.keys()) { - if (fsPath.startsWith(path) && fsPath !== path) { - return { - isDirectory() { - return true; - }, - isFile() { - return false; - }, - }; - } - } - - throw new TestHostError(`File ${path} not found`, "ENOENT"); - }, - - // symlinks not supported in test-host - async realpath(path) { - return path; - }, - getSourceFileKind: getSourceFileKindFromExt, - - logSink: { log: NodeHost.logSink.log }, - mkdirp: async (path: string) => path, - fileURLToPath, - pathToFileURL(path: string) { - return pathToFileURL(path).href; - }, - - ...options?.compilerHostOverrides, - }; -} - -export function createTestFileSystem(options?: TestHostOptions): TestFileSystem { - const virtualFs = createStringMap(!!options?.caseInsensitiveFileSystem); - const jsImports = createStringMap>(!!options?.caseInsensitiveFileSystem); - return createTestFileSystemInternal(virtualFs, jsImports, options); -} - -function createTestFileSystemInternal( - virtualFs: Map, - jsImports: Map>, - options?: TestHostOptions, -): TestFileSystem { - let frozen = false; - const compilerHost = createTestCompilerHost(virtualFs, jsImports, options); - return { - addTypeSpecFile, - addJsFile, - addRealTypeSpecFile, - addRealJsFile, - addRealFolder, - addTypeSpecLibrary, - compilerHost, - fs: virtualFs, - freeze, - clone, - }; - - function assertNotFrozen() { - if (frozen) { - throw new Error("Cannot modify the file system after it has been frozen."); - } - } - function addTypeSpecFile(path: string, contents: string) { - assertNotFrozen(); - virtualFs.set(resolveVirtualPath(path), contents); - } - - function addJsFile(path: string, contents: any) { - assertNotFrozen(); - - const key = resolveVirtualPath(path); - virtualFs.set(key, ""); // don't need contents - jsImports.set(key, new Promise((r) => r(contents))); - } - - async function addRealTypeSpecFile(path: string, existingPath: string) { - assertNotFrozen(); - - virtualFs.set(resolveVirtualPath(path), await readFile(existingPath, "utf8")); - } - - async function addRealFolder(folder: string, existingFolder: string) { - assertNotFrozen(); - - const entries = await readdir(existingFolder); - for (const entry of entries) { - const existingPath = join(existingFolder, entry); - const virtualPath = join(folder, entry); - const s = await stat(existingPath); - if (s.isFile()) { - if (existingPath.endsWith(".js")) { - await addRealJsFile(virtualPath, existingPath); - } else { - await addRealTypeSpecFile(virtualPath, existingPath); - } - } - if (s.isDirectory()) { - await addRealFolder(virtualPath, existingPath); - } - } - } - - async function addRealJsFile(path: string, existingPath: string) { - assertNotFrozen(); - - const key = resolveVirtualPath(path); - const exports = await import(pathToFileURL(existingPath).href); - - virtualFs.set(key, ""); - jsImports.set(key, exports); - } - - async function addTypeSpecLibrary(testLibrary: TypeSpecTestLibrary) { - assertNotFrozen(); - - for (const { realDir, pattern, virtualPath } of testLibrary.files) { - const lookupDir = resolvePath(testLibrary.packageRoot, realDir); - const entries = await findFilesFromPattern(lookupDir, pattern); - for (const entry of entries) { - const fileRealPath = resolvePath(lookupDir, entry); - const fileVirtualPath = resolveVirtualPath(virtualPath, entry); - switch (getAnyExtensionFromPath(fileRealPath)) { - case ".tsp": - case ".json": - const contents = await readFile(fileRealPath, "utf-8"); - addTypeSpecFile(fileVirtualPath, contents); - break; - case ".js": - case ".mjs": - await addRealJsFile(fileVirtualPath, fileRealPath); - break; - } - } - } - } - - function freeze() { - frozen = true; - } - - function clone() { - return createTestFileSystemInternal(new Map(virtualFs), new Map(jsImports), options); - } -} - -export const StandardTestLibrary: TypeSpecTestLibrary = { - name: "@typespec/compiler", - packageRoot: await findTestPackageRoot(import.meta.url), - files: [ - { virtualPath: "./.tsp/dist/src/lib", realDir: "./dist/src/lib", pattern: "**" }, - { virtualPath: "./.tsp/lib", realDir: "./lib", pattern: "**" }, - ], -}; +import { createTestFileSystem } from "./fs.js"; +import { StandardTestLibrary } from "./test-compiler-host.js"; +import { createTestWrapper, resolveVirtualPath } from "./test-utils.js"; +import { BasicTestRunner, TestHost, TestHostConfig, TypeSpecTestLibrary } from "./types.js"; export async function createTestHost(config: TestHostConfig = {}): Promise { const testHost = await createTestHostInternal(); diff --git a/packages/compiler/src/testing/test-server-host.ts b/packages/compiler/src/testing/test-server-host.ts index 2bc9251b6ab..9f992065d90 100644 --- a/packages/compiler/src/testing/test-server-host.ts +++ b/packages/compiler/src/testing/test-server-host.ts @@ -6,7 +6,8 @@ import { resolvePath } from "../core/path-utils.js"; import { IdentifierNode, SyntaxKind } from "../core/types.js"; import { Server, ServerHost, createServer } from "../server/index.js"; import { createStringMap } from "../utils/misc.js"; -import { StandardTestLibrary, TestHostOptions, createTestFileSystem } from "./test-host.js"; +import { createTestFileSystem } from "./fs.js"; +import { StandardTestLibrary, TestHostOptions } from "./test-compiler-host.js"; import { resolveVirtualPath } from "./test-utils.js"; import { TestFileSystem } from "./types.js"; diff --git a/packages/compiler/src/testing/types.ts b/packages/compiler/src/testing/types.ts index 60a82386c04..97fe1ea8e92 100644 --- a/packages/compiler/src/testing/types.ts +++ b/packages/compiler/src/testing/types.ts @@ -2,11 +2,24 @@ import type { CompilerOptions } from "../core/options.js"; import type { Program } from "../core/program.js"; import type { CompilerHost, Diagnostic, Type } from "../core/types.js"; +export type MockFile = string | JsFile; + +export interface JsFile { + readonly kind: "js"; + readonly exports: Record; +} + export interface TestFileSystem { - readonly compilerHost: CompilerHost; + /** Raw files */ readonly fs: Map; + readonly compilerHost: CompilerHost; + + /** Add a mock test file */ + add(path: string, content: MockFile): void; + /** Prefer using {@link add} */ addTypeSpecFile(path: string, contents: string): void; + /** Prefer using {@link add} */ addJsFile(path: string, contents: Record): void; addRealTypeSpecFile(path: string, realPath: string): Promise; addRealJsFile(path: string, realPath: string): Promise; @@ -20,7 +33,18 @@ export interface TestFileSystem { clone(): TestFileSystem; } -export interface TestHost extends TestFileSystem { +export interface TestHost + extends Pick< + TestFileSystem, + | "addTypeSpecFile" + | "addJsFile" + | "addRealTypeSpecFile" + | "addRealJsFile" + | "addRealFolder" + | "addTypeSpecLibrary" + | "compilerHost" + | "fs" + > { program: Program; libraries: TypeSpecTestLibrary[]; testTypes: Record; diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/test-host-v2.test.ts index ed9c266509f..d4a6c2f6fa8 100644 --- a/packages/compiler/test/testing/test-host-v2.test.ts +++ b/packages/compiler/test/testing/test-host-v2.test.ts @@ -12,6 +12,7 @@ import { navigateProgram, Program, } from "../../src/index.js"; +import { mockFile } from "../../src/testing/fs.js"; import { t } from "../../src/testing/marked-template.js"; import { createTester } from "../../src/testing/test-host-v2.js"; @@ -222,7 +223,7 @@ describe("emitter", () => { version: "1.0.0", exports: { ".": "./index.js" }, }), - "node_modules/dummy-emitter/index.js": { + "node_modules/dummy-emitter/index.js": mockFile.js({ $onEmit: (context: EmitContext) => { navigateProgram(context.program, { model: (model) => { @@ -234,7 +235,7 @@ describe("emitter", () => { }, }); }, - }, + }), }).emit("dummy-emitter"); it("return output", async () => { From afc34634699beb76b91a417cfc76ef02d7fc2994 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 13 May 2025 14:03:03 -0700 Subject: [PATCH 28/55] try --- packages/compiler/src/testing/fs.ts | 2 +- packages/compiler/src/testing/test-compiler-host.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/testing/fs.ts b/packages/compiler/src/testing/fs.ts index 3bcb4d57a75..f5e7df16383 100644 --- a/packages/compiler/src/testing/fs.ts +++ b/packages/compiler/src/testing/fs.ts @@ -12,7 +12,7 @@ export function resolveVirtualPath(path: string, ...paths: string[]) { // NB: We should always resolve an absolute path, and there is no absolute // path that works across OSes. This ensures that we can still rely on API // like pathToFileURL in tests. - const rootDir = process.platform === "win32" ? "Z:/test" : "/test"; + const rootDir = process.platform === "win32" ? "/test" : "/test"; return resolvePath(rootDir, path, ...paths); } diff --git a/packages/compiler/src/testing/test-compiler-host.ts b/packages/compiler/src/testing/test-compiler-host.ts index 4ad412f24b4..aef900796dd 100644 --- a/packages/compiler/src/testing/test-compiler-host.ts +++ b/packages/compiler/src/testing/test-compiler-host.ts @@ -1,5 +1,5 @@ import { RmOptions } from "fs"; -import { fileURLToPath, pathToFileURL } from "url"; +import { fileURLToPath } from "url"; import { CompilerPackageRoot, NodeHost } from "../core/node-host.js"; import { createSourceFile, getSourceFileKindFromExt } from "../core/source-file.js"; import { CompilerHost } from "../core/types.js"; @@ -136,7 +136,7 @@ export function createTestCompilerHost( mkdirp: async (path: string) => path, fileURLToPath, pathToFileURL(path: string) { - return pathToFileURL(path).href; + return `file://${path}`; }, ...options?.compilerHostOverrides, From f2874653a39fc3dcfb743ff9a05a0fc8decee9be Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 13 May 2025 14:11:28 -0700 Subject: [PATCH 29/55] Create tester --- .../src/testing/test-compiler-host.ts | 26 ++++++++++++-- packages/compiler/src/testing/test-host-v2.ts | 26 ++------------ packages/compiler/src/testing/test-host.ts | 36 +++---------------- 3 files changed, 31 insertions(+), 57 deletions(-) diff --git a/packages/compiler/src/testing/test-compiler-host.ts b/packages/compiler/src/testing/test-compiler-host.ts index aef900796dd..3450b914eee 100644 --- a/packages/compiler/src/testing/test-compiler-host.ts +++ b/packages/compiler/src/testing/test-compiler-host.ts @@ -2,9 +2,9 @@ import { RmOptions } from "fs"; import { fileURLToPath } from "url"; import { CompilerPackageRoot, NodeHost } from "../core/node-host.js"; import { createSourceFile, getSourceFileKindFromExt } from "../core/source-file.js"; -import { CompilerHost } from "../core/types.js"; +import { CompilerHost, StringLiteral, Type } from "../core/types.js"; import { resolveVirtualPath } from "./fs.js"; -import { TestHostError, TypeSpecTestLibrary } from "./types.js"; +import { TestFileSystem, TestHostError, TypeSpecTestLibrary } from "./types.js"; export const StandardTestLibrary: TypeSpecTestLibrary = { name: "@typespec/compiler", @@ -142,3 +142,25 @@ export function createTestCompilerHost( ...options?.compilerHostOverrides, }; } + +export function addTestLib(fs: TestFileSystem): Record { + const testTypes: Record = {}; + // add test decorators + fs.add(".tsp/test-lib/main.tsp", 'import "./test.js";'); + fs.addJsFile(".tsp/test-lib/test.js", { + namespace: "TypeSpec", + $test(_: any, target: Type, nameLiteral?: StringLiteral) { + let name = nameLiteral?.value; + if (!name) { + if ("name" in target && typeof target.name === "string") { + name = target.name; + } else { + throw new Error("Need to specify a name for test type"); + } + } + + testTypes[name] = target; + }, + }); + return testTypes; +} diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index 105a0d915c9..b65e193a6fd 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -8,12 +8,12 @@ import { getNodeAtPosition } from "../core/parser.js"; import { getRelativePathFromDirectory, joinPaths, resolvePath } from "../core/path-utils.js"; import { Program, compile as coreCompile } from "../core/program.js"; import { createSourceLoader } from "../core/source-loader.js"; -import { Diagnostic, Entity, NoTarget, SourceFile, StringLiteral, Type } from "../core/types.js"; +import { Diagnostic, Entity, NoTarget, SourceFile } from "../core/types.js"; import { expectDiagnosticEmpty } from "./expect.js"; import { PositionedMarker, extractMarkers } from "./fourslash.js"; import { createTestFileSystem } from "./fs.js"; import { GetMarkedEntities, Marker, TemplateWithMarkers } from "./marked-template.js"; -import { StandardTestLibrary } from "./test-compiler-host.js"; +import { StandardTestLibrary, addTestLib } from "./test-compiler-host.js"; import { resolveVirtualPath } from "./test-utils.js"; import { MockFile, TestFileSystem } from "./types.js"; @@ -475,25 +475,3 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { return diagnostics; } } - -function addTestLib(fs: TestFileSystem): Record { - const testTypes: Record = {}; - // add test decorators - fs.addTypeSpecFile(".tsp/test-lib/main.tsp", 'import "./test.js";'); - fs.addJsFile(".tsp/test-lib/test.js", { - namespace: "TypeSpec", - $test(_: any, target: Type, nameLiteral?: StringLiteral) { - let name = nameLiteral?.value; - if (!name) { - if ("name" in target && typeof target.name === "string") { - name = target.name; - } else { - throw new Error("Need to specify a name for test type"); - } - } - - testTypes[name] = target; - }, - }); - return testTypes; -} diff --git a/packages/compiler/src/testing/test-host.ts b/packages/compiler/src/testing/test-host.ts index a0205515265..a62e5b33787 100644 --- a/packages/compiler/src/testing/test-host.ts +++ b/packages/compiler/src/testing/test-host.ts @@ -4,13 +4,14 @@ import { logDiagnostics, logVerboseTestOutput } from "../core/diagnostics.js"; import { createLogger } from "../core/logger/logger.js"; import { CompilerOptions } from "../core/options.js"; import { compile as compileProgram, Program } from "../core/program.js"; -import type { Diagnostic, StringLiteral, Type } from "../core/types.js"; +import type { Diagnostic, Type } from "../core/types.js"; import { expectDiagnosticEmpty } from "./expect.js"; import { createTestFileSystem } from "./fs.js"; -import { StandardTestLibrary } from "./test-compiler-host.js"; +import { addTestLib, StandardTestLibrary } from "./test-compiler-host.js"; import { createTestWrapper, resolveVirtualPath } from "./test-utils.js"; import { BasicTestRunner, TestHost, TestHostConfig, TypeSpecTestLibrary } from "./types.js"; +/** Use {@link createTester} */ export async function createTestHost(config: TestHostConfig = {}): Promise { const testHost = await createTestHostInternal(); await testHost.addTypeSpecLibrary(StandardTestLibrary); @@ -22,6 +23,7 @@ export async function createTestHost(config: TestHostConfig = {}): Promise { const testHost = host ?? (await createTestHost()); return createTestWrapper(testHost); @@ -30,36 +32,8 @@ export async function createTestRunner(host?: TestHost): Promise { let program: Program | undefined; const libraries: TypeSpecTestLibrary[] = []; - const testTypes: Record = {}; const fileSystem = await createTestFileSystem(); - - // add test decorators - fileSystem.addTypeSpecFile(".tsp/test-lib/main.tsp", 'import "./test.js";'); - fileSystem.addJsFile(".tsp/test-lib/test.js", { - namespace: "TypeSpec", - $test(_: any, target: Type, nameLiteral?: StringLiteral) { - let name = nameLiteral?.value; - if (!name) { - if ( - target.kind === "Model" || - target.kind === "Scalar" || - target.kind === "Namespace" || - target.kind === "Enum" || - target.kind === "Operation" || - target.kind === "ModelProperty" || - target.kind === "EnumMember" || - target.kind === "Interface" || - (target.kind === "Union" && !target.expression) - ) { - name = target.name!; - } else { - throw new Error("Need to specify a name for test type"); - } - } - - testTypes[name] = target; - }, - }); + const testTypes = addTestLib(fileSystem); return { ...fileSystem, From a64c1e0b34f5776215091a6894bbbc1303a7992a Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 13 May 2025 14:16:06 -0700 Subject: [PATCH 30/55] Missing --- packages/compiler/src/testing/index.ts | 15 ++- packages/compiler/src/testing/test-host-v2.ts | 97 +++---------------- packages/compiler/src/testing/types.ts | 93 +++++++++++++++++- 3 files changed, 115 insertions(+), 90 deletions(-) diff --git a/packages/compiler/src/testing/index.ts b/packages/compiler/src/testing/index.ts index 6f60b550441..f28b7dbb3d1 100644 --- a/packages/compiler/src/testing/index.ts +++ b/packages/compiler/src/testing/index.ts @@ -1,6 +1,7 @@ export { expectCodeFixOnAst } from "./code-fix-testing.js"; export { expectDiagnosticEmpty, expectDiagnostics, type DiagnosticMatch } from "./expect.js"; export { createTestFileSystem, mockFile } from "./fs.js"; +export { t } from "./marked-template.js"; export { createLinterRuleTester, type ApplyCodeFixExpect, @@ -9,6 +10,7 @@ export { } from "./rule-tester.js"; export { extractCursor, extractSquiggles } from "./source-utils.js"; export type { TestHostOptions } from "./test-compiler-host.js"; +export { createTester } from "./test-host-v2.js"; export { createTestHost, createTestRunner, findFilesFromPattern } from "./test-host.js"; export { createTestLibrary, @@ -21,15 +23,20 @@ export { } from "./test-utils.js"; export type { BasicTestRunner, + EmitterTester, + EmitterTesterInstance, + JsFile, + MockFile, + TestCompileOptions, + TestCompileResult, + TestEmitterCompileResult, TestFileSystem as TestFileSystem, TestFiles, TestHost, TestHostConfig, TestHostError, + Tester, + TesterInstance, TypeSpecTestLibrary, TypeSpecTestLibraryInit, } from "./types.js"; - -// TODO: use named imports -export { t } from "./marked-template.js"; -export * from "./test-host-v2.js"; diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index b65e193a6fd..dd993d41879 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -3,7 +3,6 @@ import { getSymNode } from "../core/binder.js"; import { compilerAssert } from "../core/diagnostics.js"; import { getEntityName } from "../core/helpers/type-name-utils.js"; import { NodeHost } from "../core/node-host.js"; -import { CompilerOptions } from "../core/options.js"; import { getNodeAtPosition } from "../core/parser.js"; import { getRelativePathFromDirectory, joinPaths, resolvePath } from "../core/path-utils.js"; import { Program, compile as coreCompile } from "../core/program.js"; @@ -15,90 +14,17 @@ import { createTestFileSystem } from "./fs.js"; import { GetMarkedEntities, Marker, TemplateWithMarkers } from "./marked-template.js"; import { StandardTestLibrary, addTestLib } from "./test-compiler-host.js"; import { resolveVirtualPath } from "./test-utils.js"; -import { MockFile, TestFileSystem } from "./types.js"; - -// Need a way to combine that with `program` -export type TestCompileResult> = T & { - /** The program created in this test compilation. */ - readonly program: Program; - - /** File system */ - readonly fs: TestFileSystem; -} & Record; - -export interface TestEmitterCompileResult { - /** The program created in this test compilation. */ - readonly program: Program; - - /** Files written to the emitter output dir. */ - readonly outputs: Record; -} - -interface TestCompileOptions { - /** Optional compiler options */ - readonly options?: CompilerOptions; -} - -interface Testable { - compile< - T extends string | TemplateWithMarkers | Record>, - >( - code: T, - options?: TestCompileOptions, - ): Promise>>; - diagnose(main: string, options?: TestCompileOptions): Promise; - compileAndDiagnose< - T extends string | TemplateWithMarkers | Record>, - >( - code: T, - options?: TestCompileOptions, - ): Promise<[TestCompileResult>, readonly Diagnostic[]]>; -} - -// Immutable structure meant to be reused -export interface Tester extends Testable { - /** Extend with the given list of files */ - files(files: Record): Tester; - /** Auto import all libraries defined in this tester. */ - importLibraries(): Tester; - /** Import the given paths */ - import(...imports: string[]): Tester; - /** Add using statement for the given namespaces. */ - using(...names: string[]): Tester; - /** Wrap the code of the `main.tsp` file */ - wrap(fn: (x: string) => string): Tester; - /** Create an emitter tester */ - emit(emitter: string): EmitterTester; - /** Create an instance of the tester */ - createInstance(): TesterInstance; -} - -export interface OutputTester { - compile( - code: string | Record, - options?: TestCompileOptions, - ): Promise; - compileAndDiagnose( - code: string | Record, - options?: TestCompileOptions, - ): Promise<[TestEmitterCompileResult, readonly Diagnostic[]]>; - diagnose( - code: string | Record, - options?: TestCompileOptions, - ): Promise; -} -/** Alternate version of the tester which runs the configured emitter */ -export interface EmitterTester extends OutputTester { - createInstance(): EmitterTesterInstance; -} - -export interface EmitterTesterInstance extends OutputTester { - get program(): Program; -} - -export interface TesterInstance extends Testable { - get program(): Program; -} +import type { + EmitterTester, + EmitterTesterInstance, + MockFile, + TestCompileOptions, + TestCompileResult, + TestEmitterCompileResult, + TestFileSystem, + Tester, + TesterInstance, +} from "./types.js"; export interface TesterOptions { libraries: string[]; @@ -202,6 +128,7 @@ function createTesterInternal(params: TesterInternalParams): Tester { for (const [name, value] of Object.entries(files)) { fs.add(name, value); } + fs.freeze(); return fs; }; return createTesterInternal({ diff --git a/packages/compiler/src/testing/types.ts b/packages/compiler/src/testing/types.ts index 97fe1ea8e92..a679f3681f7 100644 --- a/packages/compiler/src/testing/types.ts +++ b/packages/compiler/src/testing/types.ts @@ -1,7 +1,9 @@ import type { CompilerOptions } from "../core/options.js"; import type { Program } from "../core/program.js"; -import type { CompilerHost, Diagnostic, Type } from "../core/types.js"; +import type { CompilerHost, Diagnostic, Entity, Type } from "../core/types.js"; +import { GetMarkedEntities, TemplateWithMarkers } from "./marked-template.js"; +// #region Test file system export type MockFile = string | JsFile; export interface JsFile { @@ -33,6 +35,94 @@ export interface TestFileSystem { clone(): TestFileSystem; } +//#endregion + +// #region Tester + +export type TestCompileResult> = T & { + /** The program created in this test compilation. */ + readonly program: Program; + + /** File system */ + readonly fs: TestFileSystem; +} & Record; + +export interface TestEmitterCompileResult { + /** The program created in this test compilation. */ + readonly program: Program; + + /** Files written to the emitter output dir. */ + readonly outputs: Record; +} + +export interface TestCompileOptions { + /** Optional compiler options */ + readonly options?: CompilerOptions; +} + +interface Testable { + compile< + T extends string | TemplateWithMarkers | Record>, + >( + code: T, + options?: TestCompileOptions, + ): Promise>>; + diagnose(main: string, options?: TestCompileOptions): Promise; + compileAndDiagnose< + T extends string | TemplateWithMarkers | Record>, + >( + code: T, + options?: TestCompileOptions, + ): Promise<[TestCompileResult>, readonly Diagnostic[]]>; +} + +// Immutable structure meant to be reused +export interface Tester extends Testable { + /** Extend with the given list of files */ + files(files: Record): Tester; + /** Auto import all libraries defined in this tester. */ + importLibraries(): Tester; + /** Import the given paths */ + import(...imports: string[]): Tester; + /** Add using statement for the given namespaces. */ + using(...names: string[]): Tester; + /** Wrap the code of the `main.tsp` file */ + wrap(fn: (x: string) => string): Tester; + /** Create an emitter tester */ + emit(emitter: string): EmitterTester; + /** Create an instance of the tester */ + createInstance(): TesterInstance; +} + +export interface OutputTester { + compile( + code: string | Record, + options?: TestCompileOptions, + ): Promise; + compileAndDiagnose( + code: string | Record, + options?: TestCompileOptions, + ): Promise<[TestEmitterCompileResult, readonly Diagnostic[]]>; + diagnose( + code: string | Record, + options?: TestCompileOptions, + ): Promise; +} +/** Alternate version of the tester which runs the configured emitter */ +export interface EmitterTester extends OutputTester { + createInstance(): EmitterTesterInstance; +} + +export interface EmitterTesterInstance extends OutputTester { + get program(): Program; +} + +export interface TesterInstance extends Testable { + get program(): Program; +} +// #endregion + +// #region Legacy Test host export interface TestHost extends Pick< TestFileSystem, @@ -123,3 +213,4 @@ export interface BasicTestRunner { options?: CompilerOptions, ): Promise<[Record, readonly Diagnostic[]]>; } +// #endregion From 9f3c4a3356bcafbd5e0fbb01911c792c1e52e6c9 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 13 May 2025 14:49:36 -0700 Subject: [PATCH 31/55] fix --- packages/compiler/src/testing/fs.ts | 2 +- packages/compiler/src/testing/test-host-v2.ts | 210 +++++++++--------- packages/compiler/src/testing/types.ts | 6 +- .../test/testing/test-host-v2.test.ts | 26 +++ 4 files changed, 139 insertions(+), 105 deletions(-) diff --git a/packages/compiler/src/testing/fs.ts b/packages/compiler/src/testing/fs.ts index f5e7df16383..3bcb4d57a75 100644 --- a/packages/compiler/src/testing/fs.ts +++ b/packages/compiler/src/testing/fs.ts @@ -12,7 +12,7 @@ export function resolveVirtualPath(path: string, ...paths: string[]) { // NB: We should always resolve an absolute path, and there is no absolute // path that works across OSes. This ensures that we can still rely on API // like pathToFileURL in tests. - const rootDir = process.platform === "win32" ? "/test" : "/test"; + const rootDir = process.platform === "win32" ? "Z:/test" : "/test"; return resolvePath(rootDir, path, ...paths); } diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index dd993d41879..42f5b98cd0b 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -98,21 +98,28 @@ interface EmitterTesterInternalParams extends TesterInternalParams { } function createEmitterTesterInternal(params: EmitterTesterInternalParams): EmitterTester { - const { compile, compileAndDiagnose, diagnose } = createEmitterTesterInstance(params); return { - compile, - compileAndDiagnose, - diagnose, - createInstance: () => createEmitterTesterInstance(params), + ...createCompilable(async (...args) => { + const instance = await createEmitterTesterInstance(params); + return instance.compileAndDiagnose(...args); + }), + createInstance: () => + createEmitterTesterInstance({ + ...params, + fs: async () => { + const fs = await params.fs(); + return fs.clone(); + }, + }), }; } function createTesterInternal(params: TesterInternalParams): Tester { - const { compile, compileAndDiagnose, diagnose } = createInstance(); return { - compile, - compileAndDiagnose, - diagnose, + ...createCompilable(async (...args) => { + const instance = await createTesterInstance(params); + return instance.compileAndDiagnose(...args); + }), files, wrap, importLibraries, @@ -170,7 +177,7 @@ function createTesterInternal(params: TesterInternalParams): Tester { }); } - function createInstance(): TesterInstance { + function createInstance(): Promise { return createTesterInstance({ ...params, fs: async () => { @@ -181,32 +188,18 @@ function createTesterInternal(params: TesterInternalParams): Tester { } } -function createEmitterTesterInstance(params: EmitterTesterInternalParams): EmitterTesterInstance { - const tester = createTesterInstance(params); +async function createEmitterTesterInstance( + params: EmitterTesterInternalParams, +): Promise { + const tester = await createTesterInstance(params); return { - compile, - compileAndDiagnose, - diagnose, + fs: tester.fs, + ...createCompilable(compileAndDiagnose), get program() { return tester.program; }, }; - async function compile>( - code: T, - options?: TestCompileOptions, - ): Promise { - const [result, diagnostics] = await compileAndDiagnose(code, options); - expectDiagnosticEmpty(diagnostics); - return result; - } - async function diagnose( - code: string | Record, - options?: TestCompileOptions, - ): Promise { - const [_, diagnostics] = await compileAndDiagnose(code, options); - return diagnostics; - } async function compileAndDiagnose( code: string | Record, options?: TestCompileOptions, @@ -241,13 +234,13 @@ function createEmitterTesterInstance(params: EmitterTesterInternalParams): Emitt } } -function createTesterInstance(params: TesterInternalParams): TesterInstance { +async function createTesterInstance(params: TesterInternalParams): Promise { let savedProgram: Program | undefined; + const fs = await params.fs(); return { - compileAndDiagnose, - compile, - diagnose, + ...createCompilable(compileAndDiagnose), + fs, get program() { if (!savedProgram) { throw new Error("Program not initialized. Call compile first."); @@ -263,11 +256,6 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { return code; } - interface PositionedMarkerInFile extends PositionedMarker { - /** The file where the marker is located */ - readonly filename: string; - } - function addCode( fs: TestFileSystem, code: string | TemplateWithMarkers | Record>, @@ -323,7 +311,6 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { code: T, options?: TestCompileOptions, ): Promise<[TestCompileResult>, readonly Diagnostic[]]> { - const fs = await params.fs(); const typesCollected = addTestLib(fs); const { markerPositions, markerConfigs } = addCode(fs, code); @@ -334,71 +321,90 @@ function createTesterInstance(params: TesterInternalParams): TesterInstance { ); savedProgram = program; - const entities: Record = { ...typesCollected }; - if (typeof code !== "string") { - for (const marker of markerPositions) { - const file = program.sourceFiles.get(resolveVirtualPath(marker.filename)); - if (!file) { - throw new Error(`Couldn't find ${resolveVirtualPath(marker.filename)} in program`); - } - const { name, pos } = marker; - const markerConfig = markerConfigs[name]; - const node = getNodeAtPosition(file, pos); - if (!node) { - throw new Error(`Could not find node at ${pos}`); - } - const sym = program.checker.resolveRelatedSymbols(node as any)?.[0]; - if (sym === undefined) { - throw new Error( - `Could not find symbol for ${name} at ${pos}. File content: ${file.file.text}`, - ); - } - const entity = program.checker.getTypeOrValueForNode(getSymNode(sym)); - if (entity === null) { - throw new Error( - `Expected ${name} to be of entity kind ${markerConfig?.entityKind} but got null (Means a value failed to resolve) at ${pos}`, - ); - } - if (markerConfig) { - const { entityKind, kind, valueKind } = markerConfig as any; - if (entity.entityKind !== entityKind) { - throw new Error( - `Expected ${name} to be of entity kind ${entityKind} but got (${entity?.entityKind}) ${getEntityName(entity)} at ${pos}`, - ); - } - if (entity.entityKind === "Type" && kind !== undefined && entity.kind !== kind) { - throw new Error( - `Expected ${name} to be of kind ${kind} but got (${entity.kind}) ${getEntityName(entity)} at ${pos}`, - ); - } else if ( - entity?.entityKind === "Value" && - valueKind !== undefined && - entity.valueKind !== valueKind - ) { - throw new Error( - `Expected ${name} to be of value kind ${valueKind} but got (${entity.valueKind}) ${getEntityName(entity)} at ${pos}`, - ); - } - } + const entities = extractMarkedEntities(program, markerPositions, markerConfigs); + return [{ program, fs, ...typesCollected, ...entities } as any, program.diagnostics]; + } +} + +interface PositionedMarkerInFile extends PositionedMarker { + /** The file where the marker is located */ + readonly filename: string; +} - (entities as any)[name] = entity; +function extractMarkedEntities( + program: Program, + markerPositions: PositionedMarkerInFile[], + markerConfigs: Record>, +) { + const entities: Record = {}; + for (const marker of markerPositions) { + const file = program.sourceFiles.get(resolveVirtualPath(marker.filename)); + if (!file) { + throw new Error(`Couldn't find ${resolveVirtualPath(marker.filename)} in program`); + } + const { name, pos } = marker; + const markerConfig = markerConfigs[name]; + const node = getNodeAtPosition(file, pos); + if (!node) { + throw new Error(`Could not find node at ${pos}`); + } + const sym = program.checker.resolveRelatedSymbols(node as any)?.[0]; + if (sym === undefined) { + throw new Error( + `Could not find symbol for ${name} at ${pos}. File content: ${file.file.text}`, + ); + } + const entity = program.checker.getTypeOrValueForNode(getSymNode(sym)); + if (entity === null) { + throw new Error( + `Expected ${name} to be of entity kind ${markerConfig?.entityKind} but got null (Means a value failed to resolve) at ${pos}`, + ); + } + if (markerConfig) { + const { entityKind, kind, valueKind } = markerConfig as any; + if (entity.entityKind !== entityKind) { + throw new Error( + `Expected ${name} to be of entity kind ${entityKind} but got (${entity?.entityKind}) ${getEntityName(entity)} at ${pos}`, + ); + } + if (entity.entityKind === "Type" && kind !== undefined && entity.kind !== kind) { + throw new Error( + `Expected ${name} to be of kind ${kind} but got (${entity.kind}) ${getEntityName(entity)} at ${pos}`, + ); + } else if ( + entity?.entityKind === "Value" && + valueKind !== undefined && + entity.valueKind !== valueKind + ) { + throw new Error( + `Expected ${name} to be of value kind ${valueKind} but got (${entity.valueKind}) ${getEntityName(entity)} at ${pos}`, + ); } } - return [{ program, fs, ...entities } as any, program.diagnostics]; - } - async function compile< - T extends string | TemplateWithMarkers | Record>, - >(code: T, options?: TestCompileOptions): Promise>> { - const [result, diagnostics] = await compileAndDiagnose(code, options); - expectDiagnosticEmpty(diagnostics); - return result; - } - async function diagnose( - code: string | TemplateWithMarkers | Record>, - options?: TestCompileOptions, - ): Promise { - const [_, diagnostics] = await compileAndDiagnose(code, options); - return diagnostics; + entities[name] = entity; } + return entities; +} + +export interface Compilable { + compileAndDiagnose(...args: A): Promise<[R, readonly Diagnostic[]]>; + compile(...args: A): Promise; + diagnose(...args: A): Promise; +} +function createCompilable( + fn: (...args: A) => Promise<[R, readonly Diagnostic[]]>, +): Compilable { + return { + compileAndDiagnose: fn, + compile: async (...args: A) => { + const [result, diagnostics] = await fn(...args); + expectDiagnosticEmpty(diagnostics); + return result; + }, + diagnose: async (...args: A) => { + const [_, diagnostics] = await fn(...args); + return diagnostics; + }, + }; } diff --git a/packages/compiler/src/testing/types.ts b/packages/compiler/src/testing/types.ts index a679f3681f7..98d5322a1e7 100644 --- a/packages/compiler/src/testing/types.ts +++ b/packages/compiler/src/testing/types.ts @@ -91,7 +91,7 @@ export interface Tester extends Testable { /** Create an emitter tester */ emit(emitter: string): EmitterTester; /** Create an instance of the tester */ - createInstance(): TesterInstance; + createInstance(): Promise; } export interface OutputTester { @@ -110,15 +110,17 @@ export interface OutputTester { } /** Alternate version of the tester which runs the configured emitter */ export interface EmitterTester extends OutputTester { - createInstance(): EmitterTesterInstance; + createInstance(): Promise; } export interface EmitterTesterInstance extends OutputTester { get program(): Program; + readonly fs: TestFileSystem; } export interface TesterInstance extends Testable { get program(): Program; + readonly fs: TestFileSystem; } // #endregion diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/test-host-v2.test.ts index d4a6c2f6fa8..709c97ef5bb 100644 --- a/packages/compiler/test/testing/test-host-v2.test.ts +++ b/packages/compiler/test/testing/test-host-v2.test.ts @@ -216,6 +216,17 @@ it("still extract with multiple files", async () => { expect(res.B.kind).toBe("Enum"); }); +it("add extra files via fs api", async () => { + const tester = await Tester.createInstance(); + tester.fs.add("foo.tsp", "model Foo {}"); + await tester.compile( + ` + import "./foo.tsp"; + model Bar {} + `, + ); +}); + describe("emitter", () => { const EmitterTester = Tester.files({ "node_modules/dummy-emitter/package.json": JSON.stringify({ @@ -250,4 +261,19 @@ describe("emitter", () => { "Bar.model": "Bar", }); }); + + it("add extra files via fs api", async () => { + const tester = await EmitterTester.createInstance(); + tester.fs.add("foo.tsp", "model Foo {}"); + const res = await tester.compile( + ` + import "./foo.tsp"; + model Bar {} + `, + ); + expect(res.outputs).toEqual({ + "Foo.model": "Foo", + "Bar.model": "Bar", + }); + }); }); From 12402d29bcad2a8a814b08e5deac077803ae3d57 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 13 May 2025 15:33:50 -0700 Subject: [PATCH 32/55] fixup and basic connect to openapi3 --- packages/compiler/src/testing/test-host-v2.ts | 61 ++++++++++++------- packages/openapi3/src/testing/index.ts | 1 + packages/openapi3/test/test-host.ts | 51 +++++++++++----- 3 files changed, 77 insertions(+), 36 deletions(-) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index 42f5b98cd0b..b21ce061d45 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -1,4 +1,5 @@ -import { readFile } from "fs/promises"; +import { readFile, realpath } from "fs/promises"; +import { pathToFileURL } from "url"; import { getSymNode } from "../core/binder.js"; import { compilerAssert } from "../core/diagnostics.js"; import { getEntityName } from "../core/helpers/type-name-utils.js"; @@ -7,7 +8,8 @@ import { getNodeAtPosition } from "../core/parser.js"; import { getRelativePathFromDirectory, joinPaths, resolvePath } from "../core/path-utils.js"; import { Program, compile as coreCompile } from "../core/program.js"; import { createSourceLoader } from "../core/source-loader.js"; -import { Diagnostic, Entity, NoTarget, SourceFile } from "../core/types.js"; +import { CompilerHost, Diagnostic, Entity, NoTarget, SourceFile } from "../core/types.js"; +import { resolveModule } from "../module-resolver/module-resolver.js"; import { expectDiagnosticEmpty } from "./expect.js"; import { PositionedMarker, extractMarkers } from "./fourslash.js"; import { createTestFileSystem } from "./fs.js"; @@ -48,10 +50,36 @@ function once(fn: () => Promise): () => Promise { async function createTesterFs(base: string, options: TesterOptions) { const fs = createTestFileSystem(); - const sl = await createSourceLoader({ ...NodeHost, realpath: async (x) => x }); + const host: CompilerHost = { + ...NodeHost, + // We want to keep the original path in the file map but we do still want to resolve the full path when loading JS to prevent duplicate imports. + realpath: async (x: string) => x, + getJsImport: async (path: string) => { + return await import(pathToFileURL(await realpath(path)).href); + }, + }; + + const sl = await createSourceLoader(host); const selfName = JSON.parse(await readFile(resolvePath(base, "package.json"), "utf8")).name; for (const lib of options.libraries) { await sl.importPath(lib, NoTarget, base); + + const resolved = await resolveModule( + { + realpath: async (x) => x, + stat: NodeHost.stat, + readFile: async (path) => { + const file = await NodeHost.readFile(path); + return file.text; + }, + }, + lib, + { baseDir: base, conditions: ["import", "default"] }, + ); + if (resolved.type === "module") { + const virtualPath = computeRelativePath(lib, resolved.mainFile); + fs.addJsFile(virtualPath, host.getJsImport(resolved.mainFile)); + } } await fs.addTypeSpecLibrary(StandardTestLibrary); @@ -62,8 +90,12 @@ async function createTesterFs(base: string, options: TesterOptions) { context?.type === "library", `Unexpected: all source files should be in a library but ${file.path} was in '${context?.type}'`, ); - const relativePath = getRelativePathFromDirectory(base, file.path, false); - if (context.metadata.name === selfName) { + return computeRelativePath(context.metadata.name, file.path); + } + + function computeRelativePath(libName: string, realPath: string): string { + const relativePath = getRelativePathFromDirectory(base, realPath, false); + if (libName === selfName) { return joinPaths("node_modules", selfName, relativePath); } else { return relativePath; @@ -103,14 +135,7 @@ function createEmitterTesterInternal(params: EmitterTesterInternalParams): Emitt const instance = await createEmitterTesterInstance(params); return instance.compileAndDiagnose(...args); }), - createInstance: () => - createEmitterTesterInstance({ - ...params, - fs: async () => { - const fs = await params.fs(); - return fs.clone(); - }, - }), + createInstance: () => createEmitterTesterInstance(params), }; } @@ -178,13 +203,7 @@ function createTesterInternal(params: TesterInternalParams): Tester { } function createInstance(): Promise { - return createTesterInstance({ - ...params, - fs: async () => { - const fs = await params.fs(); - return fs.clone(); - }, - }); + return createTesterInstance(params); } } @@ -236,7 +255,7 @@ async function createEmitterTesterInstance( async function createTesterInstance(params: TesterInternalParams): Promise { let savedProgram: Program | undefined; - const fs = await params.fs(); + const fs = (await params.fs()).clone(); return { ...createCompilable(compileAndDiagnose), diff --git a/packages/openapi3/src/testing/index.ts b/packages/openapi3/src/testing/index.ts index 32aa459b660..be67df9601c 100644 --- a/packages/openapi3/src/testing/index.ts +++ b/packages/openapi3/src/testing/index.ts @@ -4,6 +4,7 @@ import { findTestPackageRoot, } from "@typespec/compiler/testing"; +/** @deprecated use new Tester */ export const OpenAPI3TestLibrary: TypeSpecTestLibrary = createTestLibrary({ name: "@typespec/openapi3", packageRoot: await findTestPackageRoot(import.meta.url), diff --git a/packages/openapi3/test/test-host.ts b/packages/openapi3/test/test-host.ts index 8c22ee40af4..d1565b8cd3c 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -1,5 +1,6 @@ import { Diagnostic, interpolatePath, resolvePath } from "@typespec/compiler"; import { + createTester, createTestHost, createTestWrapper, expectDiagnosticEmpty, @@ -17,6 +18,33 @@ import { OpenAPI3EmitterOptions } from "../src/lib.js"; import { OpenAPI3TestLibrary } from "../src/testing/index.js"; import { OpenAPI3Document } from "../src/types.js"; +const Tester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: [ + "@typespec/http", + "@typespec/json-schema", + "@typespec/rest", + "@typespec/versioning", + "@typespec/openapi", + "@typespec/xml", + "@typespec/openapi3", + ], +}); + +export const SimpleTester = Tester.import( + "@typespec/http", + "@typespec/json-schema", + "@typespec/rest", + "@typespec/openapi", + "@typespec/xml", + "@typespec/openapi3", +) + .using("Http", "Rest", "OpenAPI", "Xml") + .emit("@typespec/openapi3"); + +export const TesterWithVersioning = Tester.importLibraries() + .using("Http", "Rest", "OpenAPI", "Xml", "Versioning") + .emit("@typespec/openapi3"); + export async function createOpenAPITestHost() { return createTestHost({ libraries: [ @@ -94,27 +122,20 @@ export async function openApiFor( versions?: string[], options: OpenAPI3EmitterOptions = {}, ) { - const host = await createOpenAPITestHost(); - const outPath = resolveVirtualPath("{version}.openapi.json"); - host.addTypeSpecFile( - "./main.tsp", - `import "@typespec/http"; import "@typespec/json-schema"; import "@typespec/rest"; import "@typespec/openapi"; import "@typespec/openapi3";import "@typespec/xml"; ${ - versions ? `import "@typespec/versioning"; using Versioning;` : "" - }using Rest;using Http;using OpenAPI;using TypeSpec.Xml;${code}`, - ); - const diagnostics = await host.diagnose("./main.tsp", { - noEmit: false, - emit: ["@typespec/openapi3"], - options: { "@typespec/openapi3": { ...options, "output-file": outPath } }, + const host = await (versions ? TesterWithVersioning : SimpleTester).createInstance(); + const outPath = "{emitter-output-dir}/{version}.openapi.json"; + const { outputs } = await host.compile(code, { + options: { + options: { "@typespec/openapi3": { ...options, "output-file": outPath } }, + }, }); - expectDiagnosticEmpty(diagnostics); if (!versions) { - return JSON.parse(host.fs.get(resolveVirtualPath("openapi.json"))!); + return JSON.parse(outputs["openapi.json"]); } else { const output: any = {}; for (const version of versions) { - output[version] = JSON.parse(host.fs.get(interpolatePath(outPath, { version: version }))!); + output[version] = JSON.parse(outputs[interpolatePath(outPath, { version: version })]!); } return output; } From 37d93a23c3860eca0e8720621b9af5abf6655144 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 13 May 2025 16:01:31 -0700 Subject: [PATCH 33/55] More migration openapi3 --- packages/compiler/src/testing/test-host-v2.ts | 14 +++- packages/compiler/src/testing/types.ts | 7 +- packages/openapi3/test/decorators.test.ts | 34 ++++----- packages/openapi3/test/output-file.test.ts | 19 ++--- .../test/output-spec-versions.test.ts | 21 +++--- packages/openapi3/test/test-host.ts | 69 ++++--------------- packages/openapi3/test/versioning.test.ts | 36 +++++----- packages/openapi3/test/works-for.ts | 7 +- packages/openapi3/test/xml-models.test.ts | 5 +- 9 files changed, 87 insertions(+), 125 deletions(-) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index b21ce061d45..c145905531d 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -4,6 +4,7 @@ import { getSymNode } from "../core/binder.js"; import { compilerAssert } from "../core/diagnostics.js"; import { getEntityName } from "../core/helpers/type-name-utils.js"; import { NodeHost } from "../core/node-host.js"; +import { CompilerOptions } from "../core/options.js"; import { getNodeAtPosition } from "../core/parser.js"; import { getRelativePathFromDirectory, joinPaths, resolvePath } from "../core/path-utils.js"; import { Program, compile as coreCompile } from "../core/program.js"; @@ -123,6 +124,7 @@ interface TesterInternalParams { wraps?: ((code: string) => string)[]; imports?: string[]; usings?: string[]; + compilerOptions?: CompilerOptions; } interface EmitterTesterInternalParams extends TesterInternalParams { @@ -195,10 +197,19 @@ function createTesterInternal(params: TesterInternalParams): Tester { usings: [...(params.usings ?? []), ...usings], }); } - function emit(emitter: string): EmitterTester { + function emit(emitter: string, options?: Record): EmitterTester { return createEmitterTesterInternal({ ...params, emitter, + compilerOptions: options + ? { + ...params.compilerOptions, + options: { + ...params.compilerOptions?.options, + [emitter]: options, + }, + } + : params.compilerOptions, }); } @@ -229,6 +240,7 @@ async function createEmitterTesterInstance( const resolvedOptions: TestCompileOptions = { ...options, options: { + ...params.compilerOptions, ...options?.options, outputDir: "tsp-output", emit: [params.emitter], diff --git a/packages/compiler/src/testing/types.ts b/packages/compiler/src/testing/types.ts index 98d5322a1e7..838a02d5b96 100644 --- a/packages/compiler/src/testing/types.ts +++ b/packages/compiler/src/testing/types.ts @@ -88,8 +88,11 @@ export interface Tester extends Testable { using(...names: string[]): Tester; /** Wrap the code of the `main.tsp` file */ wrap(fn: (x: string) => string): Tester; - /** Create an emitter tester */ - emit(emitter: string): EmitterTester; + /** + * Create an emitter tester + * @param options - Options to pass to the emitter + */ + emit(emitter: string, options?: Record): EmitterTester; /** Create an instance of the tester */ createInstance(): Promise; } diff --git a/packages/openapi3/test/decorators.test.ts b/packages/openapi3/test/decorators.test.ts index 98f1f31ff6d..2068efdf083 100644 --- a/packages/openapi3/test/decorators.test.ts +++ b/packages/openapi3/test/decorators.test.ts @@ -1,22 +1,13 @@ -import { - BasicTestRunner, - expectDiagnosticEmpty, - expectDiagnostics, -} from "@typespec/compiler/testing"; +import { expectDiagnostics, t } from "@typespec/compiler/testing"; import { strictEqual } from "assert"; -import { beforeEach, describe, it } from "vitest"; +import { describe, it } from "vitest"; import { getRef } from "../src/decorators.js"; -import { createOpenAPITestRunner } from "./test-host.js"; +import { ApiTester, SimpleTester } from "./test-host.js"; describe("openapi3: decorators", () => { - let runner: BasicTestRunner; - - beforeEach(async () => { - runner = await createOpenAPITestRunner(); - }); describe("@useRef", () => { it("emit diagnostic if use on non model or property", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await SimpleTester.diagnose(` @useRef("foo") op foo(): string; `); @@ -29,7 +20,7 @@ describe("openapi3: decorators", () => { }); it("emit diagnostic if ref is not a string", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await SimpleTester.diagnose(` @useRef(123) model Foo {} `); @@ -40,7 +31,7 @@ describe("openapi3: decorators", () => { }); it("emit diagnostic if ref is not passed", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await SimpleTester.diagnose(` @useRef model Foo {} `); @@ -54,14 +45,15 @@ describe("openapi3: decorators", () => { }); it("set external reference", async () => { - const [{ Foo }, diagnostics] = await runner.compileAndDiagnose(` - @test @useRef("../common.json#/definitions/Foo") - model Foo {} - `); + const { Foo, program } = await ApiTester.compile(t.code` + import "@typespec/openapi3"; + using OpenAPI; - expectDiagnosticEmpty(diagnostics); + @useRef("../common.json#/definitions/Foo") + model ${t.model("Foo")} {} + `); - strictEqual(getRef(runner.program, Foo), "../common.json#/definitions/Foo"); + strictEqual(getRef(program, Foo), "../common.json#/definitions/Foo"); }); }); }); diff --git a/packages/openapi3/test/output-file.test.ts b/packages/openapi3/test/output-file.test.ts index 0faba31d3a7..31032eee4fc 100644 --- a/packages/openapi3/test/output-file.test.ts +++ b/packages/openapi3/test/output-file.test.ts @@ -1,13 +1,13 @@ import { resolvePath } from "@typespec/compiler"; import { - BasicTestRunner, expectDiagnosticEmpty, resolveVirtualPath, + TesterInstance, } from "@typespec/compiler/testing"; import { ok, strictEqual } from "assert"; import { beforeEach, describe, it } from "vitest"; import { OpenAPI3EmitterOptions } from "../src/lib.js"; -import { createOpenAPITestRunner } from "./test-host.js"; +import { ApiTester } from "./test-host.js"; describe("openapi3: output file", () => { const expectedJsonEmptySpec = [ @@ -36,15 +36,16 @@ describe("openapi3: output file", () => { ]; const outputDir = resolveVirtualPath("test-output"); - let runner: BasicTestRunner; + let runner: TesterInstance; beforeEach(async () => { - runner = await createOpenAPITestRunner(); + runner = await ApiTester.importLibraries().createInstance(); }); async function compileOpenAPI(options: OpenAPI3EmitterOptions, code: string = ""): Promise { const diagnostics = await runner.diagnose(code, { - noEmit: false, - emit: ["@typespec/openapi3"], - options: { "@typespec/openapi3": { ...options, "emitter-output-dir": outputDir } }, + options: { + emit: ["@typespec/openapi3"], + options: { "@typespec/openapi3": { ...options, "emitter-output-dir": outputDir } }, + }, }); expectDiagnosticEmpty(diagnostics); @@ -56,14 +57,14 @@ describe("openapi3: output file", () => { newLine: "\n" | "\r\n" = "\n", ) { const outPath = resolvePath(outputDir, filename); - const content = runner.fs.get(outPath); + const content = runner.fs.fs.get(outPath); ok(content, `Expected ${outPath} to exist.`); strictEqual(content, lines.join(newLine)); } function expectHasOutput(filename: string) { const outPath = resolvePath(outputDir, filename); - const content = runner.fs.get(outPath); + const content = runner.fs.fs.get(outPath); ok(content, `Expected ${outPath} to exist.`); } diff --git a/packages/openapi3/test/output-spec-versions.test.ts b/packages/openapi3/test/output-spec-versions.test.ts index 2ced94b0a3f..30fd89a1a13 100644 --- a/packages/openapi3/test/output-spec-versions.test.ts +++ b/packages/openapi3/test/output-spec-versions.test.ts @@ -1,25 +1,26 @@ import { resolvePath } from "@typespec/compiler"; import { - BasicTestRunner, expectDiagnosticEmpty, resolveVirtualPath, + TesterInstance, } from "@typespec/compiler/testing"; import { ok } from "assert"; import { beforeEach, expect, it } from "vitest"; import { OpenAPI3EmitterOptions } from "../src/lib.js"; -import { createOpenAPITestRunner } from "./test-host.js"; +import { ApiTester } from "./test-host.js"; const outputDir = resolveVirtualPath("test-output"); -let runner: BasicTestRunner; +let runner: TesterInstance; beforeEach(async () => { - runner = await createOpenAPITestRunner(); + runner = await ApiTester.createInstance(); }); async function compileOpenAPI(options: OpenAPI3EmitterOptions, code: string = ""): Promise { const diagnostics = await runner.diagnose(code, { - noEmit: false, - emit: ["@typespec/openapi3"], - options: { "@typespec/openapi3": { ...options, "emitter-output-dir": outputDir } }, + options: { + emit: ["@typespec/openapi3"], + options: { "@typespec/openapi3": { ...options, "emitter-output-dir": outputDir } }, + }, }); expectDiagnosticEmpty(diagnostics); @@ -27,7 +28,7 @@ async function compileOpenAPI(options: OpenAPI3EmitterOptions, code: string = "" function expectHasOutput(filename: string) { const outPath = resolvePath(outputDir, filename); - const content = runner.fs.get(outPath); + const content = runner.fs.fs.get(outPath); ok(content, `Expected ${outPath} to exist.`); } @@ -45,7 +46,7 @@ it("does not create nested directory if only 1 spec version is specified", async it("defaults to 3.0.0 if not specified", async () => { await compileOpenAPI({ "file-type": "json" }); const outPath = resolvePath(outputDir, "openapi.json"); - const content = runner.fs.get(outPath); + const content = runner.fs.fs.get(outPath); ok(content, `Expected ${outPath} to exist.`); const doc = JSON.parse(content); expect(doc.openapi).toBe("3.0.0"); @@ -54,7 +55,7 @@ it("defaults to 3.0.0 if not specified", async () => { it("supports 3.1.0", async () => { await compileOpenAPI({ "openapi-versions": ["3.1.0"], "file-type": "json" }); const outPath = resolvePath(outputDir, "openapi.json"); - const content = runner.fs.get(outPath); + const content = runner.fs.fs.get(outPath); ok(content, `Expected ${outPath} to exist.`); const doc = JSON.parse(content); expect(doc.openapi).toBe("3.1.0"); diff --git a/packages/openapi3/test/test-host.ts b/packages/openapi3/test/test-host.ts index d1565b8cd3c..286810f8662 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -2,7 +2,6 @@ import { Diagnostic, interpolatePath, resolvePath } from "@typespec/compiler"; import { createTester, createTestHost, - createTestWrapper, expectDiagnosticEmpty, resolveVirtualPath, } from "@typespec/compiler/testing"; @@ -18,7 +17,7 @@ import { OpenAPI3EmitterOptions } from "../src/lib.js"; import { OpenAPI3TestLibrary } from "../src/testing/index.js"; import { OpenAPI3Document } from "../src/types.js"; -const Tester = createTester(resolvePath(import.meta.dirname, ".."), { +export const ApiTester = createTester(resolvePath(import.meta.dirname, ".."), { libraries: [ "@typespec/http", "@typespec/json-schema", @@ -30,7 +29,7 @@ const Tester = createTester(resolvePath(import.meta.dirname, ".."), { ], }); -export const SimpleTester = Tester.import( +export const SimpleTester = ApiTester.import( "@typespec/http", "@typespec/json-schema", "@typespec/rest", @@ -41,7 +40,7 @@ export const SimpleTester = Tester.import( .using("Http", "Rest", "OpenAPI", "Xml") .emit("@typespec/openapi3"); -export const TesterWithVersioning = Tester.importLibraries() +export const TesterWithVersioning = ApiTester.importLibraries() .using("Http", "Rest", "OpenAPI", "Xml", "Versioning") .emit("@typespec/openapi3"); @@ -59,60 +58,29 @@ export async function createOpenAPITestHost() { }); } -export async function createOpenAPITestRunner({ - emitterOptions, - withVersioning, -}: { withVersioning?: boolean; emitterOptions?: OpenAPI3EmitterOptions } = {}) { - const host = await createOpenAPITestHost(); - const importAndUsings = ` - import "@typespec/http"; - import "@typespec/rest"; - import "@typespec/json-schema"; - import "@typespec/openapi"; - import "@typespec/openapi3"; - import "@typespec/xml"; - ${withVersioning ? `import "@typespec/versioning"` : ""}; - using Rest; - using Http; - using OpenAPI; - using TypeSpec.Xml; - ${withVersioning ? "using Versioning;" : ""} -`; - return createTestWrapper(host, { - wrapper: (code) => `${importAndUsings} ${code}`, - compilerOptions: { - emit: ["@typespec/openapi3"], - options: { - "@typespec/openapi3": { ...emitterOptions }, - }, - }, - }); -} - export async function emitOpenApiWithDiagnostics( code: string, options: OpenAPI3EmitterOptions = {}, ): Promise<[OpenAPI3Document, readonly Diagnostic[], string]> { - const runner = await createOpenAPITestRunner(); + const runner = await SimpleTester.createInstance(); const fileType = options["file-type"] || "yaml"; const outputFile = resolveVirtualPath("openapi" + fileType === "json" ? ".json" : ".yaml"); const diagnostics = await runner.diagnose(code, { - emit: ["@typespec/openapi3"], options: { - "@typespec/openapi3": { ...options, "output-file": outputFile }, + options: { + "@typespec/openapi3": { ...options, "output-file": outputFile }, + }, }, }); - const content = runner.fs.get(outputFile); + const content = runner.fs.fs.get(outputFile); ok(content, "Expected to have found openapi output"); const doc = fileType === "json" ? JSON.parse(content) : parse(content); return [doc, diagnostics, content]; } export async function diagnoseOpenApiFor(code: string, options: OpenAPI3EmitterOptions = {}) { - const runner = await createOpenAPITestRunner(); - const diagnostics = await runner.diagnose(code, { - emit: ["@typespec/openapi3"], - options: { "@typespec/openapi3": options as any }, + const diagnostics = await SimpleTester.diagnose(code, { + options: { options: { "@typespec/openapi3": options as any } }, }); return diagnostics; } @@ -141,15 +109,6 @@ export async function openApiFor( } } -export async function checkFor(code: string, options: OpenAPI3EmitterOptions = {}) { - const host = await createOpenAPITestRunner(); - return await host.diagnose(code, { - dryRun: true, - emit: ["@typespec/openapi3"], - options: { "@typespec/openapi3": { ...options } }, - }); -} - export async function oapiForModel( name: string, modelDef: string, @@ -184,17 +143,15 @@ export async function openapiWithOptions( code: string, options: OpenAPI3EmitterOptions, ): Promise { - const runner = await createOpenAPITestRunner(); - const outPath = resolvePath("/openapi.json"); + const runner = await SimpleTester.createInstance(); const diagnostics = await runner.diagnose(code, { - emit: ["@typespec/openapi3"], - options: { "@typespec/openapi3": { ...options, "output-file": outPath } }, + options: { options: { "@typespec/openapi3": { ...options, "output-file": outPath } } }, }); expectDiagnosticEmpty(diagnostics); - const content = runner.fs.get(outPath)!; + const content = runner.fs.fs.get(outPath)!; return JSON.parse(content); } diff --git a/packages/openapi3/test/versioning.test.ts b/packages/openapi3/test/versioning.test.ts index f29a92e004e..3830b028b88 100644 --- a/packages/openapi3/test/versioning.test.ts +++ b/packages/openapi3/test/versioning.test.ts @@ -2,10 +2,14 @@ import { DecoratorContext, getNamespaceFullName, Namespace } from "@typespec/com import { createTestWrapper, expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, strictEqual } from "assert"; import { describe, it } from "vitest"; -import { createOpenAPITestHost, createOpenAPITestRunner } from "./test-host.js"; +import { ApiTester, createOpenAPITestHost } from "./test-host.js"; import { worksFor } from "./works-for.js"; worksFor(["3.0.0", "3.1.0"], ({ openApiFor, version: specVersion }) => { + const TesterWithVersioning = ApiTester.importLibraries() + .using("Http", "Rest", "Versioning") + .emit("@typespec/openapi3", { "openapi-versions": [specVersion] }); + it("works with models", async () => { const { v1, v2, v3 } = await openApiFor( ` @@ -156,11 +160,8 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor, version: specVersion }) => { it("doesn't throw errors when using UpdateableProperties", async () => { // if this test throws a duplicate name diagnostic, check that getEffectiveType // is returning the projected type. - const runner = await createOpenAPITestRunner({ - withVersioning: true, - emitterOptions: { "openapi-versions": [specVersion] }, - }); - await runner.compile(` + await TesterWithVersioning.compile( + ` @versioned(Library.Versions) namespace Library { enum Versions { @@ -181,16 +182,14 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor, version: specVersion }) => { oops(...UpdateableProperties): Widget; } } - `); + `, + ); }); describe("versioned resource", () => { it("reports diagnostic without crashing for mismatched versions", async () => { - const runner = await createOpenAPITestRunner({ - withVersioning: true, - emitterOptions: { "openapi-versions": [specVersion] }, - }); - const diagnostics = await runner.diagnose(` + const diagnostics = await TesterWithVersioning.diagnose( + ` @versioned(Versions) @service namespace DemoService; @@ -219,18 +218,16 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor, version: specVersion }) => { @route("/widgets") interface Widgets extends Resource.ResourceOperations {} - `); + `, + ); expectDiagnostics(diagnostics, { code: "@typespec/versioning/incompatible-versioned-reference", }); }); it("succeeds for aligned versions", async () => { - const runner = await createOpenAPITestRunner({ - withVersioning: true, - emitterOptions: { "openapi-versions": [specVersion] }, - }); - await runner.compile(` + await TesterWithVersioning.compile( + ` @versioned(Versions) @service namespace DemoService; @@ -260,7 +257,8 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor, version: specVersion }) => { @added(Versions.v2) @route("/widgets") interface Widgets extends Resource.ResourceOperations {} - `); + `, + ); }); }); }); diff --git a/packages/openapi3/test/works-for.ts b/packages/openapi3/test/works-for.ts index f1c04a1bfbd..c99e2a7cd71 100644 --- a/packages/openapi3/test/works-for.ts +++ b/packages/openapi3/test/works-for.ts @@ -1,7 +1,6 @@ import { describe } from "vitest"; import { OpenAPIVersion } from "../src/lib.js"; import { - checkFor, diagnoseOpenApiFor, emitOpenApiWithDiagnostics, oapiForModel, @@ -21,7 +20,7 @@ export type SpecHelper = { oapiForModel: typeof oapiForModel; openApiFor: typeof openApiFor; openapiWithOptions: typeof openapiWithOptions; - checkFor: typeof checkFor; + checkFor: typeof diagnoseOpenApiFor; diagnoseOpenApiFor: typeof diagnoseOpenApiFor; emitOpenApiWithDiagnostics: typeof emitOpenApiWithDiagnostics; objectSchemaIndexer: ObjectSchemaIndexer; @@ -38,8 +37,8 @@ function createSpecHelpers(version: OpenAPIVersion): SpecHelper { openApiFor(code, versions, { ...options, "openapi-versions": [version] }), openapiWithOptions: (...[code, options]: Parameters) => openapiWithOptions(code, { ...options, "openapi-versions": [version] }), - checkFor: (...[code, options]: Parameters) => - checkFor(code, { ...options, "openapi-versions": [version] }), + checkFor: (...[code, options]: Parameters) => + diagnoseOpenApiFor(code, { ...options, "openapi-versions": [version] }), diagnoseOpenApiFor: (...[code, options]: Parameters) => diagnoseOpenApiFor(code, { ...options, "openapi-versions": [version] }), emitOpenApiWithDiagnostics: ( diff --git a/packages/openapi3/test/xml-models.test.ts b/packages/openapi3/test/xml-models.test.ts index 907afd224a1..e5ce5eb56ed 100644 --- a/packages/openapi3/test/xml-models.test.ts +++ b/packages/openapi3/test/xml-models.test.ts @@ -1,7 +1,7 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; import { describe, expect, it } from "vitest"; -import { createOpenAPITestRunner } from "./test-host.js"; +import { SimpleTester } from "./test-host.js"; import { worksFor } from "./works-for.js"; worksFor(["3.0.0", "3.1.0"], ({ emitOpenApiWithDiagnostics, oapiForModel }) => { @@ -229,8 +229,7 @@ worksFor(["3.0.0", "3.1.0"], ({ emitOpenApiWithDiagnostics, oapiForModel }) => { describe("@unwrapped", () => { it("warning if unwrapped not array", async () => { - const runner = await createOpenAPITestRunner(); - const diagnostics = await runner.diagnose( + const diagnostics = await SimpleTester.diagnose( `model Book { @unwrapped id: string; From b5123376afed4cac7e1e0507c3b7534298dcdd9d Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 13 May 2025 16:08:49 -0700 Subject: [PATCH 34/55] more fixes --- packages/openapi3/test/get-openapi.test.ts | 34 ++++----------- packages/openapi3/test/test-host.ts | 22 ---------- packages/openapi3/test/versioning.test.ts | 49 +--------------------- 3 files changed, 11 insertions(+), 94 deletions(-) diff --git a/packages/openapi3/test/get-openapi.test.ts b/packages/openapi3/test/get-openapi.test.ts index 7503dfbed27..9b389fd099d 100644 --- a/packages/openapi3/test/get-openapi.test.ts +++ b/packages/openapi3/test/get-openapi.test.ts @@ -2,19 +2,14 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { ok, strictEqual } from "assert"; import { it } from "vitest"; import { getOpenAPI3 } from "../src/openapi.js"; -import { createOpenAPITestHost } from "./test-host.js"; +import { ApiTester } from "./test-host.js"; it("can get openapi as an object", async () => { - const host = await createOpenAPITestHost(); - host.addTypeSpecFile( - "./main.tsp", - `import "@typespec/http"; - import "@typespec/rest"; + const { program } = await ApiTester.compile(` + import "@typespec/http"; import "@typespec/openapi"; import "@typespec/openapi3"; - using Rest; using Http; - using OpenAPI; @service namespace Foo; @@ -23,35 +18,24 @@ it("can get openapi as an object", async () => { model Item { x: true } model Bar { }; // unreachable - `, - ); - await host.compile("main.tsp"); - const output = await getOpenAPI3(host.program, { "omit-unreachable-types": false }); + `); + const output = await getOpenAPI3(program, { "omit-unreachable-types": false }); const documentRecord = output[0]; ok(!documentRecord.versioned, "should not be versioned"); strictEqual(documentRecord.document.components!.schemas!["Item"].type, "object"); }); it("has diagnostics", async () => { - const host = await createOpenAPITestHost(); - host.addTypeSpecFile( - "./main.tsp", - `import "@typespec/http"; - import "@typespec/rest"; - import "@typespec/openapi"; - import "@typespec/openapi3"; - using Rest; + const { program } = await ApiTester.compile(` + import "@typespec/http"; using Http; - using OpenAPI; @service namespace Foo; op read(): {@minValue(455) @maxValue(495) @statusCode _: int32, content: string}; - `, - ); - await host.compile("main.tsp"); - const output = await getOpenAPI3(host.program, { "omit-unreachable-types": false }); + `); + const output = await getOpenAPI3(program, { "omit-unreachable-types": false }); const documentRecord = output[0]; ok(!documentRecord.versioned, "should not be versioned"); expectDiagnostics(documentRecord.diagnostics, [ diff --git a/packages/openapi3/test/test-host.ts b/packages/openapi3/test/test-host.ts index 286810f8662..8aa9c87c898 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -1,20 +1,12 @@ import { Diagnostic, interpolatePath, resolvePath } from "@typespec/compiler"; import { createTester, - createTestHost, expectDiagnosticEmpty, resolveVirtualPath, } from "@typespec/compiler/testing"; -import { HttpTestLibrary } from "@typespec/http/testing"; -import { JsonSchemaTestLibrary } from "@typespec/json-schema/testing"; -import { OpenAPITestLibrary } from "@typespec/openapi/testing"; -import { RestTestLibrary } from "@typespec/rest/testing"; -import { VersioningTestLibrary } from "@typespec/versioning/testing"; -import { XmlTestLibrary } from "@typespec/xml/testing"; import { ok } from "assert"; import { parse } from "yaml"; import { OpenAPI3EmitterOptions } from "../src/lib.js"; -import { OpenAPI3TestLibrary } from "../src/testing/index.js"; import { OpenAPI3Document } from "../src/types.js"; export const ApiTester = createTester(resolvePath(import.meta.dirname, ".."), { @@ -44,20 +36,6 @@ export const TesterWithVersioning = ApiTester.importLibraries() .using("Http", "Rest", "OpenAPI", "Xml", "Versioning") .emit("@typespec/openapi3"); -export async function createOpenAPITestHost() { - return createTestHost({ - libraries: [ - HttpTestLibrary, - JsonSchemaTestLibrary, - RestTestLibrary, - VersioningTestLibrary, - XmlTestLibrary, - OpenAPITestLibrary, - OpenAPI3TestLibrary, - ], - }); -} - export async function emitOpenApiWithDiagnostics( code: string, options: OpenAPI3EmitterOptions = {}, diff --git a/packages/openapi3/test/versioning.test.ts b/packages/openapi3/test/versioning.test.ts index 3830b028b88..d7b8b98b18e 100644 --- a/packages/openapi3/test/versioning.test.ts +++ b/packages/openapi3/test/versioning.test.ts @@ -1,8 +1,7 @@ -import { DecoratorContext, getNamespaceFullName, Namespace } from "@typespec/compiler"; -import { createTestWrapper, expectDiagnostics } from "@typespec/compiler/testing"; +import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, strictEqual } from "assert"; import { describe, it } from "vitest"; -import { ApiTester, createOpenAPITestHost } from "./test-host.js"; +import { ApiTester } from "./test-host.js"; import { worksFor } from "./works-for.js"; worksFor(["3.0.0", "3.1.0"], ({ openApiFor, version: specVersion }) => { @@ -112,50 +111,6 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor, version: specVersion }) => { }); }); - it("doesn't lose parent namespace", async () => { - const host = await createOpenAPITestHost(); - - let storedNamespace: string | undefined = undefined; - host.addJsFile("test.js", { - $armNamespace(context: DecoratorContext, entity: Namespace) { - storedNamespace = getNamespaceFullName(entity); - }, - }); - - const runner = createTestWrapper(host, { - autoImports: [...host.libraries.map((x) => x.name), "./test.js"], - autoUsings: ["TypeSpec.Rest", "TypeSpec.Http", "TypeSpec.OpenAPI", "TypeSpec.Versioning"], - compilerOptions: { - emit: ["@typespec/openapi3"], - options: { "@typespec/openapi3": { "openapi-versions": [specVersion] } }, - }, - }); - - await runner.compile(` - @versioned(Contoso.Library.Versions) - namespace Contoso.Library { - namespace Blah { } - enum Versions { v1 }; - } - @armNamespace - @service(#{title: "Widgets 'r' Us"}) - @useDependency(Contoso.Library.Versions.v1) - namespace Contoso.WidgetService { - model Widget { - @key - @segment("widgets") - id: string; - } - interface Operations { - @test - op get(id: string): Widget; - } - } - `); - - strictEqual(storedNamespace, "Contoso.WidgetService"); - }); - // Test for https://github.com/microsoft/typespec/issues/812 it("doesn't throw errors when using UpdateableProperties", async () => { // if this test throws a duplicate name diagnostic, check that getEffectiveType From a306d02c199c0cb5ab905eeb3521f4457bc18613 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 13 May 2025 16:18:02 -0700 Subject: [PATCH 35/55] Simplify openapiFor --- packages/openapi3/test/merge-patch.test.ts | 1 - packages/openapi3/test/metadata.test.ts | 1 - .../openapi3/test/primitive-types.test.ts | 2 -- packages/openapi3/test/test-host.ts | 36 +++++++++++-------- packages/openapi3/test/versioning.test.ts | 10 +++--- packages/openapi3/test/works-for.ts | 4 +-- 6 files changed, 29 insertions(+), 25 deletions(-) diff --git a/packages/openapi3/test/merge-patch.test.ts b/packages/openapi3/test/merge-patch.test.ts index 45c319f42c8..93412b9aae6 100644 --- a/packages/openapi3/test/merge-patch.test.ts +++ b/packages/openapi3/test/merge-patch.test.ts @@ -18,7 +18,6 @@ export async function oapiForPatchRequest( @patch op update(${body}): void; } `, - undefined, options, ); diff --git a/packages/openapi3/test/metadata.test.ts b/packages/openapi3/test/metadata.test.ts index 4ab93228386..8d26eea79d0 100644 --- a/packages/openapi3/test/metadata.test.ts +++ b/packages/openapi3/test/metadata.test.ts @@ -388,7 +388,6 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @delete op delete(...U): void; } `, - undefined, { "omit-unreachable-types": true }, ); diff --git a/packages/openapi3/test/primitive-types.test.ts b/packages/openapi3/test/primitive-types.test.ts index b54dd4990bc..5720882727f 100644 --- a/packages/openapi3/test/primitive-types.test.ts +++ b/packages/openapi3/test/primitive-types.test.ts @@ -53,7 +53,6 @@ worksFor(["3.0.0", "3.1.0"], ({ oapiForModel, openApiFor }) => { ` model Pet { name: safeint }; `, - undefined, { "safeint-strategy": "double-int" }, ); @@ -66,7 +65,6 @@ worksFor(["3.0.0", "3.1.0"], ({ oapiForModel, openApiFor }) => { ` model Pet { name: safeint }; `, - undefined, { "safeint-strategy": "int64" }, ); diff --git a/packages/openapi3/test/test-host.ts b/packages/openapi3/test/test-host.ts index 8aa9c87c898..172e4f7750b 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -63,28 +63,35 @@ export async function diagnoseOpenApiFor(code: string, options: OpenAPI3EmitterO return diagnostics; } -export async function openApiFor( +export async function openApiFor(code: string, options: OpenAPI3EmitterOptions = {}) { + const host = await SimpleTester.createInstance(); + const outPath = "{emitter-output-dir}/openapi.json"; + const { outputs } = await host.compile(code, { + options: { + options: { "@typespec/openapi3": { ...options, "output-file": outPath } }, + }, + }); + + return JSON.parse(outputs["openapi.json"]); +} + +export async function openApiForVersions( code: string, - versions?: string[], - options: OpenAPI3EmitterOptions = {}, -) { - const host = await (versions ? TesterWithVersioning : SimpleTester).createInstance(); + versions: T[], +): Promise> { + const host = await TesterWithVersioning.createInstance(); const outPath = "{emitter-output-dir}/{version}.openapi.json"; const { outputs } = await host.compile(code, { options: { - options: { "@typespec/openapi3": { ...options, "output-file": outPath } }, + options: { "@typespec/openapi3": { "output-file": outPath } }, }, }); - if (!versions) { - return JSON.parse(outputs["openapi.json"]); - } else { - const output: any = {}; - for (const version of versions) { - output[version] = JSON.parse(outputs[interpolatePath(outPath, { version: version })]!); - } - return output; + const output: Record = {} as any; + for (const version of versions) { + output[version] = JSON.parse(outputs[interpolatePath(outPath, { version: version })]!); } + return output; } export async function oapiForModel( @@ -104,7 +111,6 @@ export async function oapiForModel( }; } `, - undefined, options, ); diff --git a/packages/openapi3/test/versioning.test.ts b/packages/openapi3/test/versioning.test.ts index d7b8b98b18e..4a9ccbbc4f7 100644 --- a/packages/openapi3/test/versioning.test.ts +++ b/packages/openapi3/test/versioning.test.ts @@ -1,7 +1,7 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; -import { deepStrictEqual, strictEqual } from "assert"; +import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, it } from "vitest"; -import { ApiTester } from "./test-host.js"; +import { ApiTester, openApiForVersions } from "./test-host.js"; import { worksFor } from "./works-for.js"; worksFor(["3.0.0", "3.1.0"], ({ openApiFor, version: specVersion }) => { @@ -10,7 +10,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor, version: specVersion }) => { .emit("@typespec/openapi3", { "openapi-versions": [specVersion] }); it("works with models", async () => { - const { v1, v2, v3 } = await openApiFor( + const { v1, v2, v3 } = await openApiForVersions( ` @versioned(Versions) @service(#{title: "My Service"}) @@ -50,6 +50,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor, version: specVersion }) => { ); strictEqual(v1.info.version, "v1"); + ok(v1.components?.schemas); deepStrictEqual(v1.components.schemas.Test, { type: "object", properties: { @@ -70,6 +71,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor, version: specVersion }) => { }); strictEqual(v2.info.version, "v2"); + ok(v2.components?.schemas); deepStrictEqual(v2.components.schemas.Test, { type: "object", properties: { @@ -88,7 +90,7 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor, version: specVersion }) => { }, required: ["prop1", "prop2"], }); - + ok(v3.components?.schemas); strictEqual(v3.info.version, "v3"); deepStrictEqual(v3.components.schemas.Test, { type: "object", diff --git a/packages/openapi3/test/works-for.ts b/packages/openapi3/test/works-for.ts index c99e2a7cd71..a4dfa9e64a9 100644 --- a/packages/openapi3/test/works-for.ts +++ b/packages/openapi3/test/works-for.ts @@ -33,8 +33,8 @@ function createSpecHelpers(version: OpenAPIVersion): SpecHelper { version, oapiForModel: (...[name, modelDef, options]: Parameters) => oapiForModel(name, modelDef, { ...options, "openapi-versions": [version] }), - openApiFor: (...[code, versions, options]: Parameters) => - openApiFor(code, versions, { ...options, "openapi-versions": [version] }), + openApiFor: (...[code, options]: Parameters) => + openApiFor(code, { ...options, "openapi-versions": [version] }), openapiWithOptions: (...[code, options]: Parameters) => openapiWithOptions(code, { ...options, "openapi-versions": [version] }), checkFor: (...[code, options]: Parameters) => From d342f06145381fab0af71b9ca2e89b8279add6fc Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 14 May 2025 08:21:58 -0700 Subject: [PATCH 36/55] fix --- packages/compiler/src/testing/test-compiler-host.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/testing/test-compiler-host.ts b/packages/compiler/src/testing/test-compiler-host.ts index 3450b914eee..067224cff2a 100644 --- a/packages/compiler/src/testing/test-compiler-host.ts +++ b/packages/compiler/src/testing/test-compiler-host.ts @@ -1,5 +1,5 @@ import { RmOptions } from "fs"; -import { fileURLToPath } from "url"; +import { fileURLToPath, pathToFileURL } from "url"; import { CompilerPackageRoot, NodeHost } from "../core/node-host.js"; import { createSourceFile, getSourceFileKindFromExt } from "../core/source-file.js"; import { CompilerHost, StringLiteral, Type } from "../core/types.js"; @@ -136,7 +136,7 @@ export function createTestCompilerHost( mkdirp: async (path: string) => path, fileURLToPath, pathToFileURL(path: string) { - return `file://${path}`; + return pathToFileURL(path).href; }, ...options?.compilerHostOverrides, From 627d21963076fd84cb1e47adf8b27fc84b40a79f Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 14 May 2025 08:26:51 -0700 Subject: [PATCH 37/55] Migrate xml --- packages/xml/test/decorators.test.ts | 48 ++++++++++++++-------------- packages/xml/test/encoding.test.ts | 29 +++++++---------- packages/xml/test/test-host.ts | 18 ++++------- 3 files changed, 42 insertions(+), 53 deletions(-) diff --git a/packages/xml/test/decorators.test.ts b/packages/xml/test/decorators.test.ts index 8c181074547..591c27662f3 100644 --- a/packages/xml/test/decorators.test.ts +++ b/packages/xml/test/decorators.test.ts @@ -1,13 +1,13 @@ -import { resolveEncodedName, type Model, type ModelProperty } from "@typespec/compiler"; -import { expectDiagnostics, type BasicTestRunner } from "@typespec/compiler/testing"; +import { resolveEncodedName, type Model } from "@typespec/compiler"; +import { expectDiagnostics, t, type TesterInstance } from "@typespec/compiler/testing"; import { beforeEach, describe, expect, it } from "vitest"; import { getNs, isAttribute, isUnwrapped } from "../src/decorators.js"; -import { createXmlTestRunner } from "./test-host.js"; +import { Tester } from "./test-host.js"; -let runner: BasicTestRunner; +let runner: TesterInstance; beforeEach(async () => { - runner = await createXmlTestRunner(); + runner = await Tester.createInstance(); }); describe("@name", () => { @@ -16,7 +16,7 @@ describe("@name", () => { ["model prop", `model Blob {@Xml.name("XmlName") @test title:string}`], ["scalar", `@Xml.name("XmlName") @test scalar Blob extends string;`], ])("%s", async (_, code) => { - const result = await runner.compile(`${code}`); + const result = await runner.compile(t.code`${code}`); const curr = (result.Blob || result.title) as Model; expect(resolveEncodedName(runner.program, curr, "application/xml")).toEqual("XmlName"); }); @@ -24,17 +24,17 @@ describe("@name", () => { describe("@attribute", () => { it("mark property as being an attribute", async () => { - const { id } = (await runner.compile(`model Blob { - @test @Xml.attribute id : string - }`)) as { id: ModelProperty }; + const { id } = await runner.compile(t.code`model Blob { + @Xml.attribute ${t.modelProperty("id")} : string + }`); expect(isAttribute(runner.program, id)).toBe(true); }); it("returns false if property is not decorated", async () => { - const { id } = (await runner.compile(`model Blob { - @test id : string - }`)) as { id: ModelProperty }; + const { id } = await runner.compile(t.code`model Blob { + ${t.modelProperty("id")} : string + }`); expect(isAttribute(runner.program, id)).toBe(false); }); @@ -42,17 +42,17 @@ describe("@attribute", () => { describe("@unwrapped", () => { it("mark property as to not be wrapped", async () => { - const { id } = (await runner.compile(`model Blob { - @test @Xml.unwrapped id : string - }`)) as { id: ModelProperty }; + const { id } = await runner.compile(t.code`model Blob { + @Xml.unwrapped ${t.modelProperty("id")} : string + }`); expect(isUnwrapped(runner.program, id)).toBe(true); }); it("returns false if property is not decorated", async () => { - const { id } = (await runner.compile(`model Blob { - @test id : string - }`)) as { id: ModelProperty }; + const { id } = await runner.compile(t.code`model Blob { + ${t.modelProperty("id")} : string + }`); expect(isUnwrapped(runner.program, id)).toBe(false); }); @@ -60,9 +60,9 @@ describe("@unwrapped", () => { describe("@ns", () => { it("provide the namespace and prefix using string", async () => { - const { id } = await runner.compile(` + const { id } = await runner.compile(t.code` model Blob { - @test @Xml.ns("https://example.com/ns1", "ns1") id : string; + @Xml.ns("https://example.com/ns1", "ns1") ${t.modelProperty("id")} : string; } `); @@ -73,10 +73,10 @@ describe("@ns", () => { }); it("doesn't carry over to children", async () => { - const { id } = await runner.compile(` + const { id } = await runner.compile(t.code` @Xml.ns("https://example.com/ns1", "ns1") model Blob { - @test id : string; + ${t.modelProperty("id")} : string; } `); @@ -84,7 +84,7 @@ describe("@ns", () => { }); it("provide the namespace using enum declaration", async () => { - const { id } = await runner.compile(` + const { id } = await runner.compile(t.code` @Xml.nsDeclarations enum Namespaces { ns1: "https://example.com/ns1", @@ -92,7 +92,7 @@ describe("@ns", () => { } model Blob { - @test @Xml.ns(Namespaces.ns2) id : string; + @Xml.ns(Namespaces.ns2) ${t.modelProperty("id")} : string; } `); diff --git a/packages/xml/test/encoding.test.ts b/packages/xml/test/encoding.test.ts index 3ff3d757ffa..1f754384094 100644 --- a/packages/xml/test/encoding.test.ts +++ b/packages/xml/test/encoding.test.ts @@ -1,14 +1,7 @@ -import type { ModelProperty } from "@typespec/compiler"; -import type { BasicTestRunner } from "@typespec/compiler/testing"; -import { beforeEach, describe, expect, it } from "vitest"; +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; import { getXmlEncoding } from "../src/encoding.js"; -import { createXmlTestRunner } from "./test-host.js"; - -let runner: BasicTestRunner; - -beforeEach(async () => { - runner = await createXmlTestRunner(); -}); +import { Tester } from "./test-host.js"; describe("default encodings", () => { it.each([ @@ -19,19 +12,19 @@ describe("default encodings", () => { ["plainTime", "TypeSpec.Xml.Encoding.xmlTime"], ["bytes", "TypeSpec.Xml.Encoding.xmlBase64Binary"], ])("%s", async (type, expectedEncoding) => { - const { prop } = (await runner.compile(`model Foo { - @test prop: ${type} - }`)) as { prop: ModelProperty }; - const encoding = getXmlEncoding(runner.program, prop); + const { prop, program } = await Tester.compile(t.code`model Foo { + ${t.modelProperty("prop")}: ${type} + }`); + const encoding = getXmlEncoding(program, prop); expect(encoding?.encoding).toEqual(expectedEncoding); }); }); it("override encoding", async () => { - const { prop } = (await runner.compile(`model Foo { + const { prop, program } = await Tester.compile(t.code`model Foo { @encode("rfc3339") - @test prop: utcDateTime; - }`)) as { prop: ModelProperty }; - const encoding = getXmlEncoding(runner.program, prop); + ${t.modelProperty("prop")}: utcDateTime; + }`); + const encoding = getXmlEncoding(program, prop); expect(encoding?.encoding).toEqual("rfc3339"); }); diff --git a/packages/xml/test/test-host.ts b/packages/xml/test/test-host.ts index ada33dbc4c9..225791a8689 100644 --- a/packages/xml/test/test-host.ts +++ b/packages/xml/test/test-host.ts @@ -1,12 +1,8 @@ -import { createTestHost, createTestWrapper } from "@typespec/compiler/testing"; -import { XmlTestLibrary } from "../src/testing/index.js"; +import { resolvePath } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; -export async function createXmlTestHost() { - return createTestHost({ - libraries: [XmlTestLibrary], - }); -} -export async function createXmlTestRunner() { - const host = await createXmlTestHost(); - return createTestWrapper(host, { autoUsings: ["TypeSpec.Xml"] }); -} +export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/xml"], +}) + .importLibraries() + .using("Xml"); From 8acb8dd01c8a3c87644b1a62a2c698f9a247b35d Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 14 May 2025 08:44:41 -0700 Subject: [PATCH 38/55] Migrate sse and streams --- packages/sse/test/decorators.test.ts | 20 +++++-------- packages/sse/test/models.test.ts | 24 +++++----------- packages/sse/test/test-host.ts | 22 +++++---------- packages/streams/test/decorators.test.ts | 36 +++++++++++------------- packages/streams/test/test-host.ts | 19 +++++-------- 5 files changed, 45 insertions(+), 76 deletions(-) diff --git a/packages/sse/test/decorators.test.ts b/packages/sse/test/decorators.test.ts index 45babde4edc..df513cd9fc3 100644 --- a/packages/sse/test/decorators.test.ts +++ b/packages/sse/test/decorators.test.ts @@ -1,19 +1,13 @@ import type { UnionVariant } from "@typespec/compiler"; -import { expectDiagnostics, type BasicTestRunner } from "@typespec/compiler/testing"; -import { beforeEach, describe, expect, it } from "vitest"; +import { expectDiagnostics, t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; import { isTerminalEvent } from "../src/decorators.js"; -import { createSSETestRunner } from "./test-host.js"; - -let runner: BasicTestRunner; - -beforeEach(async () => { - runner = await createSSETestRunner(); -}); +import { Tester } from "./test-host.js"; describe("@terminalEvent", () => { it("marks the model as a terminal event", async () => { - const { TerminalEvent } = await runner.compile( - ` + const { TerminalEvent, program } = await Tester.compile( + t.code` @events union TestEvents { { done: false, @data message: string}, @@ -25,11 +19,11 @@ union TestEvents { `, ); - expect(isTerminalEvent(runner.program, TerminalEvent as UnionVariant)).toBe(true); + expect(isTerminalEvent(program, TerminalEvent as UnionVariant)).toBe(true); }); it("can only be applied to union variants within a union decorated with @events", async () => { - const diagnostics = await runner.diagnose( + const diagnostics = await Tester.diagnose( ` union TestEvents { { done: false, @data message: string}, diff --git a/packages/sse/test/models.test.ts b/packages/sse/test/models.test.ts index 57e0db242c4..35bb1999972 100644 --- a/packages/sse/test/models.test.ts +++ b/packages/sse/test/models.test.ts @@ -1,32 +1,22 @@ -import type { BasicTestRunner } from "@typespec/compiler/testing"; +import { t } from "@typespec/compiler/testing"; import { getContentTypes } from "@typespec/http"; import { getStreamOf } from "@typespec/streams"; -import { assert, beforeEach, describe, expect, it } from "vitest"; -import { createSSETestRunner } from "./test-host.js"; - -let runner: BasicTestRunner; - -beforeEach(async () => { - runner = await createSSETestRunner(); -}); +import { describe, expect, it } from "vitest"; +import { Tester } from "./test-host.js"; describe("SSEStream", () => { it("sets streamOf, contentType ('text/event-stream'), and body", async () => { - const { Foo, TestEvents } = await runner.compile(` - @test + const { Foo, TestEvents, program } = await Tester.compile(t.code` @events - union TestEvents { + union ${t.union("TestEvents")} { foo: string, bar: string, } - @test model Foo is SSEStream; + model ${t.model("Foo")} is SSEStream; `); - assert(Foo.kind === "Model"); - assert(TestEvents.kind === "Union"); - - expect(getStreamOf(runner.program, Foo)).toBe(TestEvents); + expect(getStreamOf(program, Foo)).toBe(TestEvents); expect(getContentTypes(Foo.properties.get("contentType")!)[0]).toEqual(["text/event-stream"]); expect(Foo.properties.get("body")!.type).toMatchObject({ kind: "Scalar", diff --git a/packages/sse/test/test-host.ts b/packages/sse/test/test-host.ts index 0f27e4efb6f..622aae21b25 100644 --- a/packages/sse/test/test-host.ts +++ b/packages/sse/test/test-host.ts @@ -1,16 +1,8 @@ -import { createTestHost, createTestWrapper } from "@typespec/compiler/testing"; -import { EventsTestLibrary } from "@typespec/events/testing"; -import { HttpTestLibrary } from "@typespec/http/testing"; -import { StreamsTestLibrary } from "@typespec/streams/testing"; -import { SSETestLibrary } from "../src/testing/index.js"; +import { resolvePath } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; -export async function createSSETestHost() { - return createTestHost({ - libraries: [EventsTestLibrary, HttpTestLibrary, StreamsTestLibrary, SSETestLibrary], - }); -} - -export async function createSSETestRunner() { - const host = await createSSETestHost(); - return createTestWrapper(host, { autoUsings: ["TypeSpec.Events", "TypeSpec.SSE"] }); -} +export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/events", "@typespec/http", "@typespec/streams", "@typespec/sse"], +}) + .importLibraries() + .using("Events", "SSE"); diff --git a/packages/streams/test/decorators.test.ts b/packages/streams/test/decorators.test.ts index 4c3465d059c..ea6fe6ee3ae 100644 --- a/packages/streams/test/decorators.test.ts +++ b/packages/streams/test/decorators.test.ts @@ -1,38 +1,36 @@ import type { Model } from "@typespec/compiler"; -import type { BasicTestRunner } from "@typespec/compiler/testing"; -import { beforeEach, describe, expect, it } from "vitest"; +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; import { getStreamOf } from "../src/decorators.js"; -import { createStreamsTestRunner } from "./test-host.js"; - -let runner: BasicTestRunner; - -beforeEach(async () => { - runner = await createStreamsTestRunner(); -}); +import { Tester } from "./test-host.js"; describe("@streamOf", () => { it("provides stream protocol type", async () => { - const { Blob } = await runner.compile(`@test @streamOf(string) model Blob {}`); + const { Blob, program } = await Tester.compile(t.code` + @streamOf(string) + model ${t.model("Blob")} {} + `); - expect(getStreamOf(runner.program, Blob as Model)).toMatchObject({ + expect(getStreamOf(program, Blob as Model)).toMatchObject({ kind: "Scalar", name: "string", }); }); it("returns undefined if model is not decorated", async () => { - const { Blob } = await runner.compile(`@test model Blob {}`); + const { Blob, program } = await Tester.compile(t.code` + model ${t.model("Blob")} {} + `); - expect(getStreamOf(runner.program, Blob as Model)).toBeUndefined(); + expect(getStreamOf(program, Blob as Model)).toBeUndefined(); }); it("is automatically set on the Stream model", async () => { - const { CustomStream, Message } = await runner.compile( - ` - @test model Message { id: string, text: string } - @test model CustomStream is Stream {}`, - ); + const { CustomStream, Message, program } = await Tester.compile(t.code` + model ${t.model("Message")} { id: string, text: string } + model ${t.model("CustomStream")} is Stream {} + `); - expect(getStreamOf(runner.program, CustomStream as Model)).toBe(Message); + expect(getStreamOf(program, CustomStream as Model)).toBe(Message); }); }); diff --git a/packages/streams/test/test-host.ts b/packages/streams/test/test-host.ts index 1bb51f7ac3a..09b8ee81b96 100644 --- a/packages/streams/test/test-host.ts +++ b/packages/streams/test/test-host.ts @@ -1,13 +1,8 @@ -import { createTestHost, createTestWrapper } from "@typespec/compiler/testing"; -import { StreamsTestLibrary } from "../src/testing/index.js"; +import { resolvePath } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; -export async function createStreamsTestHost() { - return createTestHost({ - libraries: [StreamsTestLibrary], - }); -} - -export async function createStreamsTestRunner() { - const host = await createStreamsTestHost(); - return createTestWrapper(host, { autoUsings: ["TypeSpec.Streams"] }); -} +export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/streams"], +}) + .importLibraries() + .using("Streams"); From 61f8c52e2e2e0460908a375d347bb7bb0eafff65 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 14 May 2025 09:21:04 -0700 Subject: [PATCH 39/55] migrate rest --- .../compiler/src/testing/marked-template.ts | 15 ++-- packages/rest/test/resource.test.ts | 89 +++++++------------ packages/rest/test/rest-decorators.test.ts | 36 +++----- packages/rest/test/routes.test.ts | 66 +++++++------- packages/rest/test/test-host.ts | 45 +++------- 5 files changed, 104 insertions(+), 147 deletions(-) diff --git a/packages/compiler/src/testing/marked-template.ts b/packages/compiler/src/testing/marked-template.ts index 177670a6ba4..96b2b6d1272 100644 --- a/packages/compiler/src/testing/marked-template.ts +++ b/packages/compiler/src/testing/marked-template.ts @@ -1,4 +1,5 @@ import { + BooleanLiteral, Entity, Enum, EnumMember, @@ -6,9 +7,13 @@ import { Model, ModelProperty, Namespace, + NumericLiteral, Operation, + Scalar, + StringLiteral, Type, Union, + UnionVariant, Value, } from "../core/types.js"; @@ -116,11 +121,11 @@ export const t = { enumMember: typeMarker("EnumMember"), modelProperty: typeMarker("ModelProperty"), namespace: typeMarker("Namespace"), - scalar: typeMarker("Scalar"), - unionVariant: typeMarker("UnionVariant"), - boolean: typeMarker("Boolean"), - number: typeMarker("Number"), - string: typeMarker("String"), + scalar: typeMarker("Scalar"), + unionVariant: typeMarker("UnionVariant"), + boolean: typeMarker("Boolean"), + number: typeMarker("Number"), + string: typeMarker("String"), // Values value: valueMarker(), diff --git a/packages/rest/test/resource.test.ts b/packages/rest/test/resource.test.ts index b2192257546..e3663762d2a 100644 --- a/packages/rest/test/resource.test.ts +++ b/packages/rest/test/resource.test.ts @@ -1,10 +1,9 @@ -import { Model } from "@typespec/compiler"; -import { expectDiagnostics } from "@typespec/compiler/testing"; +import { expectDiagnostics, t } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, it } from "vitest"; import { getResourceTypeKey } from "../src/resource.js"; import { getSegment } from "../src/rest.js"; -import { compileOperations, createRestTestRunner, getRoutesFor } from "./test-host.js"; +import { Tester, compileOperations, getRoutesFor } from "./test-host.js"; describe("rest: resources", () => { it("@resource decorator emits a diagnostic when a @key property is not found", async () => { @@ -13,7 +12,7 @@ describe("rest: resources", () => { model Thing { id: string; } - `); + `); expectDiagnostics(diagnostics, { code: "@typespec/rest/resource-missing-key", @@ -23,50 +22,41 @@ describe("rest: resources", () => { }); it("getResourceTypeKey works for base classes", async () => { - const runner = await createRestTestRunner(); - const { Thing } = (await runner.compile(` - + const { Thing, program } = await Tester.compile(t.code` model BaseThing { @key id: string; } - @test @resource("things") - model Thing extends BaseThing { + model ${t.model("Thing")} extends BaseThing { extra: string; } - `)) as { Thing: Model }; + `); - // Check the key property to ensure the segment got added - const key = getResourceTypeKey(runner.program, Thing); + const key = getResourceTypeKey(program, Thing); ok(key, "No key property found."); - strictEqual(getSegment(runner.program, key.keyProperty), "things"); + strictEqual(getSegment(program, key.keyProperty), "things"); }); it("@resource decorator applies @segment decorator on the @key property", async () => { - const runner = await createRestTestRunner(); - const { Thing } = (await runner.compile(` - @test + const { Thing, program } = await Tester.compile(t.code` @resource("things") - model Thing { + model ${t.model("Thing")} { @key id: string; } - `)) as { Thing: Model }; + `); - // Check the key property to ensure the segment got added - const key = getResourceTypeKey(runner.program, Thing); + const key = getResourceTypeKey(program, Thing); ok(key, "No key property found."); - strictEqual(getSegment(runner.program, key.keyProperty), "things"); + strictEqual(getSegment(program, key.keyProperty), "things"); }); it("@resource decorator applies @segment decorator that reaches route generation", async () => { - const routes = await getRoutesFor( - ` + const routes = await getRoutesFor(` using Rest.Resource; - @test @resource("things") model Thing { @key("thingId") @@ -76,8 +66,7 @@ describe("rest: resources", () => { @error model Error {} interface Things extends ResourceRead {} - `, - ); + `); deepStrictEqual(routes, [ { @@ -89,8 +78,7 @@ describe("rest: resources", () => { }); it("resources: generates standard operations for resource types and their children", async () => { - const routes = await getRoutesFor( - ` + const routes = await getRoutesFor(` using Rest.Resource; namespace Things { @@ -112,8 +100,7 @@ describe("rest: resources", () => { interface Things extends ResourceOperations {} interface Subthings extends ResourceOperations {} } - `, - ); + `); deepStrictEqual(routes, [ { @@ -170,8 +157,7 @@ describe("rest: resources", () => { }); it("resources: collection action paths are generated correctly", async () => { - const routes = await getRoutesFor( - ` + const routes = await getRoutesFor(` using Rest.Resource; model Thing { @@ -191,8 +177,7 @@ describe("rest: resources", () => { @actionSeparator(":") op exportThingWithColon2(): {}; } - `, - ); + `); deepStrictEqual(routes, [ { @@ -219,7 +204,7 @@ describe("rest: resources", () => { @key("anotherId") secondId: string; } - `); + `); expectDiagnostics(diagnostics, [ { @@ -261,7 +246,7 @@ describe("rest: resources", () => { subSubthingId: string; } } - `); + `); expectDiagnostics(diagnostics, [ { @@ -280,8 +265,7 @@ describe("rest: resources", () => { }); it("resources: standard lifecycle operations have expected paths and verbs", async () => { - const routes = await getRoutesFor( - ` + const routes = await getRoutesFor(` using Rest.Resource; model Thing { @@ -294,8 +278,7 @@ describe("rest: resources", () => { interface Things extends ResourceOperations, ResourceCreateOrReplace { } - `, - ); + `); deepStrictEqual(routes, [ { @@ -332,8 +315,7 @@ describe("rest: resources", () => { }); it("singleton resource: generates standard operations", async () => { - const routes = await getRoutesFor( - ` + const routes = await getRoutesFor(` using Rest.Resource; namespace Things { @@ -353,8 +335,7 @@ describe("rest: resources", () => { interface Things extends ResourceRead {} interface ThingsSingleton extends SingletonResourceOperations {} } - `, - ); + `); deepStrictEqual(routes, [ { @@ -376,8 +357,7 @@ describe("rest: resources", () => { }); it("extension resources: generates standard operations for extensions on parent and child resources", async () => { - const routes = await getRoutesFor( - ` + const routes = await getRoutesFor(` using Rest.Resource; namespace Things { @@ -405,8 +385,7 @@ describe("rest: resources", () => { interface ThingsExtension extends ExtensionResourceOperations {} interface SubthingsExtension extends ExtensionResourceOperations {} } - `, - ); + `); deepStrictEqual(routes, [ { @@ -463,17 +442,14 @@ describe("rest: resources", () => { }); it("emit diagnostic if missing @key decorator on resource", async () => { - const runner = await createRestTestRunner(); - const diagnostics = await runner.diagnose( - ` + const diagnostics = await Tester.diagnose(` using Rest.Resource; interface Dogs extends ResourceOperations {} model Dog {} @error model Error {code: string} - `, - ); + `); expectDiagnostics(diagnostics, { code: "@typespec/rest/resource-missing-key", message: @@ -482,9 +458,7 @@ describe("rest: resources", () => { }); it("emit diagnostic if missing @error decorator on error", async () => { - const runner = await createRestTestRunner(); - const diagnostics = await runner.diagnose( - ` + const diagnostics = await Tester.diagnose(` using Rest.Resource; interface Dogs extends ResourceOperations {} @@ -493,8 +467,7 @@ describe("rest: resources", () => { @key foo: string } model Error {code: string} - `, - ); + `); expectDiagnostics(diagnostics, { code: "@typespec/rest/resource-missing-error", message: diff --git a/packages/rest/test/rest-decorators.test.ts b/packages/rest/test/rest-decorators.test.ts index e14a67993d4..1bea0e3abef 100644 --- a/packages/rest/test/rest-decorators.test.ts +++ b/packages/rest/test/rest-decorators.test.ts @@ -1,27 +1,20 @@ -import { Scalar } from "@typespec/compiler"; -import { BasicTestRunner, expectDiagnostics } from "@typespec/compiler/testing"; +import { expectDiagnostics, t } from "@typespec/compiler/testing"; import { ok, strictEqual } from "assert"; -import { beforeEach, describe, it } from "vitest"; +import { describe, it } from "vitest"; import { getResourceLocationType } from "../src/rest.js"; -import { createRestTestRunner } from "./test-host.js"; +import { Tester } from "./test-host.js"; describe("rest: rest decorators", () => { - let runner: BasicTestRunner; - - beforeEach(async () => { - runner = await createRestTestRunner(); - }); - describe("@resourceLocation", () => { it("emit diagnostic when used on non-model", async () => { - const diagnostics = await runner.diagnose(` - model Widget {}; + const diagnostics = await Tester.diagnose(` + model Widget {}; - @TypeSpec.Rest.Private.resourceLocation(Widget) - op test(): string; + @TypeSpec.Rest.Private.resourceLocation(Widget) + op test(): string; - scalar WidgetLocation extends ResourceLocation; - `); + scalar WidgetLocation extends ResourceLocation; + `); expectDiagnostics(diagnostics, [ { @@ -33,14 +26,13 @@ describe("rest: rest decorators", () => { }); it("marks a model type as a resource location for a specific type", async () => { - const { WidgetLocation } = (await runner.compile(` - model Widget {}; + const { WidgetLocation, program } = await Tester.compile(t.code` + model Widget {}; - @test - scalar WidgetLocation extends ResourceLocation; -`)) as { WidgetLocation: Scalar }; + scalar ${t.scalar("WidgetLocation")} extends ResourceLocation; + `); - const resourceType = getResourceLocationType(runner.program, WidgetLocation.baseScalar!); + const resourceType = getResourceLocationType(program, WidgetLocation.baseScalar!); ok(resourceType); strictEqual(resourceType!.name, "Widget"); }); diff --git a/packages/rest/test/routes.test.ts b/packages/rest/test/routes.test.ts index d63baf40dd3..3841570f663 100644 --- a/packages/rest/test/routes.test.ts +++ b/packages/rest/test/routes.test.ts @@ -1,14 +1,9 @@ import { ModelProperty, Operation } from "@typespec/compiler"; -import { expectDiagnostics } from "@typespec/compiler/testing"; +import { expectDiagnostics, t } from "@typespec/compiler/testing"; import { isSharedRoute } from "@typespec/http"; import { deepStrictEqual, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; -import { - compileOperations, - createRestTestRunner, - getOperations, - getRoutesFor, -} from "./test-host.js"; +import { Tester, compileOperations, getOperations, getRoutesFor } from "./test-host.js"; describe("rest: routes", () => { it("always produces a route starting with /", async () => { @@ -222,9 +217,11 @@ describe("rest: routes", () => { }); it("emit diagnostic if passing arguments to autoroute decorators", async () => { - const [_, diagnostics] = await compileOperations(` + const [_, diagnostics] = await compileOperations( + ` @autoRoute("/test") op test(): string; - `); + `, + ); expectDiagnostics(diagnostics, { code: "invalid-argument-count", @@ -234,7 +231,8 @@ describe("rest: routes", () => { describe("use of @route with @autoRoute", () => { it("can override library operation route in service", async () => { - const ops = await getOperations(` + const ops = await getOperations( + ` namespace Lib { @route("one") op action(): void; @@ -246,7 +244,8 @@ describe("rest: routes", () => { @route("my") op my2 is Lib.action; } - `); + `, + ); strictEqual(ops[0].verb, "get"); strictEqual(ops[0].path, "/one"); strictEqual(ops[1].verb, "get"); @@ -254,7 +253,8 @@ describe("rest: routes", () => { }); it("can override library interface route in service", async () => { - const ops = await getOperations(` + const ops = await getOperations( + ` namespace Lib { @route("one") interface Ops { @@ -269,7 +269,8 @@ describe("rest: routes", () => { @route("my") interface Mys2 extends Lib.Ops {} } - `); + `, + ); strictEqual(ops[0].verb, "get"); strictEqual(ops[0].path, "/"); strictEqual(ops[1].verb, "get"); @@ -277,7 +278,8 @@ describe("rest: routes", () => { }); it("can override library interface route in service without changing library", async () => { - const ops = await getOperations(` + const ops = await getOperations( + ` namespace Lib { @route("one") interface Ops { @@ -291,7 +293,8 @@ describe("rest: routes", () => { op op2 is Lib.Ops.action; } - `); + `, + ); strictEqual(ops[1].verb, "get"); strictEqual(ops[1].path, "/my"); strictEqual(ops[1].container.kind, "Interface"); @@ -301,7 +304,8 @@ describe("rest: routes", () => { }); it("prepends @route in service when library operation uses @autoRoute", async () => { - const ops = await getOperations(` + const ops = await getOperations( + ` namespace Lib { @autoRoute op action(@path @segment("pets") id: string): void; @@ -314,7 +318,8 @@ describe("rest: routes", () => { @route("my") op my2 is Lib.action; } - `); + `, + ); strictEqual(ops[0].verb, "get"); strictEqual(ops[0].path, "/pets/{id}"); strictEqual(ops[1].verb, "get"); @@ -322,7 +327,8 @@ describe("rest: routes", () => { }); it("prepends @route in service when library interface operation uses @autoRoute", async () => { - const ops = await getOperations(` + const ops = await getOperations( + ` namespace Lib { interface Ops { @autoRoute @@ -336,7 +342,8 @@ describe("rest: routes", () => { @route("my") interface Mys2 extends Lib.Ops {}; } - `); + `, + ); strictEqual(ops[0].verb, "get"); strictEqual(ops[0].path, "/pets/{id}"); strictEqual(ops[1].verb, "get"); @@ -344,7 +351,8 @@ describe("rest: routes", () => { }); it("prepends @route in service when library interface uses @autoRoute", async () => { - const ops = await getOperations(` + const ops = await getOperations( + ` namespace Lib { @autoRoute interface Ops { @@ -358,7 +366,8 @@ describe("rest: routes", () => { @route("my") interface Mys2 extends Lib.Ops {}; } - `); + `, + ); strictEqual(ops[0].verb, "get"); strictEqual(ops[0].path, "/pets/{id}"); strictEqual(ops[1].verb, "get"); @@ -491,20 +500,17 @@ describe("rest: routes", () => { }); it("@autoRoute operations can also be shared routes", async () => { - const runner = await createRestTestRunner(); - const { get1, get2 } = (await runner.compile(` - @test + const { get1, get2, program } = await Tester.compile(t.code` @autoRoute @sharedRoute - op get1(@segment("get1") @path name: string): string; + op ${t.op("get1")}(@segment("get1") @path name: string): string; - @test @autoRoute - op get2(@segment("get2") @path name: string): string; - `)) as { get1: Operation; get2: Operation }; + op ${t.op("get2")}(@segment("get2") @path name: string): string; + `); - strictEqual(isSharedRoute(runner.program, get1), true); - strictEqual(isSharedRoute(runner.program, get2), false); + strictEqual(isSharedRoute(program, get1), true); + strictEqual(isSharedRoute(program, get2), false); }); it("emits a diagnostic when @sharedRoute is used on action without explicit name", async () => { diff --git a/packages/rest/test/test-host.ts b/packages/rest/test/test-host.ts index d72f21388d9..e1e75a0f87d 100644 --- a/packages/rest/test/test-host.ts +++ b/packages/rest/test/test-host.ts @@ -1,11 +1,6 @@ -import { Diagnostic } from "@typespec/compiler"; -import { - BasicTestRunner, - createTestHost, - createTestWrapper, - expectDiagnosticEmpty, - TestHost, -} from "@typespec/compiler/testing"; +import type { Diagnostic } from "@typespec/compiler"; +import { resolvePath } from "@typespec/compiler"; +import { createTester, expectDiagnosticEmpty } from "@typespec/compiler/testing"; import { getAllHttpServices, HttpOperation, @@ -13,18 +8,12 @@ import { HttpVerb, } from "@typespec/http"; import { unsafe_RouteResolutionOptions as RouteResolutionOptions } from "@typespec/http/experimental"; -import { HttpTestLibrary } from "@typespec/http/testing"; -import { RestTestLibrary } from "../src/testing/index.js"; -export async function createRestTestHost(): Promise { - return createTestHost({ - libraries: [HttpTestLibrary, RestTestLibrary], - }); -} -export async function createRestTestRunner(): Promise { - const host = await createRestTestHost(); - return createTestWrapper(host, { autoUsings: ["TypeSpec.Http", "TypeSpec.Rest"] }); -} +export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/http", "@typespec/rest"], +}) + .importLibraries() + .using("Http", "Rest"); export interface RouteDetails { path: string; @@ -51,9 +40,6 @@ export interface SimpleOperationDetails { path: string; params: { params: Array<{ name: string; type: HttpOperationParameter["type"] }>; - /** - * name of explicit `@body` parameter or array of unannotated parameter names that make up the body. - */ body?: string | string[]; }; } @@ -86,22 +72,17 @@ export async function getOperationsWithServiceNamespace( code: string, routeOptions?: RouteResolutionOptions, ): Promise<[HttpOperation[], readonly Diagnostic[]]> { - const runner = await createRestTestRunner(); - await runner.compileAndDiagnose( + const [result, diagnostics] = await Tester.compileAndDiagnose( `@service(#{title: "Test Service"}) namespace TestService; ${code}`, - { - noEmit: true, - }, ); - const [services] = getAllHttpServices(runner.program, routeOptions); - return [services[0].operations, runner.program.diagnostics]; + const [services] = getAllHttpServices(result.program, routeOptions); + return [services[0].operations, diagnostics]; } export async function getOperations(code: string): Promise { - const runner = await createRestTestRunner(); - await runner.compile(code); - const [services, diagnostics] = getAllHttpServices(runner.program); + const { program } = await Tester.compile(code); + const [services, diagnostics] = getAllHttpServices(program); expectDiagnosticEmpty(diagnostics); return services[0].operations; From 600686c92a9d3a484106e390df7ab0cd503aee27 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 14 May 2025 10:18:01 -0700 Subject: [PATCH 40/55] migrate http and better node resolution --- packages/compiler/src/testing/rule-tester.ts | 7 +- packages/compiler/src/testing/test-host-v2.ts | 11 +- .../test/testing/test-host-v2.test.ts | 9 + packages/http/test/auth.test.ts | 21 +- .../typekit/http-operation.test.ts | 59 ++- .../experimental/typekit/http-request.test.ts | 143 ++++--- .../typekit/http-response.test.ts | 49 +-- packages/http/test/file.test.ts | 34 +- packages/http/test/http-decorators.test.ts | 380 ++++++++---------- packages/http/test/merge-patch.test.ts | 17 +- packages/http/test/overloads.test.ts | 51 +-- packages/http/test/plaindata.test.ts | 49 +-- packages/http/test/routes.test.ts | 22 +- .../op-reference-container-route.test.ts | 6 +- packages/http/test/test-host.ts | 54 +-- .../http/test/typekit/http-opperation.test.ts | 47 +-- .../http/test/typekit/http-request.test.ts | 124 +++--- 17 files changed, 477 insertions(+), 606 deletions(-) diff --git a/packages/compiler/src/testing/rule-tester.ts b/packages/compiler/src/testing/rule-tester.ts index 3ec2a9f3a62..4bb3be9e2ca 100644 --- a/packages/compiler/src/testing/rule-tester.ts +++ b/packages/compiler/src/testing/rule-tester.ts @@ -11,7 +11,7 @@ import { } from "../core/types.js"; import { DiagnosticMatch, expectDiagnosticEmpty, expectDiagnostics } from "./expect.js"; import { resolveVirtualPath, trimBlankLines } from "./test-utils.js"; -import { BasicTestRunner } from "./types.js"; +import { BasicTestRunner, TesterInstance } from "./types.js"; export interface LinterRuleTester { expect(code: string): LinterRuleTestExpect; @@ -28,7 +28,7 @@ export interface ApplyCodeFixExpect { } export function createLinterRuleTester( - runner: BasicTestRunner, + runner: BasicTestRunner | TesterInstance, ruleDef: LinterRuleDefinition, libraryName: string, ): LinterRuleTester { @@ -71,7 +71,8 @@ export function createLinterRuleTester( await applyCodeFixReal(host, codefix); ok(content, "No content was written to the host."); - const offset = runner.fs.get(resolveVirtualPath("./main.tsp"))?.indexOf(code); + const fs = "keys" in runner.fs ? runner.fs : runner.fs.fs; + const offset = fs.get(resolveVirtualPath("./main.tsp"))?.indexOf(code); strictEqual(trimBlankLines(content.slice(offset)), trimBlankLines(expectedCode)); } } diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index c145905531d..830fea4907b 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -1,11 +1,10 @@ import { readFile, realpath } from "fs/promises"; import { pathToFileURL } from "url"; -import { getSymNode } from "../core/binder.js"; import { compilerAssert } from "../core/diagnostics.js"; import { getEntityName } from "../core/helpers/type-name-utils.js"; import { NodeHost } from "../core/node-host.js"; import { CompilerOptions } from "../core/options.js"; -import { getNodeAtPosition } from "../core/parser.js"; +import { getIdentifierContext, getNodeAtPosition } from "../core/parser.js"; import { getRelativePathFromDirectory, joinPaths, resolvePath } from "../core/path-utils.js"; import { Program, compile as coreCompile } from "../core/program.js"; import { createSourceLoader } from "../core/source-loader.js"; @@ -379,13 +378,13 @@ function extractMarkedEntities( if (!node) { throw new Error(`Could not find node at ${pos}`); } - const sym = program.checker.resolveRelatedSymbols(node as any)?.[0]; - if (sym === undefined) { + const { node: contextNode } = getIdentifierContext(node as any); + if (contextNode === undefined) { throw new Error( - `Could not find symbol for ${name} at ${pos}. File content: ${file.file.text}`, + `Could not find context node for ${name} at ${pos}. File content: ${file.file.text}`, ); } - const entity = program.checker.getTypeOrValueForNode(getSymNode(sym)); + const entity = program.checker.getTypeOrValueForNode(contextNode); if (entity === null) { throw new Error( `Expected ${name} to be of entity kind ${markerConfig?.entityKind} but got null (Means a value failed to resolve) at ${pos}`, diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/test-host-v2.test.ts index 709c97ef5bb..274e96169ed 100644 --- a/packages/compiler/test/testing/test-host-v2.test.ts +++ b/packages/compiler/test/testing/test-host-v2.test.ts @@ -123,6 +123,15 @@ describe("extract types", () => { expect(res.prop.kind).toBe("ModelProperty"); }); + it("model property in operation", async () => { + const res = await Tester.compile(t.code` + op test( + ${t.modelProperty("prop")}: string; + ): void; + `); + expect(res.prop.kind).toBe("ModelProperty"); + }); + it("union variant", async () => { const res = await Tester.compile(t.code` union Bar { diff --git a/packages/http/test/auth.test.ts b/packages/http/test/auth.test.ts index a95355df986..47d769a874d 100644 --- a/packages/http/test/auth.test.ts +++ b/packages/http/test/auth.test.ts @@ -1,23 +1,18 @@ -import { Operation } from "@typespec/compiler"; -import { BasicTestRunner } from "@typespec/compiler/testing"; import { ok, strictEqual } from "assert"; -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { getAuthenticationForOperation } from "../src/auth.js"; -import { createHttpTestRunner } from "./test-host.js"; - -let runner: BasicTestRunner; - -beforeEach(async () => { - runner = await createHttpTestRunner(); -}); +import { Tester } from "./test-host.js"; describe("per operation authentication", () => { /** Test function that will expect api key auth only and return the name of the one selected */ async function getTestOperationApiKeyAuthName(code: string) { - const { test } = (await runner.compile(code)) as { test: Operation }; + const { test, program } = await Tester.compile(code); - ok(test, "Should have operation called test marked with @test"); - const auth = getAuthenticationForOperation(runner.program, test); + ok( + test.entityKind === "Type" && test.kind === "Operation", + "Should have operation called test marked with @test", + ); + const auth = getAuthenticationForOperation(program, test); const scheme = auth?.options[0].schemes[0]; strictEqual(scheme?.type, "apiKey"); return scheme.name; diff --git a/packages/http/test/experimental/typekit/http-operation.test.ts b/packages/http/test/experimental/typekit/http-operation.test.ts index 0b7034571c4..d33f742fb13 100644 --- a/packages/http/test/experimental/typekit/http-operation.test.ts +++ b/packages/http/test/experimental/typekit/http-operation.test.ts @@ -1,22 +1,15 @@ -import { Model, Operation } from "@typespec/compiler"; -import { BasicTestRunner, expectDiagnostics } from "@typespec/compiler/testing"; +import { expectDiagnostics, t } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; -import { assert, beforeEach, describe, expect, it } from "vitest"; -import { createHttpTestRunner } from "./../../test-host.js"; +import { assert, describe, expect, it } from "vitest"; +import { Tester } from "./../../test-host.js"; // Activate Http TypeKit augmentation import "../../../src/experimental/typekit/index.js"; -let runner: BasicTestRunner; - -beforeEach(async () => { - runner = await createHttpTestRunner(); -}); - describe("httpOperation:getResponses", () => { it("should get responses", async () => { - const { getFoo } = (await runner.compile(` - @test model Foo { + const { getFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { @visibility(Lifecycle.Create) id: int32; age: int32; @@ -24,18 +17,18 @@ describe("httpOperation:getResponses", () => { } @error - @test model Error { + model ${t.model("Error")} { message: string; code: int32 } @route("/foo") @get - @test op getFoo(): Foo | Error; - `)) as { getFoo: Operation; Foo: Model; Error: Model }; + op ${t.op("getFoo")}(): Foo | Error; + `); - const httpOperation = $(runner.program).httpOperation.get(getFoo); - const responses = $(runner.program).httpOperation.flattenResponses(httpOperation); + const httpOperation = $(program).httpOperation.get(getFoo); + const responses = $(program).httpOperation.flattenResponses(httpOperation); expect(responses).toHaveLength(2); expect(responses[0].statusCode).toBe(200); expect(responses[0].contentType).toBe("application/json"); @@ -44,8 +37,8 @@ describe("httpOperation:getResponses", () => { }); it("should get responses with multiple status codes", async () => { - const { getFoo } = (await runner.compile(` - @test model Foo { + const { getFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { @visibility(Lifecycle.Create) id: int32; age: int32; @@ -54,11 +47,11 @@ describe("httpOperation:getResponses", () => { @route("/foo") @get - @test op getFoo(): Foo | void; - `)) as { getFoo: Operation; Foo: Model; Error: Model }; + op ${t.op("getFoo")}(): Foo | void; + `); - const httpOperation = $(runner.program).httpOperation.get(getFoo); - const responses = $(runner.program).httpOperation.flattenResponses(httpOperation); + const httpOperation = $(program).httpOperation.get(getFoo); + const responses = $(program).httpOperation.flattenResponses(httpOperation); expect(responses).toHaveLength(2); expect(responses[0].statusCode).toBe(200); expect(responses[0].contentType).toBe("application/json"); @@ -67,8 +60,8 @@ describe("httpOperation:getResponses", () => { }); it("should get responses with multiple status codes and contentTypes", async () => { - const { getFoo } = (await runner.compile(` - @test model Foo { + const { getFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { @visibility(Lifecycle.Create) id: int32; age: int32; @@ -76,18 +69,18 @@ describe("httpOperation:getResponses", () => { } @error - @test model Error { + model ${t.model("Error")} { message: string; code: int32 } @route("/foo") @get - @test op getFoo(): Foo | {...Foo, @header contentType: "text/plain"} | Error; - `)) as { getFoo: Operation; Foo: Model; Error: Model }; + op ${t.op("getFoo")}(): Foo | {...Foo, @header contentType: "text/plain"} | Error; + `); - const httpOperation = $(runner.program).httpOperation.get(getFoo); - const responses = $(runner.program).httpOperation.flattenResponses(httpOperation); + const httpOperation = $(program).httpOperation.get(getFoo); + const responses = $(program).httpOperation.flattenResponses(httpOperation); expect(responses).toHaveLength(3); expect(responses[0].statusCode).toBe(200); expect(responses[0].contentType).toBe("application/json"); @@ -99,15 +92,15 @@ describe("httpOperation:getResponses", () => { }); it("should get diagnostics from httpOperation.get", async () => { - const [{ getFoo }] = await runner.compileAndDiagnose(` + const [{ getFoo, program }, _] = await Tester.compileAndDiagnose(t.code` @route("/foo/{missing-param}") @get - @test op getFoo(): Foo | Error; + op ${t.op("getFoo")}(): void; `); assert.ok(getFoo.kind === "Operation"); - const [httpOperation, diagnostics] = $(runner.program).httpOperation.get.withDiagnostics(getFoo); + const [httpOperation, diagnostics] = $(program).httpOperation.get.withDiagnostics(getFoo); expect(httpOperation).toBeDefined(); expectDiagnostics(diagnostics, { diff --git a/packages/http/test/experimental/typekit/http-request.test.ts b/packages/http/test/experimental/typekit/http-request.test.ts index 559f898bed1..896b5b18c96 100644 --- a/packages/http/test/experimental/typekit/http-request.test.ts +++ b/packages/http/test/experimental/typekit/http-request.test.ts @@ -1,22 +1,15 @@ -import { Model, Operation } from "@typespec/compiler"; -import { BasicTestRunner } from "@typespec/compiler/testing"; +import { t } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; -import { beforeEach, describe, expect, it } from "vitest"; -import { createHttpTestRunner } from "./../../test-host.js"; +import { describe, expect, it } from "vitest"; +import { Tester } from "./../../test-host.js"; // Activate Http TypeKit augmentation import "../../../src/experimental/typekit/index.js"; -let runner: BasicTestRunner; - -beforeEach(async () => { - runner = await createHttpTestRunner(); -}); - describe("HttpRequest Body Parameters", () => { it("should get the body parameters model when spread", async () => { - const { createFoo } = (await runner.compile(` - @test model Foo { + const { createFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { id: int32; age: int32; name: string; @@ -24,24 +17,24 @@ describe("HttpRequest Body Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)!; expect(body).toBeDefined(); expect(tk.model.is(body)).toBe(true); - expect((body as Model).properties.size).toBe(3); + expect((body as any).properties.size).toBe(3); }); it("should get the body model params when body is defined explicitly as a property", async () => { - const { createFoo } = (await runner.compile(` + const { createFoo, program } = await Tester.compile(t.code` @route("/foo") @post - @test op createFoo(@body foo: int32): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); + op ${t.op("createFoo")}(@body foo: int32): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)!; @@ -52,8 +45,8 @@ describe("HttpRequest Body Parameters", () => { }); it("should get the body when spread and nested", async () => { - const { createFoo } = (await runner.compile(` - @test model Foo { + const { createFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { @path id: int32; age: int32; name: string; @@ -65,22 +58,22 @@ describe("HttpRequest Body Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)!; expect(body).toBeDefined(); - expect((body as Model).properties.size).toBe(3); + expect((body as any).properties.size).toBe(3); const properties = Array.from(body.properties.values()) - .map((p) => p.name) + .map((p: any) => p.name) .join(","); expect(properties).toBe("age,name,options"); - const optionsParam = (body as Model).properties.get("options")!.type as Model; + const optionsParam = (body as any).properties.get("options").type; const optionsProps = Array.from(optionsParam.properties.values()) - .map((p) => p.name) + .map((p: any) => p.name) .join(","); // TODO: Why do we get the path property token here? @@ -88,8 +81,8 @@ describe("HttpRequest Body Parameters", () => { }); it("should get the body when named body model", async () => { - const { createFoo } = (await runner.compile(` - @test model Foo { + const { createFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { id: int32; age: int32; name: string; @@ -97,9 +90,9 @@ describe("HttpRequest Body Parameters", () => { @route("/foo") @post - @test op createFoo(@body foo: Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); + op ${t.op("createFoo")}(@body foo: Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)!; @@ -107,12 +100,12 @@ describe("HttpRequest Body Parameters", () => { expect(tk.model.is(body)).toBe(true); // Should have a single property called foo expect(body.properties.size).toBe(1); - expect((body.properties.get("foo")?.type as Model).name).toBe("Foo"); + expect((body.properties.get("foo")?.type as any).name).toBe("Foo"); }); it("should get the named body body when combined", async () => { - const { createFoo } = (await runner.compile(` - @test model Foo { + const { createFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { @path id: int32; age: int32; name: string; @@ -120,23 +113,23 @@ describe("HttpRequest Body Parameters", () => { @route("/foo") @post - @test op createFoo(foo: Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); + op ${t.op("createFoo")}(foo: Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)!; expect(body).toBeDefined(); expect(tk.model.is(body)).toBe(true); - expect((body as Model).properties.size).toBe(1); - expect(((body as Model).properties.get("foo")?.type as any).name).toBe("Foo"); + expect((body as any).properties.size).toBe(1); + expect(((body as any).properties.get("foo")?.type as any).name).toBe("Foo"); }); }); describe("HttpRequest Get Parameters", () => { it("should only have body parameters", async () => { - const { createFoo } = (await runner.compile(` - @test model Foo { + const { createFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { id: int32; age: int32; name: string; @@ -144,9 +137,9 @@ describe("HttpRequest Get Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)!; @@ -160,8 +153,8 @@ describe("HttpRequest Get Parameters", () => { }); it("should be able to get parameter options", async () => { - const { createFoo } = (await runner.compile(` - @test model Foo { + const { createFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { @path(#{allowReserved: true}) id: string; # suppress "deprecated" "Test" @header(#{explode: true}) requestId: string[]; @@ -170,9 +163,9 @@ describe("HttpRequest Get Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const headers = tk.httpRequest.getParameters(httpOperation, "header"); @@ -205,8 +198,8 @@ describe("HttpRequest Get Parameters", () => { }); it("should only have header parameters", async () => { - const { createFoo } = (await runner.compile(` - @test model Foo { + const { createFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { @path id: int32; age: int32; name: string; @@ -214,12 +207,12 @@ describe("HttpRequest Get Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); - const body = tk.httpRequest.getBodyParameters(httpOperation)! as Model; + const body = tk.httpRequest.getBodyParameters(httpOperation)! as any; const headers = tk.httpRequest.getParameters(httpOperation, "header"); const path = tk.httpRequest.getParameters(httpOperation, "path")!; const query = tk.httpRequest.getParameters(httpOperation, "query"); @@ -233,8 +226,8 @@ describe("HttpRequest Get Parameters", () => { }); it("should only have path parameters", async () => { - const { createFoo } = (await runner.compile(` - @test model Foo { + const { createFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { @header id: int32; @header age: int32; name: string; @@ -242,12 +235,12 @@ describe("HttpRequest Get Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); - const body = tk.httpRequest.getBodyParameters(httpOperation)! as Model; + const body = tk.httpRequest.getBodyParameters(httpOperation)! as any; const headers = tk.httpRequest.getParameters(httpOperation, "header")!; const path = tk.httpRequest.getParameters(httpOperation, "path"); const query = tk.httpRequest.getParameters(httpOperation, "query"); @@ -262,8 +255,8 @@ describe("HttpRequest Get Parameters", () => { }); it("should only have query parameters", async () => { - const { createFoo } = (await runner.compile(` - @test model Foo { + const { createFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { @query id: int32; @query age: int32; name: string; @@ -271,12 +264,12 @@ describe("HttpRequest Get Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); - const body = tk.httpRequest.getBodyParameters(httpOperation)! as Model; + const body = tk.httpRequest.getBodyParameters(httpOperation)! as any; const headers = tk.httpRequest.getParameters(httpOperation, "header"); const path = tk.httpRequest.getParameters(httpOperation, "path"); const query = tk.httpRequest.getParameters(httpOperation, "query")!; @@ -291,8 +284,8 @@ describe("HttpRequest Get Parameters", () => { }); it("should have query and header parameters", async () => { - const { createFoo } = (await runner.compile(` - @test model Foo { + const { createFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { @query id: int32; @header age: int32; name: string; @@ -300,9 +293,9 @@ describe("HttpRequest Get Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const headerAndQuery = tk.httpRequest.getParameters(httpOperation, ["header", "query"]); diff --git a/packages/http/test/experimental/typekit/http-response.test.ts b/packages/http/test/experimental/typekit/http-response.test.ts index d3ce22b1ea9..ca79a3d9b53 100644 --- a/packages/http/test/experimental/typekit/http-response.test.ts +++ b/packages/http/test/experimental/typekit/http-response.test.ts @@ -1,37 +1,30 @@ -import { Model, Operation } from "@typespec/compiler"; -import { BasicTestRunner } from "@typespec/compiler/testing"; +import { t } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; -import { beforeEach, expect, it } from "vitest"; -import { createHttpTestRunner } from "./../../test-host.js"; +import { expect, it } from "vitest"; +import { Tester } from "./../../test-host.js"; // Activate Http TypeKit augmentation import "../../../src/experimental/typekit/index.js"; -let runner: BasicTestRunner; - -beforeEach(async () => { - runner = await createHttpTestRunner(); -}); - it("should return true for an error response", async () => { - const { getFoo } = (await runner.compile(` - @test model Foo { + const { getFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { id: int32; age: int32; name: string; } @error - @test model Error { + model ${t.model("Error")} { message: string; code: int32 } @route("/foo") @get - @test op getFoo(): Foo | Error; - `)) as { getFoo: Operation; Foo: Model; Error: Model }; - const tk = $(runner.program); + op ${t.op("getFoo")}(): Foo | Error; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(getFoo); const responses = tk.httpOperation.flattenResponses(httpOperation); @@ -41,24 +34,24 @@ it("should return true for an error response", async () => { }); it("should identify a single and default status code", async () => { - const { getFoo } = (await runner.compile(` - @test model Foo { + const { getFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { id: int32; age: int32; name: string; } @error - @test model Error { + model ${t.model("Error")} { message: string; code: int32 } @route("/foo") @get - @test op getFoo(): Foo | Error; - `)) as { getFoo: Operation; Foo: Model; Error: Model }; - const tk = $(runner.program); + op ${t.op("getFoo")}(): Foo | Error; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(getFoo); const responses = tk.httpOperation.flattenResponses(httpOperation); @@ -69,8 +62,8 @@ it("should identify a single and default status code", async () => { }); it("should identify a range status code", async () => { - const { getFoo } = (await runner.compile(` - @test model Foo { + const { getFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { id: int32; age: int32; name: string; @@ -78,16 +71,16 @@ it("should identify a range status code", async () => { } @error - @test model Error { + model ${t.model("Error")} { message: string; code: int32 } @route("/foo") @get - @test op getFoo(): Foo | Error; - `)) as { getFoo: Operation; Foo: Model; Error: Model }; - const tk = $(runner.program); + op ${t.op("getFoo")}(): Foo | Error; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(getFoo); const responses = tk.httpOperation.flattenResponses(httpOperation); diff --git a/packages/http/test/file.test.ts b/packages/http/test/file.test.ts index 68976c43ee7..4710323bcb4 100644 --- a/packages/http/test/file.test.ts +++ b/packages/http/test/file.test.ts @@ -656,7 +656,7 @@ describe("custom file model", () => { ], }, ], - runner, + program, } = await compileOperationsFull(` ${makeFileModel("x-filename")} op example(...SpecFile): SpecFile; @@ -670,7 +670,7 @@ describe("custom file model", () => { (p) => p.type === "header" && p.name === "x-filename", ); ok(requestXFilename); - ok(isHeader(runner.program, requestBody.type.properties.get("filename")!)); + ok(isHeader(program, requestBody.type.properties.get("filename")!)); strictEqual(responseBody?.bodyKind, "file"); expect(responseBody?.property).toStrictEqual(undefined); @@ -680,7 +680,7 @@ describe("custom file model", () => { (p) => p.kind === "header" && p.options.name === "x-filename", ); ok(responseXFilename); - ok(isHeader(runner.program, responseBody.type.properties.get("filename")!)); + ok(isHeader(program, responseBody.type.properties.get("filename")!)); }); it("extends aliased File with header", async () => { @@ -695,7 +695,7 @@ describe("custom file model", () => { ], }, ], - runner, + program, } = await compileOperationsFull(` model StringFile is Http.File; model JsonFile extends StringFile<"application/json"> { @@ -712,7 +712,7 @@ describe("custom file model", () => { (p) => p.type === "header" && p.name === "x-filename", ); ok(requestXFilename); - ok(isHeader(runner.program, requestBody.type.properties.get("filename")!)); + ok(isHeader(program, requestBody.type.properties.get("filename")!)); strictEqual(responseBody?.bodyKind, "file"); expect(responseBody?.property).toStrictEqual(undefined); @@ -722,7 +722,7 @@ describe("custom file model", () => { (p) => p.kind === "header" && p.options.name === "x-filename", ); ok(responseXFilename); - ok(isHeader(runner.program, responseBody.type.properties.get("filename")!)); + ok(isHeader(program, responseBody.type.properties.get("filename")!)); }); it("intersected payload upload and download", async () => { @@ -808,7 +808,7 @@ describe("custom file model", () => { }); it("allows interior metadata using bodyRoot", async () => { - const { operations, runner, diagnostics } = await compileOperationsFull(` + const { operations, program, diagnostics } = await compileOperationsFull(` ${makeFileModel("x-filename")} op example(@bodyRoot specFile: SpecFile): { @bodyRoot specFile: SpecFile }; `); @@ -835,7 +835,7 @@ describe("custom file model", () => { (p) => p.type === "header" && p.name === "x-filename", ); ok(requestXFilename); - ok(isHeader(runner.program, requestBody.type.properties.get("filename")!)); + ok(isHeader(program, requestBody.type.properties.get("filename")!)); strictEqual(responseBody?.bodyKind, "file"); ok(responseBody.property); @@ -846,7 +846,7 @@ describe("custom file model", () => { (p) => p.kind === "header" && p.options.name === "x-filename", ); ok(responseXFilename); - ok(isHeader(runner.program, responseBody.type.properties.get("filename")!)); + ok(isHeader(program, responseBody.type.properties.get("filename")!)); }); describe("multipart", () => { @@ -863,7 +863,7 @@ describe("custom file model", () => { }, ], diagnostics, - runner, + program, } = await compileOperationsFull(` ${makeFileModel("x-filename")} op example(@multipartBody fields: { file: HttpPart }) : { @multipartBody fields: { file: HttpPart}}; @@ -884,7 +884,7 @@ describe("custom file model", () => { (p) => p.options.name === "x-filename", ); ok(requestXFilename); - ok(isHeader(runner.program, requestPartBody.type.properties.get("filename")!)); + ok(isHeader(program, requestPartBody.type.properties.get("filename")!)); strictEqual(multipartResponseBody?.bodyKind, "multipart"); ok(multipartResponseBody?.property); @@ -899,7 +899,7 @@ describe("custom file model", () => { (p) => p.options.name === "x-filename", ); ok(responseXFilename); - ok(isHeader(runner.program, responsePartBody.type.properties.get("filename")!)); + ok(isHeader(program, responsePartBody.type.properties.get("filename")!)); }); it("intersect payload form-data upload and download", async () => { @@ -915,7 +915,7 @@ describe("custom file model", () => { }, ], diagnostics, - runner, + program, } = await compileOperationsFull(` ${makeFileModel("x-filename")} op example(@multipartBody fields: { file: HttpPart }) : { @multipartBody fields: { file: HttpPart};}; @@ -936,12 +936,12 @@ describe("custom file model", () => { (p) => p.options.name === "x-filename", ); ok(requestXFilename); - ok(isHeader(runner.program, requestPartBody.type.properties.get("filename")!)); + ok(isHeader(program, requestPartBody.type.properties.get("filename")!)); const requestXFoo = multipartRequestBody?.parts[0].headers.find( (p) => p.options.name === "x-foo", ); ok(requestXFoo); - ok(isHeader(runner.program, requestPartBody.type.properties.get("xFoo")!)); + ok(isHeader(program, requestPartBody.type.properties.get("xFoo")!)); strictEqual(multipartResponseBody?.bodyKind, "multipart"); ok(multipartResponseBody?.property); @@ -956,12 +956,12 @@ describe("custom file model", () => { (p) => p.options.name === "x-filename", ); ok(responseXFilename); - ok(isHeader(runner.program, responsePartBody.type.properties.get("filename")!)); + ok(isHeader(program, responsePartBody.type.properties.get("filename")!)); const responseXBar = multipartResponseBody?.parts[0].headers.find( (p) => p.options.name === "x-bar", ); ok(responseXBar); - ok(isHeader(runner.program, responsePartBody.type.properties.get("xBar")!)); + ok(isHeader(program, responsePartBody.type.properties.get("xBar")!)); }); }); }); diff --git a/packages/http/test/http-decorators.test.ts b/packages/http/test/http-decorators.test.ts index 926b1270a3d..1e88a9f7c63 100644 --- a/packages/http/test/http-decorators.test.ts +++ b/packages/http/test/http-decorators.test.ts @@ -1,11 +1,7 @@ -import { ModelProperty, Namespace } from "@typespec/compiler"; -import { - BasicTestRunner, - expectDiagnosticEmpty, - expectDiagnostics, -} from "@typespec/compiler/testing"; +import { ModelProperty } from "@typespec/compiler"; +import { expectDiagnosticEmpty, expectDiagnostics, t } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { getAuthentication, getCookieParamOptions, @@ -27,18 +23,13 @@ import { isStatusCode, } from "../src/decorators.js"; import { includeInapplicableMetadataInPayload } from "../src/private.decorators.js"; -import { createHttpTestRunner } from "./test-host.js"; -describe("http: decorators", () => { - let runner: BasicTestRunner; - - beforeEach(async () => { - runner = await createHttpTestRunner(); - }); +import { Tester } from "./test-host.js"; +describe("http: decorators", () => { describe("emit diagnostic if passing arguments to verb decorators", () => { ["get", "post", "put", "delete", "head"].forEach((verb) => { it(`@${verb}`, async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @${verb}("/test") op test(): string; `); @@ -50,7 +41,7 @@ describe("http: decorators", () => { }); it(`@patch`, async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @patch("/test") op test(): string; `); @@ -63,7 +54,7 @@ describe("http: decorators", () => { describe("@header", () => { it("emit diagnostics when @header is not used on model property", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @header op test(): string; @header model Foo {} @@ -84,7 +75,7 @@ describe("http: decorators", () => { }); it("emit diagnostics when header name is not a string or of value HeaderOptions", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` op test(@header(123) MyHeader: string): string; op test2(@header(#{ name: 123 }) MyHeader: string): string; op test3(@header(#{ format: "invalid" }) MyHeader: string): string; @@ -108,51 +99,51 @@ describe("http: decorators", () => { }); it("generate header name from property name", async () => { - const { MyHeader } = await runner.compile(` - op test(@test @header MyHeader: string): string; + const { MyHeader, program } = await Tester.compile(t.code` + op test(@header ${t.modelProperty("MyHeader")}: string): string; `); - ok(isHeader(runner.program, MyHeader)); - strictEqual(getHeaderFieldName(runner.program, MyHeader), "my-header"); + ok(isHeader(program, MyHeader)); + strictEqual(getHeaderFieldName(program, MyHeader), "my-header"); }); it("override header name with 1st parameter", async () => { - const { MyHeader } = await runner.compile(` - op test(@test @header("x-my-header") MyHeader: string): string; + const { MyHeader, program } = await Tester.compile(t.code` + op test( @header("x-my-header") ${t.modelProperty("MyHeader")}: string): string; `); - strictEqual(getHeaderFieldName(runner.program, MyHeader), "x-my-header"); + strictEqual(getHeaderFieldName(program, MyHeader), "x-my-header"); }); it("override header with HeaderOptions", async () => { - const { SingleString } = await runner.compile(` - @put op test(@test @header(#{name: "x-single-string"}) SingleString: string): string; + const { SingleString, program } = await Tester.compile(t.code` + @put op test(@header(#{name: "x-single-string"}) ${t.modelProperty("SingleString")}: string): string; `); - deepStrictEqual(getHeaderFieldOptions(runner.program, SingleString), { + deepStrictEqual(getHeaderFieldOptions(program, SingleString), { type: "header", name: "x-single-string", }); - strictEqual(getHeaderFieldName(runner.program, SingleString), "x-single-string"); + strictEqual(getHeaderFieldName(program, SingleString), "x-single-string"); }); it("specify explode", async () => { - const { MyHeader } = await runner.compile(` - @put op test(@test @header(#{ explode: true }) MyHeader: string): string; + const { MyHeader, program } = await Tester.compile(t.code` + @put op test(@header(#{ explode: true }) ${t.modelProperty("MyHeader")}: string): string; `); - deepStrictEqual(getHeaderFieldOptions(runner.program, MyHeader), { + deepStrictEqual(getHeaderFieldOptions(program, MyHeader), { type: "header", name: "my-header", explode: true, }); - strictEqual(getHeaderFieldName(runner.program, MyHeader), "my-header"); + strictEqual(getHeaderFieldName(program, MyHeader), "my-header"); }); }); describe("@cookie", () => { it("emit diagnostics when @cookie is not used on model property", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @cookie op test(): string; @cookie model Foo {} @@ -173,10 +164,10 @@ describe("http: decorators", () => { }); it("emit diagnostics when cookie name is not a string or of type CookieOptions", async () => { - const diagnostics = await runner.diagnose(` - op test(@cookie(123) MyCookie: string): string; - op test2(@cookie(#{ name: 123 }) MyCookie: string): string; - op test3(@cookie(#{ format: "invalid" }) MyCookie: string): string; + const diagnostics = await Tester.diagnose(` + op test(@cookie(123) myCookie: string): string; + op test2(@cookie(#{ name: 123 })myCookie: string): string; + op test3(@cookie(#{ format: "invalid" }) myCookie: string): string; `); expectDiagnostics(diagnostics, [ @@ -193,34 +184,34 @@ describe("http: decorators", () => { }); it("generate cookie name from property name", async () => { - const { myCookie } = await runner.compile(` - op test(@test @cookie myCookie: string): string; + const { myCookie, program } = await Tester.compile(t.code` + op test(@cookie ${t.modelProperty("myCookie")}: string): string; `); - ok(isCookieParam(runner.program, myCookie)); - strictEqual(getCookieParamOptions(runner.program, myCookie)?.name, "my_cookie"); + ok(isCookieParam(program, myCookie)); + strictEqual(getCookieParamOptions(program, myCookie)?.name, "my_cookie"); }); it("override cookie name with 1st parameter", async () => { - const { myCookie } = await runner.compile(` - op test(@test @cookie("my-cookie") myCookie: string): string; + const { myCookie, program } = await Tester.compile(t.code` + op test(@cookie("my-cookie") ${t.modelProperty("myCookie")}: string): string; `); - strictEqual(getCookieParamOptions(runner.program, myCookie)?.name, "my-cookie"); + strictEqual(getCookieParamOptions(program, myCookie)?.name, "my-cookie"); }); it("override cookie with CookieOptions", async () => { - const { myCookie } = await runner.compile(` - op test(@test @cookie(#{name: "my-cookie"}) myCookie: string): string; + const { myCookie, program } = await Tester.compile(t.code` + op test(@cookie(#{name: "my-cookie"}) ${t.modelProperty("myCookie")}: string): string; `); - strictEqual(getCookieParamOptions(runner.program, myCookie)?.name, "my-cookie"); + strictEqual(getCookieParamOptions(program, myCookie)?.name, "my-cookie"); }); }); describe("@query", () => { it("emit diagnostics when @query is not used on model property", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @query op test(): string; @query model Foo {} @@ -241,7 +232,7 @@ describe("http: decorators", () => { }); it("emit diagnostics when query name is not a string or of type QueryOptions", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` op test(@query(123) MyQuery: string): string; op test2(@query(#{name: 123}) MyQuery: string): string; op test3(@query(#{format: "invalid"}) MyQuery: string): string; @@ -261,27 +252,27 @@ describe("http: decorators", () => { }); it("generate query name from property name", async () => { - const { select } = await runner.compile(` - op test(@test @query select: string): string; + const { select, program } = await Tester.compile(t.code` + op test(@query ${t.modelProperty("select")}: string): string; `); - ok(isQueryParam(runner.program, select)); - strictEqual(getQueryParamName(runner.program, select), "select"); + ok(isQueryParam(program, select)); + strictEqual(getQueryParamName(program, select), "select"); }); it("override query name with 1st parameter", async () => { - const { select } = await runner.compile(` - op test(@test @query("$select") select: string): string; + const { select, program } = await Tester.compile(t.code` + op test(@query("$select") ${t.modelProperty("select")}: string): string; `); - strictEqual(getQueryParamName(runner.program, select), "$select"); + strictEqual(getQueryParamName(program, select), "$select"); }); it("specify explode: true", async () => { - const { selects } = await runner.compile(` - op test(@test @query(#{ explode: true }) selects: string[]): string; + const { selects, program } = await Tester.compile(t.code` + op test(@query(#{ explode: true }) ${t.modelProperty("selects")}: string[]): string; `); - expect(getQueryParamOptions(runner.program, selects)).toEqual({ + expect(getQueryParamOptions(program, selects)).toEqual({ type: "query", name: "selects", explode: true, @@ -291,7 +282,7 @@ describe("http: decorators", () => { describe("@route", () => { it("emit diagnostics when duplicated unshared routes are applied", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @route("/test") op test(): string; @route("/test") op test2(): string; `); @@ -309,7 +300,7 @@ describe("http: decorators", () => { }); it("emit diagnostics when not all duplicated routes are declared shared on each op conflicting", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @route("/test") @sharedRoute op test(): string; @route("/test") @sharedRoute op test2(): string; @route("/test") op test3(): string; @@ -331,7 +322,7 @@ describe("http: decorators", () => { }); it("do not emit diagnostics when duplicated shared routes are applied", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @route("/test") @sharedRoute op test(): string; @route("/test") @sharedRoute op test2(): string; `); @@ -340,7 +331,7 @@ describe("http: decorators", () => { }); it("do not emit diagnostics routes sharing path but not same verb", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @route("/test") @sharedRoute op test(): string; @route("/test") @sharedRoute op test2(): string; @route("/test") @post op test3(): string; @@ -351,7 +342,7 @@ describe("http: decorators", () => { describe("@path", () => { it("emit diagnostics when @path is not used on model property", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @path op test(): string; @path model Foo {} @@ -372,7 +363,7 @@ describe("http: decorators", () => { }); it("accept optional path when specified at the root of @route", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @route("{/myPath}") op test(@path myPath?: string): string; `); @@ -380,7 +371,7 @@ describe("http: decorators", () => { }); it("accept optional path when specified in route", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @route("base{/myPath}") op test(@path myPath?: string): string; `); @@ -388,7 +379,7 @@ describe("http: decorators", () => { }); it("accept optional path when not used as operation parameter", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @route("/") op test(): {@path myPath?: string}; `); @@ -396,7 +387,7 @@ describe("http: decorators", () => { }); it("emit diagnostics when path name is not a string", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` op test(@path(123) MyPath: string): string; `); @@ -408,33 +399,33 @@ describe("http: decorators", () => { }); it("generate path name from property name", async () => { - const { select } = await runner.compile(` - op test(@test @path select: string): string; + const { select, program } = await Tester.compile(t.code` + op test(@path ${t.modelProperty("select")}: string): string; `); - ok(isPathParam(runner.program, select)); - strictEqual(getPathParamName(runner.program, select), "select"); + ok(isPathParam(program, select)); + strictEqual(getPathParamName(program, select), "select"); }); it("override path name with 1st parameter", async () => { - const { select } = await runner.compile(` - op test(@test @path("$select") select: string): string; + const { select, program } = await Tester.compile(t.code` + op test(@path("$select") ${t.modelProperty("select")}: string): string; `); - deepStrictEqual(getPathParamOptions(runner.program, select), { + deepStrictEqual(getPathParamOptions(program, select), { type: "path", name: "$select", allowReserved: false, explode: false, style: "simple", }); - strictEqual(getPathParamName(runner.program, select), "$select"); + strictEqual(getPathParamName(program, select), "$select"); }); }); describe("@body", () => { it("emit diagnostics when @body is not used on model property", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @body op test(): string; @body model Foo {} @@ -455,17 +446,17 @@ describe("http: decorators", () => { }); it("set the body with @body", async () => { - const { body } = await runner.compile(` - @post op test(@test @body body: string): string; + const { body, program } = await Tester.compile(t.code` + @post op test(@body ${t.modelProperty("body")}: string): string; `); - ok(isBody(runner.program, body)); + ok(isBody(program, body)); }); }); describe("@bodyRoot", () => { it("emit diagnostics when @body is not used on model property", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @bodyRoot op test(): string; @bodyRoot model Foo {} @@ -486,17 +477,17 @@ describe("http: decorators", () => { }); it("set the body root with @bodyRoot", async () => { - const { body } = (await runner.compile(` - @post op test(@test @bodyRoot body: string): string; - `)) as { body: ModelProperty }; + const { body, program } = await Tester.compile(t.code` + @post op test(@bodyRoot ${t.modelProperty("body")}: string): string; + `); - ok(isBodyRoot(runner.program, body)); + ok(isBodyRoot(program, body)); }); }); describe("@bodyIgnore", () => { it("emit diagnostics when @body is not used on model property", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @bodyIgnore op test(): string; @bodyIgnore model Foo {} @@ -517,17 +508,17 @@ describe("http: decorators", () => { }); it("isBodyIgnore returns true on property decorated", async () => { - const { body } = await runner.compile(` - @post op test(@test @bodyIgnore body: string): string; + const { body, program } = await Tester.compile(t.code` + @post op test(@bodyIgnore ${t.modelProperty("body")}: string): string; `); - ok(isBodyIgnore(runner.program, body as ModelProperty)); + ok(isBodyIgnore(program, body)); }); }); describe("@statusCode", () => { it("emit diagnostics when @statusCode is not used on model property", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @statusCode op test(): string; @statusCode model Foo {} @@ -548,7 +539,7 @@ describe("http: decorators", () => { }); it("emits error if multiple properties are decorated with `@statusCode` in return type", async () => { - const diagnostics = await runner.diagnose( + const diagnostics = await Tester.diagnose( ` model CreatedOrUpdatedResponse { @statusCode ok: "200"; @@ -567,7 +558,7 @@ describe("http: decorators", () => { }); it("emits error if multiple `@statusCode` decorators are composed together", async () => { - const diagnostics = await runner.diagnose( + const diagnostics = await Tester.diagnose( ` model CustomUnauthorizedResponse { @statusCode _: 401; @@ -590,30 +581,30 @@ describe("http: decorators", () => { }); it("set numeric statusCode with @statusCode", async () => { - const { code } = (await runner.compile(` + const { code, program } = await Tester.compile(t.code` op test(): { - @test @statusCode code: 201 + @statusCode ${t.modelProperty("code")}: 201 }; - `)) as { code: ModelProperty }; + `); - ok(isStatusCode(runner.program, code)); - deepStrictEqual(getStatusCodes(runner.program, code), [201]); + ok(isStatusCode(program, code)); + deepStrictEqual(getStatusCodes(program, code), [201]); }); it("set range statusCode with @statusCode", async () => { - const { code } = (await runner.compile(` + const { code, program } = await Tester.compile(t.code` op test(): { - @test @statusCode @minValue(200) @maxValue(299) code: int32; + @statusCode @minValue(200) @maxValue(299) ${t.modelProperty("code")}: int32; }; - `)) as { code: ModelProperty }; + `); - ok(isStatusCode(runner.program, code)); - deepStrictEqual(getStatusCodes(runner.program, code), [{ start: 200, end: 299 }]); + ok(isStatusCode(program, code)); + deepStrictEqual(getStatusCodes(program, code), [{ start: 200, end: 299 }]); }); describe("invalid status codes", () => { async function checkInvalid(code: string, message: string) { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` op test(): { @statusCode code: ${code} }; @@ -633,7 +624,7 @@ describe("http: decorators", () => { describe("@server", () => { it("emit diagnostics when @server is not used on namespace", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @server("https://example.com", "MyServer") op test(): string; @server("https://example.com", "MyServer") model Foo {} @@ -652,7 +643,7 @@ describe("http: decorators", () => { }); it("emit diagnostics when url is not a string", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @server(123, "MyServer") namespace MyService {} `); @@ -663,7 +654,7 @@ describe("http: decorators", () => { }); it("emit diagnostics when description is not a string", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @server("https://example.com", 123) namespace MyService {} `); @@ -674,7 +665,7 @@ describe("http: decorators", () => { }); it("emit diagnostics when parameters is not a model", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @server("https://example.com", "My service url", 123) namespace MyService {} `); @@ -685,7 +676,7 @@ describe("http: decorators", () => { }); it("emit diagnostics if url has parameters that is not specified in model", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @server("https://example.com/{name}/foo", "My service url", {other: string}) namespace MyService {} `); @@ -697,12 +688,12 @@ describe("http: decorators", () => { }); it("define a simple server without description", async () => { - const { MyService } = (await runner.compile(` + const { MyService, program } = await Tester.compile(t.code` @server("https://example.com") - @test namespace MyService {} - `)) as { MyService: Namespace }; + namespace ${t.namespace("MyService")} {} + `); - const servers = getServers(runner.program, MyService); + const servers = getServers(program, MyService); deepStrictEqual(servers, [ { description: undefined, @@ -713,12 +704,12 @@ describe("http: decorators", () => { }); it("define a simple server with a fixed url", async () => { - const { MyService } = (await runner.compile(` + const { MyService, program } = await Tester.compile(t.code` @server("https://example.com", "My service url") - @test namespace MyService {} - `)) as { MyService: Namespace }; + namespace ${t.namespace("MyService")} {} + `); - const servers = getServers(runner.program, MyService); + const servers = getServers(program, MyService); deepStrictEqual(servers, [ { description: "My service url", @@ -729,16 +720,16 @@ describe("http: decorators", () => { }); it("define a server with parameters", async () => { - const { MyService, NameParam } = (await runner.compile(` + const { MyService, NameParam, program } = await Tester.compile(t.code` @server("https://example.com/{name}/foo", "My service url", {@test("NameParam") name: string }) - @test namespace MyService {} - `)) as { MyService: Namespace; NameParam: ModelProperty }; + namespace ${t.namespace("MyService")} {} + `); - const servers = getServers(runner.program, MyService); + const servers = getServers(program, MyService); deepStrictEqual(servers, [ { description: "My service url", - parameters: new Map([["name", NameParam]]), + parameters: new Map([["name", NameParam as any]]), url: "https://example.com/{name}/foo", }, ]); @@ -747,7 +738,7 @@ describe("http: decorators", () => { describe("@useAuth", () => { it("emit diagnostics when config is not a model, tuple or union", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @useAuth(anOp) namespace Foo {} @@ -760,7 +751,7 @@ describe("http: decorators", () => { }); it("emit diagnostic when OAuth2 flow is not a valid model", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @useAuth(OAuth2Auth<["foo"]>) namespace Foo {} @@ -780,12 +771,12 @@ describe("http: decorators", () => { }); it("can specify BasicAuth", async () => { - const { Foo } = (await runner.compile(` + const { Foo, program } = await Tester.compile(t.code` @useAuth(BasicAuth) - @test namespace Foo {} - `)) as { Foo: Namespace }; + namespace ${t.namespace("Foo")} {} + `); - expect(getAuthentication(runner.program, Foo)).toEqual({ + expect(getAuthentication(program, Foo)).toEqual({ options: [ { schemes: [ @@ -802,14 +793,14 @@ describe("http: decorators", () => { }); it("can specify custom auth name with description", async () => { - const { Foo } = (await runner.compile(` + const { Foo, program } = await Tester.compile(t.code` @doc("My custom basic auth") model MyAuth is BasicAuth; @useAuth(MyAuth) - @test namespace Foo {} - `)) as { Foo: Namespace }; + namespace ${t.namespace("Foo")} {} + `); - expect(getAuthentication(runner.program, Foo)).toEqual({ + expect(getAuthentication(program, Foo)).toEqual({ options: [ { schemes: [ @@ -827,12 +818,12 @@ describe("http: decorators", () => { }); it("can specify BearerAuth", async () => { - const { Foo } = (await runner.compile(` + const { Foo, program } = await Tester.compile(t.code` @useAuth(BearerAuth) - @test namespace Foo {} - `)) as { Foo: Namespace }; + namespace ${t.namespace("Foo")} {} + `); - expect(getAuthentication(runner.program, Foo)).toEqual({ + expect(getAuthentication(program, Foo)).toEqual({ options: [ { schemes: [ @@ -849,12 +840,12 @@ describe("http: decorators", () => { }); it("can specify ApiKeyAuth", async () => { - const { Foo } = (await runner.compile(` + const { Foo, program } = await Tester.compile(t.code` @useAuth(ApiKeyAuth) - @test namespace Foo {} - `)) as { Foo: Namespace }; + namespace ${t.namespace("Foo")} {} + `); - expect(getAuthentication(runner.program, Foo)).toEqual({ + expect(getAuthentication(program, Foo)).toEqual({ options: [ { schemes: [ @@ -872,7 +863,7 @@ describe("http: decorators", () => { }); it("can specify OAuth2", async () => { - const { Foo } = (await runner.compile(` + const { Foo, program } = await Tester.compile(t.code` model MyFlow { type: OAuth2FlowType.implicit; authorizationUrl: "https://api.example.com/oauth2/authorize"; @@ -880,10 +871,10 @@ describe("http: decorators", () => { scopes: ["read", "write"]; } @useAuth(OAuth2Auth<[MyFlow]>) - @test namespace Foo {} - `)) as { Foo: Namespace }; + namespace ${t.namespace("Foo")} {} + `); - expect(getAuthentication(runner.program, Foo)).toEqual({ + expect(getAuthentication(program, Foo)).toEqual({ options: [ { schemes: [ @@ -907,7 +898,7 @@ describe("http: decorators", () => { }); it("can specify OAuth2 with scopes, which are default for every flow", async () => { - const { Foo } = (await runner.compile(` + const { Foo, program } = await Tester.compile(t.code` alias MyAuth = OAuth2Auth { }], Scopes=T>; @useAuth(MyAuth<["read", "write"]>) - @test namespace Foo {} - `)) as { Foo: Namespace }; + namespace ${t.namespace("Foo")} {} + `); - expect(getAuthentication(runner.program, Foo)).toEqual({ + expect(getAuthentication(program, Foo)).toEqual({ options: [ { schemes: [ @@ -942,12 +933,12 @@ describe("http: decorators", () => { }); it("can specify NoAuth", async () => { - const { Foo } = (await runner.compile(` + const { Foo, program } = await Tester.compile(t.code` @useAuth(NoAuth) - @test namespace Foo {} - `)) as { Foo: Namespace }; + namespace ${t.namespace("Foo")} {} + `); - expect(getAuthentication(runner.program, Foo)).toEqual({ + expect(getAuthentication(program, Foo)).toEqual({ options: [ { schemes: [ @@ -963,12 +954,12 @@ describe("http: decorators", () => { }); it("can specify multiple auth options", async () => { - const { Foo } = (await runner.compile(` + const { Foo, program } = await Tester.compile(t.code` @useAuth(BasicAuth | BearerAuth) - @test namespace Foo {} - `)) as { Foo: Namespace }; + namespace ${t.namespace("Foo")} {} + `); - expect(getAuthentication(runner.program, Foo)).toEqual({ + expect(getAuthentication(program, Foo)).toEqual({ options: [ { schemes: [ @@ -995,12 +986,12 @@ describe("http: decorators", () => { }); it("can specify multiple auth schemes to be used together", async () => { - const { Foo } = (await runner.compile(` + const { Foo, program } = await Tester.compile(t.code` @useAuth([BasicAuth, BearerAuth]) - @test namespace Foo {} - `)) as { Foo: Namespace }; + namespace ${t.namespace("Foo")} {} + `); - expect(getAuthentication(runner.program, Foo)).toEqual({ + expect(getAuthentication(program, Foo)).toEqual({ options: [ { schemes: [ @@ -1023,12 +1014,12 @@ describe("http: decorators", () => { }); it("can specify multiple auth schemes to be used together and multiple options", async () => { - const { Foo } = (await runner.compile(` + const { Foo, program } = await Tester.compile(t.code` @useAuth(BearerAuth | [ApiKeyAuth, BasicAuth]) - @test namespace Foo {} - `)) as { Foo: Namespace }; + namespace ${t.namespace("Foo")} {} + `); - expect(getAuthentication(runner.program, Foo)).toEqual({ + expect(getAuthentication(program, Foo)).toEqual({ options: [ { schemes: [ @@ -1062,16 +1053,16 @@ describe("http: decorators", () => { }); it("can override auth schemes on interface", async () => { - const { Foo } = (await runner.compile(` + const { Foo, program } = await Tester.compile(t.code` alias ServiceKeyAuth = ApiKeyAuth; @useAuth(ServiceKeyAuth) - @test namespace Foo { + namespace ${t.namespace("Foo")} { @useAuth(BasicAuth | BearerAuth) interface Bar { } } - `)) as { Foo: Namespace }; + `); - expect(getAuthentication(runner.program, Foo.interfaces.get("Bar")!)).toEqual({ + expect(getAuthentication(program, Foo.interfaces.get("Bar")!)).toEqual({ options: [ { schemes: [ @@ -1098,16 +1089,16 @@ describe("http: decorators", () => { }); it("can override auth schemes on operation", async () => { - const { Foo } = (await runner.compile(` + const { Foo, program } = await Tester.compile(t.code` alias ServiceKeyAuth = ApiKeyAuth; @useAuth(ServiceKeyAuth) - @test namespace Foo { + namespace ${t.namespace("Foo")} { @useAuth([BasicAuth, BearerAuth]) op bar(): void; } - `)) as { Foo: Namespace }; + `); - expect(getAuthentication(runner.program, Foo.operations.get("bar")!)).toEqual({ + expect(getAuthentication(program, Foo.operations.get("bar")!)).toEqual({ options: [ { schemes: [ @@ -1132,67 +1123,52 @@ describe("http: decorators", () => { describe("@includeInapplicableMetadataInPayload", () => { it("defaults to true", async () => { - const { M } = await runner.compile(` + const { M, program } = await Tester.compile(t.code` namespace Foo; - @test model M {p: string; } + model ${t.model("M")} {p: string; } `); strictEqual(M.kind, "Model" as const); - strictEqual( - includeInapplicableMetadataInPayload(runner.program, M.properties.get("p")!), - true, - ); + strictEqual(includeInapplicableMetadataInPayload(program, M.properties.get("p")!), true); }); it("can specify at namespace level", async () => { - const { M } = await runner.compile(` + const { M, program } = await Tester.compile(t.code` @Private.includeInapplicableMetadataInPayload(false) namespace Foo; - @test model M {p: string; } + model ${t.model("M")} {p: string; } `); strictEqual(M.kind, "Model" as const); - strictEqual( - includeInapplicableMetadataInPayload(runner.program, M.properties.get("p")!), - false, - ); + strictEqual(includeInapplicableMetadataInPayload(program, M.properties.get("p")!), false); }); it("can specify at model level", async () => { - const { M } = await runner.compile(` + const { M, program } = await Tester.compile(t.code` namespace Foo; - @Private.includeInapplicableMetadataInPayload(false) @test model M { p: string; } + @Private.includeInapplicableMetadataInPayload(false) model ${t.model("M")} { p: string; } `); strictEqual(M.kind, "Model" as const); - strictEqual( - includeInapplicableMetadataInPayload(runner.program, M.properties.get("p")!), - false, - ); + strictEqual(includeInapplicableMetadataInPayload(program, M.properties.get("p")!), false); }); it("can specify at property level", async () => { - const { M } = await runner.compile(` + const { M, program } = await Tester.compile(t.code` namespace Foo; - @test model M { @Private.includeInapplicableMetadataInPayload(false) p: string; } + model ${t.model("M")} { @Private.includeInapplicableMetadataInPayload(false) p: string; } `); strictEqual(M.kind, "Model" as const); - strictEqual( - includeInapplicableMetadataInPayload(runner.program, M.properties.get("p")!), - false, - ); + strictEqual(includeInapplicableMetadataInPayload(program, M.properties.get("p")!), false); }); it("can be overridden", async () => { - const { M } = await runner.compile(` + const { M, program } = await Tester.compile(t.code` @Private.includeInapplicableMetadataInPayload(false) namespace Foo; - @Private.includeInapplicableMetadataInPayload(true) @test model M { p: string; } + @Private.includeInapplicableMetadataInPayload(true) model ${t.model("M")} { p: string; } `); strictEqual(M.kind, "Model" as const); - strictEqual( - includeInapplicableMetadataInPayload(runner.program, M.properties.get("p")!), - true, - ); + strictEqual(includeInapplicableMetadataInPayload(program, M.properties.get("p")!), true); }); }); }); diff --git a/packages/http/test/merge-patch.test.ts b/packages/http/test/merge-patch.test.ts index 646e182c405..948ca8729c0 100644 --- a/packages/http/test/merge-patch.test.ts +++ b/packages/http/test/merge-patch.test.ts @@ -1,8 +1,8 @@ import { Diagnostic, Model, ModelProperty, Program, Type, TypeKind } from "@typespec/compiler"; import { - BasicTestRunner, expectDiagnosticEmpty, expectDiagnostics, + TesterInstance, } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { deepStrictEqual, ok } from "assert"; @@ -14,15 +14,11 @@ import { } from "../src/experimental/merge-patch/helpers.js"; import { getAllHttpServices } from "../src/operations.js"; import { HttpOperation, RouteResolutionOptions } from "../src/types.js"; -import { - createHttpTestRunner, - diagnoseOperations, - getOperationsWithServiceNamespace, -} from "./test-host.js"; +import { diagnoseOperations, getOperationsWithServiceNamespace, Tester } from "./test-host.js"; -let runner: BasicTestRunner; +let runner: TesterInstance; beforeEach(async () => { - runner = await createHttpTestRunner(); + runner = await Tester.createInstance(); }); function checkNullableUnion(program: Program, union: Type): boolean { @@ -69,16 +65,13 @@ function isNullableUnion(property: ModelProperty) { return property; } async function compileAndDiagnoseWithRunner( - runner: BasicTestRunner, + runner: TesterInstance, code: string, options?: RouteResolutionOptions, ): Promise<[HttpOperation[], readonly Diagnostic[]]> { await runner.compileAndDiagnose( `@service(#{title: "Test Service"}) namespace TestService; ${code}`, - { - noEmit: true, - }, ); const [services] = getAllHttpServices(runner.program, options); return [services[0].operations, runner.program.diagnostics]; diff --git a/packages/http/test/overloads.test.ts b/packages/http/test/overloads.test.ts index 6211e6c078f..19b6681be4a 100644 --- a/packages/http/test/overloads.test.ts +++ b/packages/http/test/overloads.test.ts @@ -1,30 +1,23 @@ -import { Operation } from "@typespec/compiler"; -import { BasicTestRunner, expectDiagnostics } from "@typespec/compiler/testing"; +import { expectDiagnostics, t } from "@typespec/compiler/testing"; import { strictEqual } from "assert"; -import { beforeEach, describe, it } from "vitest"; +import { describe, it } from "vitest"; import { getHttpOperation, listHttpOperationsIn } from "../src/index.js"; -import { createHttpTestRunner } from "./test-host.js"; +import { Tester } from "./test-host.js"; describe("http: overloads", () => { - let runner: BasicTestRunner; - - beforeEach(async () => { - runner = await createHttpTestRunner(); - }); - it("overloads inherit base overload route and verb", async () => { - const { uploadString, uploadBytes } = (await runner.compile(` + const { uploadString, uploadBytes, program } = await Tester.compile(t.code` @route("/upload") @put op upload(data: string | bytes, @header contentType: "text/plain" | "application/octet-stream"): void; @overload(upload) - @test op uploadString(data: string, @header contentType: "text/plain" ): void; + op ${t.op("uploadString")}(data: string, @header contentType: "text/plain" ): void; @overload(upload) - @test op uploadBytes(data: bytes, @header contentType: "application/octet-stream"): void; - `)) as { uploadString: Operation; uploadBytes: Operation }; + op ${t.op("uploadBytes")}(data: bytes, @header contentType: "application/octet-stream"): void; + `); - const [uploadStringHttp] = getHttpOperation(runner.program, uploadString); - const [uploadBytesHttp] = getHttpOperation(runner.program, uploadBytes); + const [uploadStringHttp] = getHttpOperation(program, uploadString); + const [uploadBytesHttp] = getHttpOperation(program, uploadBytes); strictEqual(uploadStringHttp.path, "/upload"); strictEqual(uploadStringHttp.verb, "put"); @@ -33,20 +26,20 @@ describe("http: overloads", () => { }); it("overloads can change their route or verb", async () => { - const { upload, uploadString, uploadBytes } = (await runner.compile(` + const { upload, uploadString, uploadBytes, program } = await Tester.compile(t.code` @route("/upload") @put - @test op upload(data: string | bytes, @header contentType: "text/plain" | "application/octet-stream"): void; + op ${t.op("upload")}(data: string | bytes, @header contentType: "text/plain" | "application/octet-stream"): void; @overload(upload) @route("/uploadString") - @test op uploadString(data: string, @header contentType: "text/plain" ): void; + op ${t.op("uploadString")}(data: string, @header contentType: "text/plain" ): void; @overload(upload) - @post @test op uploadBytes(data: bytes, @header contentType: "application/octet-stream"): void; - `)) as { upload: Operation; uploadString: Operation; uploadBytes: Operation }; + @post op ${t.op("uploadBytes")}(data: bytes, @header contentType: "application/octet-stream"): void; + `); - const [uploadHttp] = getHttpOperation(runner.program, upload); - const [uploadStringHttp] = getHttpOperation(runner.program, uploadString); - const [uploadBytesHttp] = getHttpOperation(runner.program, uploadBytes); + const [uploadHttp] = getHttpOperation(program, upload); + const [uploadStringHttp] = getHttpOperation(program, uploadString); + const [uploadBytesHttp] = getHttpOperation(program, uploadBytes); strictEqual(uploadHttp.path, "/upload"); strictEqual(uploadHttp.verb, "put"); @@ -61,7 +54,7 @@ describe("http: overloads", () => { }); it("links overloads", async () => { - await runner.compile(` + const { program } = await Tester.compile(t.code` @route("/upload") @put op upload(data: string | bytes, @header contentType: "text/plain" | "application/octet-stream"): void; @@ -72,8 +65,8 @@ describe("http: overloads", () => { `); const [[overload, uploadString, uploadBytes]] = listHttpOperationsIn( - runner.program, - runner.program.getGlobalNamespaceType(), + program, + program.getGlobalNamespaceType(), ); strictEqual(uploadString.overloading, overload); @@ -83,7 +76,7 @@ describe("http: overloads", () => { }); it("overload base route should still be unique with other operations", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @route("/upload") op otherUpload(data: bytes): void; @@ -107,7 +100,7 @@ describe("http: overloads", () => { }); it("overloads route should still be unique with other operations", async () => { - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @route("/uploadString") op otherUploadString(data: string): void; diff --git a/packages/http/test/plaindata.test.ts b/packages/http/test/plaindata.test.ts index 18ad55dd53e..a041a91ce68 100644 --- a/packages/http/test/plaindata.test.ts +++ b/packages/http/test/plaindata.test.ts @@ -1,62 +1,37 @@ -import { TestHost } from "@typespec/compiler/testing"; +import { t } from "@typespec/compiler/testing"; import { ok, strictEqual } from "assert"; -import { beforeEach, describe, it } from "vitest"; +import { describe, it } from "vitest"; import { isBody, isHeader, isPathParam, isQueryParam } from "../src/decorators.js"; -import { createHttpTestHost } from "./test-host.js"; +import { Tester } from "./test-host.js"; describe("http: plain data", () => { - let testHost: TestHost; - - beforeEach(async () => { - testHost = await createHttpTestHost(); - }); - it("removes header/query/body/path", async () => { - testHost.addTypeSpecFile( - "main.tsp", - ` - import "@typespec/http"; - using Http; - - @test - model Before { + const { Before, After, Spread, program } = await Tester.compile(t.code` + model ${t.model("Before")} { @header a: string; @query b: string; @path c: string; @body d: string; } - @test - model After is PlainData {} + model ${t.model("After")} is PlainData {} - @test - model Spread { + model ${t.model("Spread")} { ...After } - `, - ); - - const { Before, After, Spread } = await testHost.compile("main.tsp"); - const program = testHost.program; + `); - strictEqual(Before.kind, "Model" as const); ok(isHeader(program, Before.properties.get("a")!), "header expected"); ok(isBody(program, Before.properties.get("d")!), "body expected"); - ok(isQueryParam(testHost.program, Before.properties.get("b")!), "query expected"); - ok(isPathParam(testHost.program, Before.properties.get("c")!), "path expected"); + ok(isQueryParam(program, Before.properties.get("b")!), "query expected"); + ok(isPathParam(program, Before.properties.get("c")!), "path expected"); for (const model of [After, Spread]) { strictEqual(model.kind, "Model" as const); ok(!isHeader(program, model.properties.get("a")!), `header not expected in ${model.name}`); ok(!isBody(program, model.properties.get("d")!), `body not expected in ${model.name}`); - ok( - !isQueryParam(testHost.program, model.properties.get("b")!), - `query not expected in ${model.name}`, - ); - ok( - !isPathParam(testHost.program, model.properties.get("c")!), - `path not expected in ${model.name}`, - ); + ok(!isQueryParam(program, model.properties.get("b")!), `query not expected in ${model.name}`); + ok(!isPathParam(program, model.properties.get("c")!), `path not expected in ${model.name}`); } }); }); diff --git a/packages/http/test/routes.test.ts b/packages/http/test/routes.test.ts index 92ba6312054..93775129fc2 100644 --- a/packages/http/test/routes.test.ts +++ b/packages/http/test/routes.test.ts @@ -1,15 +1,14 @@ -import { Operation } from "@typespec/compiler"; -import { expectDiagnosticEmpty, expectDiagnostics } from "@typespec/compiler/testing"; +import { expectDiagnosticEmpty, expectDiagnostics, t } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; import { PathOptions } from "../generated-defs/TypeSpec.Http.js"; -import { HttpOperation, HttpOperationParameter, getRoutePath } from "../src/index.js"; +import { getRoutePath, HttpOperation, HttpOperationParameter } from "../src/index.js"; import { compileOperations, - createHttpTestRunner, diagnoseOperations, getOperations, getRoutesFor, + Tester, } from "./test-host.js"; describe("http: routes", () => { @@ -462,26 +461,23 @@ describe("http: routes", () => { describe("shared routes", () => { it("@sharedRoute decorator makes routes shared", async () => { - const runner = await createHttpTestRunner(); - const { get1, get2 } = (await runner.compile(` + const { program, get1, get2 } = await Tester.compile(t.code` @route("/test") namespace Foo { - @test @sharedRoute @route("/get1") - op get1(): string; + op ${t.op("get1")}(): string; } @route("/test") namespace Foo { - @test @route("/get2") - op get2(): string; + op ${t.op("get2")}(): string; } - `)) as { get1: Operation; get2: Operation }; + `); - strictEqual(getRoutePath(runner.program, get1)?.shared, true); - strictEqual(getRoutePath(runner.program, get2)?.shared, false); + strictEqual(getRoutePath(program, get1)?.shared, true); + strictEqual(getRoutePath(program, get2)?.shared, false); }); }); }); diff --git a/packages/http/test/rules/op-reference-container-route.test.ts b/packages/http/test/rules/op-reference-container-route.test.ts index 3cfc198b555..1c7b4aee3e9 100644 --- a/packages/http/test/rules/op-reference-container-route.test.ts +++ b/packages/http/test/rules/op-reference-container-route.test.ts @@ -1,12 +1,12 @@ -import { LinterRuleTester, createLinterRuleTester } from "@typespec/compiler/testing"; +import { createLinterRuleTester, LinterRuleTester } from "@typespec/compiler/testing"; import { beforeEach, describe, it } from "vitest"; import { opReferenceContainerRouteRule } from "../../src/rules/op-reference-container-route.js"; -import { createHttpTestRunner } from "../test-host.js"; +import { Tester } from "../test-host.js"; describe("operation reference route container rule", () => { let ruleTester: LinterRuleTester; beforeEach(async () => { - const runner = await createHttpTestRunner(); + const runner = await Tester.createInstance(); ruleTester = createLinterRuleTester(runner, opReferenceContainerRouteRule, "@typespec/http"); }); diff --git a/packages/http/test/test-host.ts b/packages/http/test/test-host.ts index ba92f6870cf..203cd332a87 100644 --- a/packages/http/test/test-host.ts +++ b/packages/http/test/test-host.ts @@ -1,29 +1,18 @@ -import { createDiagnosticCollector, Diagnostic } from "@typespec/compiler"; -import { - BasicTestRunner, - createTestHost, - createTestWrapper, - expectDiagnosticEmpty, - TestHost, -} from "@typespec/compiler/testing"; +import { createDiagnosticCollector, Diagnostic, Program, resolvePath } from "@typespec/compiler"; +import { createTester, expectDiagnosticEmpty } from "@typespec/compiler/testing"; import { getAllHttpServices, HttpOperation, HttpOperationParameter, HttpVerb, } from "../src/index.js"; -import { HttpTestLibrary } from "../src/testing/index.js"; import { RouteResolutionOptions } from "../src/types.js"; -export async function createHttpTestHost(): Promise { - return createTestHost({ - libraries: [HttpTestLibrary], - }); -} -export async function createHttpTestRunner(): Promise { - const host = await createHttpTestHost(); - return createTestWrapper(host, { autoUsings: ["TypeSpec.Http"] }); -} +export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/http"], +}) + .importLibraries() + .using("Http"); export interface RouteDetails { path: string; @@ -82,7 +71,7 @@ export async function compileOperations( } export interface CompileOperationsResult { - runner: BasicTestRunner; + program: Program; operations: HttpOperation[]; diagnostics: readonly Diagnostic[]; } @@ -91,19 +80,15 @@ export async function compileOperationsFull( code: string, routeOptions?: RouteResolutionOptions, ): Promise { - const runner = await createHttpTestRunner(); const diagnostics = createDiagnosticCollector(); - diagnostics.pipe( - await runner.compileAndDiagnose( + const { program } = diagnostics.pipe( + await Tester.compileAndDiagnose( `@service(#{title: "Test Service"}) namespace TestService; ${code}`, - { - noEmit: true, - }, ), ); - const services = diagnostics.pipe(getAllHttpServices(runner.program, routeOptions)); - return { runner, operations: services[0].operations, diagnostics: diagnostics.diagnostics }; + const services = diagnostics.pipe(getAllHttpServices(program, routeOptions)); + return { operations: services[0].operations, diagnostics: diagnostics.diagnostics, program }; } export async function diagnoseOperations( @@ -118,22 +103,17 @@ export async function getOperationsWithServiceNamespace( code: string, routeOptions?: RouteResolutionOptions, ): Promise<[HttpOperation[], readonly Diagnostic[]]> { - const runner = await createHttpTestRunner(); - await runner.compileAndDiagnose( + const [{ program }, _] = await Tester.compileAndDiagnose( `@service(#{title: "Test Service"}) namespace TestService; ${code}`, - { - noEmit: true, - }, ); - const [services] = getAllHttpServices(runner.program, routeOptions); - return [services[0].operations, runner.program.diagnostics]; + const [services] = getAllHttpServices(program, routeOptions); + return [services[0].operations, program.diagnostics]; } export async function getOperations(code: string): Promise { - const runner = await createHttpTestRunner(); - await runner.compile(code); - const [services, diagnostics] = getAllHttpServices(runner.program); + const { program } = await Tester.compile(code); + const [services, diagnostics] = getAllHttpServices(program); expectDiagnosticEmpty(diagnostics); return services[0].operations; diff --git a/packages/http/test/typekit/http-opperation.test.ts b/packages/http/test/typekit/http-opperation.test.ts index eb0f462756b..26215429264 100644 --- a/packages/http/test/typekit/http-opperation.test.ts +++ b/packages/http/test/typekit/http-opperation.test.ts @@ -1,20 +1,13 @@ -import { Model, Operation } from "@typespec/compiler"; -import { BasicTestRunner } from "@typespec/compiler/testing"; +import { t } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import "../../src/experimental/typekit/index.js"; -import { createHttpTestRunner } from "./../test-host.js"; - -let runner: BasicTestRunner; - -beforeEach(async () => { - runner = await createHttpTestRunner(); -}); +import { Tester } from "./../test-host.js"; describe("httpOperation:getResponses", () => { it("should get responses", async () => { - const { getFoo } = (await runner.compile(` - @test model Foo { + const { getFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { @visibility(Lifecycle.Create) id: int32; age: int32; @@ -22,16 +15,16 @@ describe("httpOperation:getResponses", () => { } @error - @test model Error { + model ${t.model("Error")} { message: string; code: int32 } @route("/foo") @get - @test op getFoo(): Foo | Error; - `)) as { getFoo: Operation; Foo: Model; Error: Model }; - const tk = $(runner.program); + op ${t.op("getFoo")}(): Foo | Error; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(getFoo); const responses = tk.httpOperation.flattenResponses(httpOperation); @@ -43,8 +36,8 @@ describe("httpOperation:getResponses", () => { }); it("should get responses with multiple status codes", async () => { - const { getFoo } = (await runner.compile(` - @test model Foo { + const { getFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { @visibility(Lifecycle.Create) id: int32; age: int32; @@ -53,9 +46,9 @@ describe("httpOperation:getResponses", () => { @route("/foo") @get - @test op getFoo(): Foo | void; - `)) as { getFoo: Operation; Foo: Model; Error: Model }; - const tk = $(runner.program); + op ${t.op("getFoo")}(): Foo | void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(getFoo); const responses = tk.httpOperation.flattenResponses(httpOperation); @@ -67,8 +60,8 @@ describe("httpOperation:getResponses", () => { }); it("should get responses with multiple status codes and contentTypes", async () => { - const { getFoo } = (await runner.compile(` - @test model Foo { + const { getFoo, program } = await Tester.compile(t.code` + model ${t.model("Foo")} { @visibility(Lifecycle.Create) id: int32; age: int32; @@ -76,16 +69,16 @@ describe("httpOperation:getResponses", () => { } @error - @test model Error { + model ${t.model("Error")} { message: string; code: int32 } @route("/foo") @get - @test op getFoo(): Foo | {...Foo, @header contentType: "text/plain"} | Error; - `)) as { getFoo: Operation; Foo: Model; Error: Model }; - const tk = $(runner.program); + op ${t.op("getFoo")}(): Foo | {...Foo, @header contentType: "text/plain"} | Error; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(getFoo); const responses = tk.httpOperation.flattenResponses(httpOperation); diff --git a/packages/http/test/typekit/http-request.test.ts b/packages/http/test/typekit/http-request.test.ts index 8ab36677fea..1c24f2e1dfb 100644 --- a/packages/http/test/typekit/http-request.test.ts +++ b/packages/http/test/typekit/http-request.test.ts @@ -1,25 +1,18 @@ -import { Model, Operation } from "@typespec/compiler"; -import { BasicTestRunner } from "@typespec/compiler/testing"; +import { Model } from "@typespec/compiler"; +import { t } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import "../../src/experimental/typekit/index.js"; -import { createHttpTestRunner } from "./../test-host.js"; - -let runner: BasicTestRunner; - -beforeEach(async () => { - runner = await createHttpTestRunner(); -}); +import { Tester } from "./../test-host.js"; describe("HttpRequest Body Parameters", () => { it("should handle model is array response", async () => { - const { get } = (await runner.compile(` - model EmbeddingVector is Array; - - @test op get(): EmbeddingVector; - `)) as { get: Operation; Foo: Model }; - const tk = $(runner.program); + const { program, get } = await Tester.compile(t.code` + model EmbeddingVector is Array; + @test op ${t.op("get")}(): EmbeddingVector; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(get); const body = tk.httpOperation.getReturnType(httpOperation)!; expect(body).toBeDefined(); @@ -29,7 +22,7 @@ describe("HttpRequest Body Parameters", () => { }); it("should get the body parameters model when spread", async () => { - const { createFoo } = (await runner.compile(` + const { program, createFoo } = await Tester.compile(t.code` @test model Foo { id: int32; age: int32; @@ -38,10 +31,9 @@ describe("HttpRequest Body Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); - + @test op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)!; expect(body).toBeDefined(); @@ -51,13 +43,12 @@ describe("HttpRequest Body Parameters", () => { }); it("should get the body model params when body is defined explicitly as a property", async () => { - const { createFoo } = (await runner.compile(` + const { program, createFoo } = await Tester.compile(t.code` @route("/foo") @post - @test op createFoo(@body foo: int32): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); - + op ${t.op("createFoo")}(@body foo: int32): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)!; expect(body).toBeDefined(); @@ -68,7 +59,7 @@ describe("HttpRequest Body Parameters", () => { }); it("should get the body when spread and nested", async () => { - const { createFoo } = (await runner.compile(` + const { program, createFoo } = await Tester.compile(t.code` @test model Foo { @path id: int32; age: int32; @@ -81,10 +72,9 @@ describe("HttpRequest Body Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); - + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)!; expect(body).toBeDefined(); @@ -104,7 +94,7 @@ describe("HttpRequest Body Parameters", () => { }); it("should get the body when named body model", async () => { - const { createFoo } = (await runner.compile(` + const { program, createFoo } = await Tester.compile(t.code` @test model Foo { id: int32; age: int32; @@ -113,10 +103,9 @@ describe("HttpRequest Body Parameters", () => { @route("/foo") @post - @test op createFoo(@body foo: Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); - + op ${t.op("createFoo")}(@body foo: Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)!; expect(body).toBeDefined(); @@ -127,7 +116,7 @@ describe("HttpRequest Body Parameters", () => { }); it("should get the named body body when combined", async () => { - const { createFoo } = (await runner.compile(` + const { program, createFoo } = await Tester.compile(t.code` @test model Foo { @path id: int32; age: int32; @@ -136,10 +125,9 @@ describe("HttpRequest Body Parameters", () => { @route("/foo") @post - @test op createFoo(foo: Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); - + op ${t.op("createFoo")}(foo: Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)!; expect(body).toBeDefined(); @@ -153,7 +141,7 @@ describe("HttpRequest Body Parameters", () => { describe("HttpRequest Get Parameters", () => { it("should only have body parameters", async () => { - const { createFoo } = (await runner.compile(` + const { program, createFoo } = await Tester.compile(t.code` @test model Foo { id: int32; age: int32; @@ -162,10 +150,9 @@ describe("HttpRequest Get Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); - + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)!; const headers = tk.httpRequest.getParameters(httpOperation, "header"); @@ -178,7 +165,7 @@ describe("HttpRequest Get Parameters", () => { }); it("should be able to get parameter options", async () => { - const { createFoo } = (await runner.compile(` + const { program, createFoo } = await Tester.compile(t.code` @test model Foo { @path(#{allowReserved: true}) id: string; @header(#{explode: true}) requestId: string[]; @@ -187,10 +174,9 @@ describe("HttpRequest Get Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); - + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const headers = tk.httpRequest.getParameters(httpOperation, "header"); const path = tk.httpRequest.getParameters(httpOperation, "path"); @@ -222,7 +208,7 @@ describe("HttpRequest Get Parameters", () => { }); it("should only have header parameters", async () => { - const { createFoo } = (await runner.compile(` + const { program, createFoo } = await Tester.compile(t.code` @test model Foo { @path id: int32; age: int32; @@ -231,10 +217,9 @@ describe("HttpRequest Get Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); - + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)! as Model; const headers = tk.httpRequest.getParameters(httpOperation, "header"); @@ -250,7 +235,7 @@ describe("HttpRequest Get Parameters", () => { }); it("should only have path parameters", async () => { - const { createFoo } = (await runner.compile(` + const { program, createFoo } = await Tester.compile(t.code` @test model Foo { @header id: int32; @header age: int32; @@ -259,10 +244,9 @@ describe("HttpRequest Get Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); - + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)! as Model; const headers = tk.httpRequest.getParameters(httpOperation, "header")!; @@ -279,7 +263,7 @@ describe("HttpRequest Get Parameters", () => { }); it("should only have query parameters", async () => { - const { createFoo } = (await runner.compile(` + const { program, createFoo } = await Tester.compile(t.code` @test model Foo { @query id: int32; @query age: int32; @@ -288,10 +272,9 @@ describe("HttpRequest Get Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); - + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const body = tk.httpRequest.getBodyParameters(httpOperation)! as Model; const headers = tk.httpRequest.getParameters(httpOperation, "header"); @@ -308,7 +291,7 @@ describe("HttpRequest Get Parameters", () => { }); it("should have query and header parameters", async () => { - const { createFoo } = (await runner.compile(` + const { program, createFoo } = await Tester.compile(t.code` @test model Foo { @query id: int32; @header age: int32; @@ -317,10 +300,9 @@ describe("HttpRequest Get Parameters", () => { @route("/foo") @post - @test op createFoo(...Foo): void; - `)) as { createFoo: Operation; Foo: Model }; - const tk = $(runner.program); - + op ${t.op("createFoo")}(...Foo): void; + `); + const tk = $(program); const httpOperation = tk.httpOperation.get(createFoo); const headerAndQuery = tk.httpRequest.getParameters(httpOperation, ["header", "query"]); expect(headerAndQuery).toBeDefined(); From d9bdc804f4d48b946159dff1c35a789a3ee68f3b Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 14 May 2025 10:40:06 -0700 Subject: [PATCH 41/55] migrate versioning --- .../test/incompatible-versioning.test.ts | 79 ++++++++----------- .../apply-snapshot-versioning.test.ts | 11 +-- packages/versioning/test/test-host.ts | 23 ++---- .../test/versioning-timeline.test.ts | 15 ++-- 4 files changed, 51 insertions(+), 77 deletions(-) diff --git a/packages/versioning/test/incompatible-versioning.test.ts b/packages/versioning/test/incompatible-versioning.test.ts index a36eb881bec..383d1ce4bbb 100644 --- a/packages/versioning/test/incompatible-versioning.test.ts +++ b/packages/versioning/test/incompatible-versioning.test.ts @@ -1,30 +1,19 @@ import { - createTestWrapper, expectDiagnosticEmpty, expectDiagnostics, - type BasicTestRunner, - type TestHost, + type TesterInstance, } from "@typespec/compiler/testing"; import { ok } from "assert"; import { beforeEach, describe, it } from "vitest"; -import { createVersioningTestHost, createVersioningTestRunner } from "./test-host.js"; +import { Tester } from "./test-host.js"; describe("versioning: incompatible use of decorators", () => { - let runner: BasicTestRunner; - let host: TestHost; + let runner: TesterInstance; const imports: string[] = []; beforeEach(async () => { - host = await createVersioningTestHost(); - runner = createTestWrapper(host, { - wrapper: (code) => ` - import "@typespec/versioning"; - ${imports.map((i) => `import "${i}";`).join("\n")} - using Versioning; - ${code}`, - }); + runner = await Tester.import(...imports).createInstance(); }); - it("emit diagnostic when version enum has duplicate values", async () => { const diagnostics = await runner.diagnose(` @versioned(Versions) @@ -64,24 +53,17 @@ describe("versioning: incompatible use of decorators", () => { }); describe("versioning: validate incompatible references", () => { - let runner: BasicTestRunner; - let host: TestHost; - const imports: string[] = []; + let runner: TesterInstance; beforeEach(async () => { - host = await createVersioningTestHost(); - runner = createTestWrapper(host, { - wrapper: (code) => ` - import "@typespec/versioning"; - ${imports.map((i) => `import "${i}";`).join("\n")} - using Versioning; - + runner = await Tester.wrap( + (code) => ` @versioned(Versions) namespace TestService { enum Versions {v1, v2, v3, v4} ${code} }`, - }); + ).createInstance(); }); describe("operation", () => { @@ -850,18 +832,27 @@ describe("versioning: validate incompatible references", () => { }); describe("interface templates", () => { - beforeEach(() => { - imports.push("./lib.tsp"); - host.addTypeSpecFile( - "lib.tsp", - ` - namespace Lib; - interface Ops { - get(): T[]; - } + beforeEach(async () => { + runner = await Tester.import("./lib.tsp") + .files({ + "lib.tsp": ` + namespace Lib; + interface Ops { + get(): T[]; + } `, - ); + }) + .wrap( + (code) => ` + @versioned(Versions) + namespace TestService { + enum Versions {v1, v2, v3, v4} + ${code} + }`, + ) + .createInstance(); }); + it("emit diagnostic when extending interface with versioned type argument from unversioned interface", async () => { const diagnostics = await runner.diagnose( ` @@ -915,15 +906,9 @@ describe("versioning: validate incompatible references", () => { }); describe("with @useDependency", () => { - let runner: BasicTestRunner; - - beforeEach(async () => { - runner = await createVersioningTestRunner(); - }); - it("emit diagnostic when referencing incompatible version addition via version dependency", async () => { // Here Foo was added in v2 which makes it only available in 1 & 2. - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @versioned(Versions) namespace VersionedLib { enum Versions {l1, l2} @@ -957,7 +942,7 @@ describe("versioning: validate incompatible references", () => { it("emit diagnostic when referencing incompatible version removal via version dependency", async () => { // Here Foo was added in v2 which makes it only available in 1 & 2. - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @versioned(Versions) namespace VersionedLib { enum Versions {l1, l2, l3} @@ -991,7 +976,7 @@ describe("versioning: validate incompatible references", () => { it("doesn't emit diagnostic if all version use the same one", async () => { // Here Foo was added in v2 which makes it only available in 1 & 2. - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @versioned(Versions) namespace VersionedLib { enum Versions {l1, l2} @@ -1019,7 +1004,7 @@ describe("versioning: validate incompatible references", () => { it("emit diagnostic when using item that was added in a later version of library", async () => { // Here Foo was added in v2 but version 1 was selected. - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @versioned(Versions) namespace VersionedLib { enum Versions {l1, l2} @@ -1041,7 +1026,7 @@ describe("versioning: validate incompatible references", () => { it("emit diagnostic when using item that was removed in an earlier version of library", async () => { // Here Foo was removed in v2 but version 2 was selected. - const diagnostics = await runner.diagnose(` + const diagnostics = await Tester.diagnose(` @versioned(Versions) namespace VersionedLib { enum Versions {l1, l2} diff --git a/packages/versioning/test/mutations/apply-snapshot-versioning.test.ts b/packages/versioning/test/mutations/apply-snapshot-versioning.test.ts index 20c0a3c2c34..421f43ebc65 100644 --- a/packages/versioning/test/mutations/apply-snapshot-versioning.test.ts +++ b/packages/versioning/test/mutations/apply-snapshot-versioning.test.ts @@ -3,7 +3,7 @@ import { unsafe_mutateSubgraphWithNamespace } from "@typespec/compiler/experimen import { strictEqual } from "assert"; import { describe, expect, it } from "vitest"; import { getVersioningMutators } from "../../src/mutator.js"; -import { createVersioningTestRunner } from "../test-host.js"; +import { Tester } from "../test-host.js"; const baseCode = ` @versioned(Versions) @@ -15,13 +15,14 @@ const baseCode = ` async function testMutationLogic( code: string, ): Promise<{ v1: Namespace; v2: Namespace; v3: Namespace }> { - const runner = await createVersioningTestRunner(); + const runner = await Tester.createInstance(); const fullCode = baseCode + "\n" + code; - const { Service } = (await runner.compile(fullCode)) as { Service: Namespace }; - const mutators = getVersioningMutators(runner.program, Service); + const { Service } = await runner.compile(fullCode); + const mutators = getVersioningMutators(runner.program, Service as Namespace); strictEqual(mutators?.kind, "versioned"); const [v1, v2, v3] = mutators.snapshots.map( - (x) => unsafe_mutateSubgraphWithNamespace(runner.program, [x.mutator], Service).type, + (x) => + unsafe_mutateSubgraphWithNamespace(runner.program, [x.mutator], Service as Namespace).type, ); return { v1, v2, v3 } as any; } diff --git a/packages/versioning/test/test-host.ts b/packages/versioning/test/test-host.ts index 098e1b76af4..2740ddb0fd7 100644 --- a/packages/versioning/test/test-host.ts +++ b/packages/versioning/test/test-host.ts @@ -1,17 +1,8 @@ -import { - createTestHost, - createTestWrapper, - type BasicTestRunner, - type TestHost, -} from "@typespec/compiler/testing"; -import { VersioningTestLibrary } from "../src/testing/index.js"; +import { resolvePath } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; -export async function createVersioningTestHost(): Promise { - return createTestHost({ - libraries: [VersioningTestLibrary], - }); -} -export async function createVersioningTestRunner(): Promise { - const host = await createVersioningTestHost(); - return createTestWrapper(host, { autoUsings: ["TypeSpec.Versioning"] }); -} +export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/versioning"], +}) + .importLibraries() + .using("Versioning"); diff --git a/packages/versioning/test/versioning-timeline.test.ts b/packages/versioning/test/versioning-timeline.test.ts index 9c6194b64b9..93906958791 100644 --- a/packages/versioning/test/versioning-timeline.test.ts +++ b/packages/versioning/test/versioning-timeline.test.ts @@ -3,7 +3,7 @@ import { deepStrictEqual } from "assert"; import { describe, it } from "vitest"; import { VersioningTimeline } from "../src/versioning-timeline.js"; import { resolveVersions } from "../src/versioning.js"; -import { createVersioningTestRunner } from "./test-host.js"; +import { Tester } from "./test-host.js"; describe("versioning: VersioningTimeline", () => { function generateLibraryNamespace(name: string, versions: string[]) { @@ -22,7 +22,6 @@ describe("versioning: VersioningTimeline", () => { } const libNamespaceNames = libraryVersions.map((_, i) => `TestLibNs_${i}`); - const runner = await createVersioningTestRunner(); const content = [ `@versioned(Versions) namespace TestServiceNs { enum Versions { @@ -33,17 +32,15 @@ describe("versioning: VersioningTimeline", () => { }`, ...libraryVersions.map((x, i) => generateLibraryNamespace(libNamespaceNames[i], x)), ].join("\n"); - await runner.compile(content); + const { program } = await Tester.compile(content); - const serviceNamespace = runner.program - .getGlobalNamespaceType() - .namespaces.get("TestServiceNs")!; + const serviceNamespace = program.getGlobalNamespaceType().namespaces.get("TestServiceNs")!; const libNamespaces: Namespace[] = libNamespaceNames.map( - (x) => runner.program.getGlobalNamespaceType().namespaces.get(x)!, + (x) => program.getGlobalNamespaceType().namespaces.get(x)!, ); - const resolutions = resolveVersions(runner.program, serviceNamespace); + const resolutions = resolveVersions(program, serviceNamespace); const timeline = new VersioningTimeline( - runner.program, + program, resolutions.map((x) => x.versions), ); const timelineMatrix: string[][] = []; From 8f941cb1ae3f5de02b41247fa669db6c53858281 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 14 May 2025 12:32:25 -0700 Subject: [PATCH 42/55] Handle change to output dir --- packages/compiler/src/testing/test-host-v2.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/test-host-v2.ts index 830fea4907b..843f8c6ea8c 100644 --- a/packages/compiler/src/testing/test-host-v2.ts +++ b/packages/compiler/src/testing/test-host-v2.ts @@ -247,7 +247,9 @@ async function createEmitterTesterInstance( }; const [result, diagnostics] = await tester.compileAndDiagnose(code, resolvedOptions); const outputs: Record = {}; - const outputDir = resolveVirtualPath(resolvePath("tsp-output", params.emitter)); + const outputDir = + resolvedOptions.options?.options?.[params.emitter]?.["emitter-output-dir"] ?? + resolveVirtualPath(resolvePath("tsp-output", params.emitter)); for (const [name, value] of result.fs.fs) { if (name.startsWith(outputDir)) { const relativePath = name.slice(outputDir.length + 1); From 4d7ecd303ac3a717404f6221f62a221ff51463e6 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 15 May 2025 10:51:32 -0700 Subject: [PATCH 43/55] Create tester-v2-2025-4-14-20-23-17.md --- .chronus/changes/tester-v2-2025-4-14-20-23-17.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .chronus/changes/tester-v2-2025-4-14-20-23-17.md diff --git a/.chronus/changes/tester-v2-2025-4-14-20-23-17.md b/.chronus/changes/tester-v2-2025-4-14-20-23-17.md new file mode 100644 index 00000000000..00481206246 --- /dev/null +++ b/.chronus/changes/tester-v2-2025-4-14-20-23-17.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/compiler" +--- + +[API] Addition of a new testing framework. See https://typespec.io/docs/extending-typespec/testing From 36814a8d20aece9ee3e83051cd3937cc9867c4b2 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 15 May 2025 14:01:40 -0700 Subject: [PATCH 44/55] Create tester-v2-2025-4-15-17-52-11.md --- .chronus/changes/tester-v2-2025-4-15-17-52-11.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .chronus/changes/tester-v2-2025-4-15-17-52-11.md diff --git a/.chronus/changes/tester-v2-2025-4-15-17-52-11.md b/.chronus/changes/tester-v2-2025-4-15-17-52-11.md new file mode 100644 index 00000000000..c155ee4c604 --- /dev/null +++ b/.chronus/changes/tester-v2-2025-4-15-17-52-11.md @@ -0,0 +1,15 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: internal +packages: + - "@typespec/http" + - "@typespec/openapi" + - "@typespec/openapi3" + - "@typespec/rest" + - "@typespec/sse" + - "@typespec/streams" + - "@typespec/versioning" + - "@typespec/xml" +--- + +Migrated to new tester From 9acf7dbf9b7f21ad01bf93f9d0aea13b3453cb83 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 23 May 2025 12:01:55 -0700 Subject: [PATCH 45/55] Rename --- packages/compiler/src/testing/{test-host-v2.ts => tester.ts} | 0 .../test/testing/{test-host-v2.test.ts => tester.test.ts} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/compiler/src/testing/{test-host-v2.ts => tester.ts} (100%) rename packages/compiler/test/testing/{test-host-v2.test.ts => tester.test.ts} (99%) diff --git a/packages/compiler/src/testing/test-host-v2.ts b/packages/compiler/src/testing/tester.ts similarity index 100% rename from packages/compiler/src/testing/test-host-v2.ts rename to packages/compiler/src/testing/tester.ts diff --git a/packages/compiler/test/testing/test-host-v2.test.ts b/packages/compiler/test/testing/tester.test.ts similarity index 99% rename from packages/compiler/test/testing/test-host-v2.test.ts rename to packages/compiler/test/testing/tester.test.ts index 274e96169ed..29da347128c 100644 --- a/packages/compiler/test/testing/test-host-v2.test.ts +++ b/packages/compiler/test/testing/tester.test.ts @@ -14,7 +14,7 @@ import { } from "../../src/index.js"; import { mockFile } from "../../src/testing/fs.js"; import { t } from "../../src/testing/marked-template.js"; -import { createTester } from "../../src/testing/test-host-v2.js"; +import { createTester } from "../../src/testing/tester.js"; const Tester = createTester(resolvePath(import.meta.dirname, "../.."), { libraries: [] }); From 92c65ab343ec4751aec2267471ce684e5b7ec33d Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 23 May 2025 12:23:39 -0700 Subject: [PATCH 46/55] parity and add files --- packages/compiler/src/testing/tester.ts | 66 +++++++++++-------- packages/compiler/src/testing/types.ts | 62 ++++++++++------- packages/compiler/test/testing/tester.test.ts | 11 ++++ 3 files changed, 89 insertions(+), 50 deletions(-) diff --git a/packages/compiler/src/testing/tester.ts b/packages/compiler/src/testing/tester.ts index 843f8c6ea8c..f5128a98a2f 100644 --- a/packages/compiler/src/testing/tester.ts +++ b/packages/compiler/src/testing/tester.ts @@ -25,6 +25,7 @@ import type { TestEmitterCompileResult, TestFileSystem, Tester, + TesterBuilder, TesterInstance, } from "./types.js"; @@ -130,32 +131,19 @@ interface EmitterTesterInternalParams extends TesterInternalParams { emitter: string; } -function createEmitterTesterInternal(params: EmitterTesterInternalParams): EmitterTester { - return { - ...createCompilable(async (...args) => { - const instance = await createEmitterTesterInstance(params); - return instance.compileAndDiagnose(...args); - }), - createInstance: () => createEmitterTesterInstance(params), - }; -} - -function createTesterInternal(params: TesterInternalParams): Tester { +function createTesterBuilder>( + params: I, + create: (values: I) => O, +): TesterBuilder { return { - ...createCompilable(async (...args) => { - const instance = await createTesterInstance(params); - return instance.compileAndDiagnose(...args); - }), files, wrap, importLibraries, import: importFn, using, - emit, - createInstance, }; - function files(files: Record): Tester { + function files(files: Record): O { const fs = async () => { const fs = (await params.fs()).clone(); for (const [name, value] of Object.entries(files)) { @@ -164,38 +152,51 @@ function createTesterInternal(params: TesterInternalParams): Tester { fs.freeze(); return fs; }; - return createTesterInternal({ + return create({ ...params, fs, }); } - function wrap(fn: (x: string) => string): Tester { - return createTesterInternal({ + function wrap(fn: (x: string) => string): O { + return create({ ...params, wraps: [...(params.wraps ?? []), fn], }); } - function importLibraries(): Tester { - return createTesterInternal({ + function importLibraries(): O { + return create({ ...params, imports: [...(params.imports ?? []), ...params.libraries], }); } - function importFn(...imports: string[]): Tester { - return createTesterInternal({ + function importFn(...imports: string[]): O { + return create({ ...params, imports: [...(params.imports ?? []), ...imports], }); } - function using(...usings: string[]): Tester { - return createTesterInternal({ + function using(...usings: string[]): O { + return create({ ...params, usings: [...(params.usings ?? []), ...usings], }); } +} + +function createTesterInternal(params: TesterInternalParams): Tester { + return { + ...createCompilable(async (...args) => { + const instance = await createTesterInstance(params); + return instance.compileAndDiagnose(...args); + }), + ...createTesterBuilder(params, createTesterInternal), + emit, + createInstance, + }; + function emit(emitter: string, options?: Record): EmitterTester { return createEmitterTesterInternal({ ...params, @@ -217,6 +218,17 @@ function createTesterInternal(params: TesterInternalParams): Tester { } } +function createEmitterTesterInternal(params: EmitterTesterInternalParams): EmitterTester { + return { + ...createCompilable(async (...args) => { + const instance = await createEmitterTesterInstance(params); + return instance.compileAndDiagnose(...args); + }), + ...createTesterBuilder(params, createEmitterTesterInternal), + createInstance: () => createEmitterTesterInstance(params), + }; +} + async function createEmitterTesterInstance( params: EmitterTesterInternalParams, ): Promise { diff --git a/packages/compiler/src/testing/types.ts b/packages/compiler/src/testing/types.ts index 838a02d5b96..19a86e4f62b 100644 --- a/packages/compiler/src/testing/types.ts +++ b/packages/compiler/src/testing/types.ts @@ -4,6 +4,8 @@ import type { CompilerHost, Diagnostic, Entity, Type } from "../core/types.js"; import { GetMarkedEntities, TemplateWithMarkers } from "./marked-template.js"; // #region Test file system + +/** Represent a mock file. Use `mockFile` function to construct */ export type MockFile = string | JsFile; export interface JsFile { @@ -16,7 +18,14 @@ export interface TestFileSystem { readonly fs: Map; readonly compilerHost: CompilerHost; - /** Add a mock test file */ + /** + * Add a mock test file + * @example + * ```ts + * fs.add("foo.tsp", "model Foo {}"); + * fs.add("foo.js", mockFile.js({ Foo: { bar: 1 } })); + * ``` + */ add(path: string, content: MockFile): void; /** Prefer using {@link add} */ @@ -38,7 +47,6 @@ export interface TestFileSystem { //#endregion // #region Tester - export type TestCompileResult> = T & { /** The program created in this test compilation. */ readonly program: Program; @@ -47,14 +55,6 @@ export type TestCompileResult> = T & { readonly fs: TestFileSystem; } & Record; -export interface TestEmitterCompileResult { - /** The program created in this test compilation. */ - readonly program: Program; - - /** Files written to the emitter output dir. */ - readonly outputs: Record; -} - export interface TestCompileOptions { /** Optional compiler options */ readonly options?: CompilerOptions; @@ -76,18 +76,21 @@ interface Testable { ): Promise<[TestCompileResult>, readonly Diagnostic[]]>; } -// Immutable structure meant to be reused -export interface Tester extends Testable { +export interface TesterBuilder { /** Extend with the given list of files */ - files(files: Record): Tester; + files(files: Record): T; /** Auto import all libraries defined in this tester. */ - importLibraries(): Tester; + importLibraries(): T; /** Import the given paths */ - import(...imports: string[]): Tester; + import(...imports: string[]): T; /** Add using statement for the given namespaces. */ - using(...names: string[]): Tester; + using(...names: string[]): T; /** Wrap the code of the `main.tsp` file */ - wrap(fn: (x: string) => string): Tester; + wrap(fn: (x: string) => string): T; +} + +// Immutable structure meant to be reused +export interface Tester extends Testable, TesterBuilder { /** * Create an emitter tester * @param options - Options to pass to the emitter @@ -97,7 +100,15 @@ export interface Tester extends Testable { createInstance(): Promise; } -export interface OutputTester { +export interface TestEmitterCompileResult { + /** The program created in this test compilation. */ + readonly program: Program; + + /** Files written to the emitter output dir. */ + readonly outputs: Record; +} + +export interface OutputTestable { compile( code: string | Record, options?: TestCompileOptions, @@ -111,20 +122,25 @@ export interface OutputTester { options?: TestCompileOptions, ): Promise; } +export interface OutputTester extends OutputTestable, TesterBuilder {} /** Alternate version of the tester which runs the configured emitter */ export interface EmitterTester extends OutputTester { createInstance(): Promise; } -export interface EmitterTesterInstance extends OutputTester { +export interface TesterInstanceBase { + /** Program created. Only available after calling `compile`, `diagnose` or `compileAndDiagnose` */ get program(): Program; - readonly fs: TestFileSystem; -} -export interface TesterInstance extends Testable { - get program(): Program; + /** File system used */ readonly fs: TestFileSystem; } +/** Instance of a tester. */ +export interface TesterInstance extends TesterInstanceBase, Testable {} + +/** Instance of an emitter tester */ +export interface EmitterTesterInstance extends TesterInstanceBase, OutputTestable {} + // #endregion // #region Legacy Test host diff --git a/packages/compiler/test/testing/tester.test.ts b/packages/compiler/test/testing/tester.test.ts index 29da347128c..add6acb176e 100644 --- a/packages/compiler/test/testing/tester.test.ts +++ b/packages/compiler/test/testing/tester.test.ts @@ -271,6 +271,17 @@ describe("emitter", () => { }); }); + it("can use same chai methods", async () => { + const res = await await EmitterTester.wrap( + (x) => `model Test {}\n${x}\nmodel Test2 {}`, + ).compile(`model Foo {}`); + expect(res.outputs).toEqual({ + "Foo.model": "Foo", + "Test.model": "Test", + "Test2.model": "Test2", + }); + }); + it("add extra files via fs api", async () => { const tester = await EmitterTester.createInstance(); tester.fs.add("foo.tsp", "model Foo {}"); From 3b379e2f95a8d363fe2ca67b1456863a7e401079 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 23 May 2025 12:50:22 -0700 Subject: [PATCH 47/55] piping --- packages/compiler/src/testing/index.ts | 2 +- packages/compiler/src/testing/tester.ts | 50 ++++++++++++------- packages/compiler/src/testing/types.ts | 21 ++++---- packages/compiler/test/testing/tester.test.ts | 7 +++ 4 files changed, 51 insertions(+), 29 deletions(-) diff --git a/packages/compiler/src/testing/index.ts b/packages/compiler/src/testing/index.ts index f28b7dbb3d1..ec8044637db 100644 --- a/packages/compiler/src/testing/index.ts +++ b/packages/compiler/src/testing/index.ts @@ -10,7 +10,6 @@ export { } from "./rule-tester.js"; export { extractCursor, extractSquiggles } from "./source-utils.js"; export type { TestHostOptions } from "./test-compiler-host.js"; -export { createTester } from "./test-host-v2.js"; export { createTestHost, createTestRunner, findFilesFromPattern } from "./test-host.js"; export { createTestLibrary, @@ -21,6 +20,7 @@ export { trimBlankLines, type TestWrapperOptions, } from "./test-utils.js"; +export { createTester } from "./tester.js"; export type { BasicTestRunner, EmitterTester, diff --git a/packages/compiler/src/testing/tester.ts b/packages/compiler/src/testing/tester.ts index f5128a98a2f..0b9225138b8 100644 --- a/packages/compiler/src/testing/tester.ts +++ b/packages/compiler/src/testing/tester.ts @@ -128,13 +128,14 @@ interface TesterInternalParams { } interface EmitterTesterInternalParams extends TesterInternalParams { + outputProcess?: (result: any) => any; emitter: string; } -function createTesterBuilder>( - params: I, - create: (values: I) => O, -): TesterBuilder { +function createTesterBuilder< + const I extends TesterInternalParams, + const O extends TesterBuilder, +>(params: I, create: (values: I) => O): TesterBuilder { return { files, wrap, @@ -198,7 +199,7 @@ function createTesterInternal(params: TesterInternalParams): Tester { }; function emit(emitter: string, options?: Record): EmitterTester { - return createEmitterTesterInternal({ + return createEmitterTesterInternal({ ...params, emitter, compilerOptions: options @@ -218,20 +219,33 @@ function createTesterInternal(params: TesterInternalParams): Tester { } } -function createEmitterTesterInternal(params: EmitterTesterInternalParams): EmitterTester { +function createEmitterTesterInternal( + params: EmitterTesterInternalParams, +): EmitterTester { return { ...createCompilable(async (...args) => { - const instance = await createEmitterTesterInstance(params); + const instance = await createEmitterTesterInstance(params); return instance.compileAndDiagnose(...args); }), - ...createTesterBuilder(params, createEmitterTesterInternal), + ...createTesterBuilder>( + params, + createEmitterTesterInternal, + ), + pipe: (cb: (previous: Result) => O): EmitterTester => { + return createEmitterTesterInternal({ + ...params, + outputProcess: async (result) => { + return params.outputProcess ? cb(params.outputProcess(result)) : cb(result); + }, + }); + }, createInstance: () => createEmitterTesterInstance(params), }; } -async function createEmitterTesterInstance( +async function createEmitterTesterInstance( params: EmitterTesterInternalParams, -): Promise { +): Promise> { const tester = await createTesterInstance(params); return { fs: tester.fs, @@ -244,7 +258,7 @@ async function createEmitterTesterInstance( async function compileAndDiagnose( code: string | Record, options?: TestCompileOptions, - ): Promise<[TestEmitterCompileResult, readonly Diagnostic[]]> { + ): Promise<[Result, readonly Diagnostic[]]> { if (options?.options?.emit !== undefined) { throw new Error("Cannot set emit in options."); } @@ -268,13 +282,13 @@ async function createEmitterTesterInstance( outputs[relativePath] = value; } } - return [ - { - ...result, - outputs, - }, - diagnostics, - ]; + + const prep = { + ...result, + outputs, + }; + const final = params.outputProcess ? params.outputProcess(prep) : prep; + return [final, diagnostics]; } } diff --git a/packages/compiler/src/testing/types.ts b/packages/compiler/src/testing/types.ts index 19a86e4f62b..db5a31e54db 100644 --- a/packages/compiler/src/testing/types.ts +++ b/packages/compiler/src/testing/types.ts @@ -108,24 +108,25 @@ export interface TestEmitterCompileResult { readonly outputs: Record; } -export interface OutputTestable { - compile( - code: string | Record, - options?: TestCompileOptions, - ): Promise; +export interface OutputTestable { + compile(code: string | Record, options?: TestCompileOptions): Promise; compileAndDiagnose( code: string | Record, options?: TestCompileOptions, - ): Promise<[TestEmitterCompileResult, readonly Diagnostic[]]>; + ): Promise<[Result, readonly Diagnostic[]]>; diagnose( code: string | Record, options?: TestCompileOptions, ): Promise; } -export interface OutputTester extends OutputTestable, TesterBuilder {} + /** Alternate version of the tester which runs the configured emitter */ -export interface EmitterTester extends OutputTester { - createInstance(): Promise; +export interface EmitterTester + extends OutputTestable, + TesterBuilder> { + pipe(cb: (result: Result) => O): EmitterTester; + + createInstance(): Promise>; } export interface TesterInstanceBase { @@ -139,7 +140,7 @@ export interface TesterInstanceBase { export interface TesterInstance extends TesterInstanceBase, Testable {} /** Instance of an emitter tester */ -export interface EmitterTesterInstance extends TesterInstanceBase, OutputTestable {} +export interface EmitterTesterInstance extends TesterInstanceBase, OutputTestable {} // #endregion diff --git a/packages/compiler/test/testing/tester.test.ts b/packages/compiler/test/testing/tester.test.ts index add6acb176e..ecb5d07ed8c 100644 --- a/packages/compiler/test/testing/tester.test.ts +++ b/packages/compiler/test/testing/tester.test.ts @@ -282,6 +282,13 @@ describe("emitter", () => { }); }); + it("pipe outputs", async () => { + const res = await await EmitterTester.pipe((x) => x.outputs["Foo.model"]).compile( + `model Foo {}`, + ); + expect(res).toEqual("Foo"); + }); + it("add extra files via fs api", async () => { const tester = await EmitterTester.createInstance(); tester.fs.add("foo.tsp", "model Foo {}"); From 7c8a7ec7a7abbbfbfd8efee43529c639ab9e7a54 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 23 May 2025 12:55:36 -0700 Subject: [PATCH 48/55] Fix values --- .../compiler/src/testing/marked-template.ts | 18 +++++++++++++++--- packages/compiler/test/testing/tester.test.ts | 2 ++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/testing/marked-template.ts b/packages/compiler/src/testing/marked-template.ts index 96b2b6d1272..4fbb0e4fa87 100644 --- a/packages/compiler/src/testing/marked-template.ts +++ b/packages/compiler/src/testing/marked-template.ts @@ -1,16 +1,23 @@ -import { +import type { + ArrayValue, BooleanLiteral, + BooleanValue, Entity, Enum, EnumMember, + EnumValue, Interface, Model, ModelProperty, Namespace, NumericLiteral, + NumericValue, + ObjectValue, Operation, Scalar, + ScalarValue, StringLiteral, + StringValue, Type, Union, UnionVariant, @@ -129,8 +136,13 @@ export const t = { // Values value: valueMarker(), - object: valueMarker("ObjectValue"), - array: valueMarker("ArrayValue"), + object: valueMarker("ObjectValue"), + array: valueMarker("ArrayValue"), + numericValue: valueMarker("NumericValue"), + stringValue: valueMarker("StringValue"), + booleanValue: valueMarker("BooleanValue"), + scalarValue: valueMarker("ScalarValue"), + enumValue: valueMarker("EnumValue"), }; type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void diff --git a/packages/compiler/test/testing/tester.test.ts b/packages/compiler/test/testing/tester.test.ts index ecb5d07ed8c..308ebc8f0b9 100644 --- a/packages/compiler/test/testing/tester.test.ts +++ b/packages/compiler/test/testing/tester.test.ts @@ -10,6 +10,7 @@ import { getLocationContext, Model, navigateProgram, + ObjectValue, Program, } from "../../src/index.js"; import { mockFile } from "../../src/testing/fs.js"; @@ -174,6 +175,7 @@ describe("extract values", () => { const ${t.object("foo")} = #{}; `); expect(res.foo.valueKind).toBe("ObjectValue"); + expectTypeOf(res.foo).toExtend(); }); it("array", async () => { From 79a1e384a92a397aea5759918aa2c603ac8bb67d Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 23 May 2025 14:04:55 -0700 Subject: [PATCH 49/55] works --- website/src/content/current-sidebar.ts | 1 + .../docs/docs/extending-typespec/basics.md | 155 +--------- .../docs/docs/extending-typespec/testing.mdx | 267 ++++++++++++++++++ 3 files changed, 269 insertions(+), 154 deletions(-) create mode 100644 website/src/content/docs/docs/extending-typespec/testing.mdx diff --git a/website/src/content/current-sidebar.ts b/website/src/content/current-sidebar.ts index 4cd9a1cce2e..6da32097370 100644 --- a/website/src/content/current-sidebar.ts +++ b/website/src/content/current-sidebar.ts @@ -238,6 +238,7 @@ const sidebar: SidebarItem[] = [ "extending-typespec/create-decorators", "extending-typespec/linters", "extending-typespec/codefixes", + "extending-typespec/testing", "extending-typespec/emitters-basics", "extending-typespec/emitter-framework", "extending-typespec/emitter-metadata-handling", diff --git a/website/src/content/docs/docs/extending-typespec/basics.md b/website/src/content/docs/docs/extending-typespec/basics.md index c8674acee39..60a536e537d 100644 --- a/website/src/content/docs/docs/extending-typespec/basics.md +++ b/website/src/content/docs/docs/extending-typespec/basics.md @@ -215,160 +215,7 @@ TypeSpec libraries are defined using `peerDependencies` to avoid having multiple ## Step 4: Testing your TypeSpec library -TypeSpec provides a testing framework to assist in testing libraries. The examples here are shown using Node.js's built-in test framework (available in Node 20+), but any other JS test framework can be used that will provide more advanced features like vitest, which is used in this project. - -### a. Add devDependencies - -Ensure that you have the following in your `package.json`: - -```json -"devDependencies": { - "@types/node": "~18.11.9", - "source-map-support": "^0.5.21" -} -``` - -Also add a `vitest.config.ts` file at the root of your project. - -```ts -import { defineConfig, mergeConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - // testTimeout: 10000, // Uncomment to increase the default timeout - isolate: false, // Your test shouldn't have side effects to this will improve performance. - }, -}); -``` - -### b. Define the testing library - -The first step is to define how your library can be loaded from the test framework. This will allow your library to be reused by other library tests. - -1. Create a new file `./src/testing/index.ts` with the following content - -```ts -import { createTestLibrary, findTestPackageRoot } from "@typespec/compiler/testing"; - -export const MyTestLibrary = createTestLibrary({ - name: "", - // Set this to the absolute path to the root of the package. (e.g. in this case this file would be compiled to ./dist/src/testing/index.js) - packageRoot: await findTestPackageRoot(import.meta.url), -}); -``` - -2. Add an `exports` for the `testing` endpoint to `package.json` (update with correct paths) - -```jsonc -{ - // ... - "main": "dist/src/index.js", - "exports": { - ".": { - "default": "./dist/src/index.js", - "types": "./dist/src/index.d.ts", - }, - "./testing": { - "default": "./dist/src/testing/index.js", - "types": "./dist/src/testing/index.d.ts", - }, - }, -} -``` - -### c. Define the test host and test runner for your library - -Define some of the test framework base pieces that will be used in the tests. There are 2 functions: - -- `createTestHost`: This is a lower-level API that provides a virtual file system. -- `createTestRunner`: This is a wrapper on top of the test host that will automatically add a `main.tsp` file and automatically import libraries. - -Create a new file `test/test-host.js` (change `test` to be your test folder) - -```ts -import { createTestHost, createTestWrapper } from "@typespec/compiler/testing"; -import { RestTestLibrary } from "@typespec/rest/testing"; -import { MyTestLibrary } from "../src/testing/index.js"; - -export async function createMyTestHost() { - return createTestHost({ - libraries: [RestTestLibrary, MyTestLibrary], // Add other libraries you depend on in your tests - }); -} -export async function createMyTestRunner() { - const host = await createMyTestHost(); - return createTestWrapper(host, { autoUsings: ["My"] }); -} -``` - -### d. Write tests - -After setting up that infrastructure you can start writing tests. By default Node.js will run all files matching these patterns: - -``` -**/*.test.?(c|m)js -**/*-test.?(c|m)js -**/*_test.?(c|m)js -**/test-*.?(c|m)js -**/test.?(c|m)js -**/test/**/*.?(c|m)js -``` - -[See nodejs doc](https://nodejs.org/api/test.html) - -```ts -import { createMyTestRunner } from "./test-host.js"; -import { describe, beforeEach, it } from "node:test"; - -describe("my library", () => { - let runner: BasicTestRunner; - - beforeEach(async () => { - runner = await createMyTestRunner(); - }); - - // Check everything works fine - it("does this", async () => { - const { Foo } = await runner.compile(` - @test model Foo {} - `); - strictEqual(Foo.kind, "Model"); - }); - - // Check diagnostics are emitted - it("errors", async () => { - const diagnostics = await runner.diagnose(` - model Bar {} - `); - expectDiagnostics(diagnostics, { code: "...", message: "..." }); - }); -}); -``` - -#### e. `@test` decorator - -The `@test` decorator is a decorator loaded in the test environment. It can be used to collect any decorable type. -When using the `compile` method it will return a `Record` which is a map of all the types annotated with the `@test` decorator. - -```ts -const { Foo, CustomName } = await runner.compile(` - @test model Foo {} - - model Bar { - @test("CustomName") name: string - } -`); - -Foo; // type of: model Foo {} -CustomName; // type of : Bar.name -``` - -#### f. Install VS Code extension for the test framework - -If you are using VS Code, you can install the [Node test runner](https://marketplace.visualstudio.com/items?itemName=connor4312.nodejs-testing) to run your tests from the editor. This will also allow you to easily debug your tests. - -After installing the extension, you should be able to discover, run, and debug your tests from the test explorer. +[Testing](./testing.mdx) see documentation for adding tests to your library. ## Step 5: Publishing your TypeSpec library diff --git a/website/src/content/docs/docs/extending-typespec/testing.mdx b/website/src/content/docs/docs/extending-typespec/testing.mdx new file mode 100644 index 00000000000..92e54168a4d --- /dev/null +++ b/website/src/content/docs/docs/extending-typespec/testing.mdx @@ -0,0 +1,267 @@ +--- +title: Testing +tableOfContents: + maxHeadingLevel: 4 +--- + +import { Steps } from '@astrojs/starlight/components'; + +TypeSpec provides a testing framework to assist in testing libraries. The examples here are shown using vitest, but any other JS test framework can be used that will provide more advanced features like vitest, which is used in this project. + +## Setting up vitest + +This step is a basic explanation of how to setup vitest. Please refer to the [vitest documentation](https://vitest.dev/) for more details. + + + +1. Add vitest to your dependencies + + ```diff lang=json title="package.json" + { + "name": "my-library", + "scripts": { + + "test": "vitest run", + + "test:watch": "vitest" + }, + "devDependencies": { + + "vitest": "^3.1.4" + } + } + ``` + +2. Add a `vitest.config.ts` file at the root of your project. + + ```ts title="vitest.config.ts" + import { defineConfig, mergeConfig } from "vitest/config"; + + export default defineConfig({ + test: { + environment: "node", + // testTimeout: 10000, // Uncomment to increase the default timeout + isolate: false, // Your test shouldn't have side effects doing this will improve performance. + }, + }); + ``` + + + +## Quick start + +### Define the tester + +Define a tester for your library. This should be a root level file. It will ensure that file system calls are cached in between tests. + +```ts title="test/tester.ts" +import { createTester } from "@typespec/compiler/testing"; + +const MyTester = createTester({ + libraries: ["@typespec/http", "@typespec/openapi", "my-library"], // Add other libraries you depend on in your tests +}); +``` + +### Write your first test + +```ts title="test/my-library.test.ts" +import { t } from "@typespec/compiler/testing"; +import { MyTester } from "./test-host.js"; +import { it } from "vitest"; + +// Check everything works fine +it("does this", async () => { + const { Foo } = await MyTester.compile(t.code` + model ${t.model("Foo")} {} + `); + strictEqual(Foo.name, "Foo"); +}); + +// Check diagnostics are emitted +it("errors", async () => { + const diagnostics = await MyTester.diagnose(` + model Bar {} + `); + expectDiagnostics(diagnostics, { code: "...", message: "..." }); +}); +``` + +## Tester API + +### `compile` + +Compile the given code and assert no diagnostics were emitted. + +```ts title="test/my-library.test.ts" +// Check everything works fine +it("does this", async () => { + const { Foo } = await MyTester.compile(t.code` + model ${t.model("Foo")} {} + `); + strictEqual(Foo.name, "Foo"); +}); +``` + +### `diagnose` + +Compile the given code and return the diagnostics. + +```ts title="test/my-library.test.ts" +it("errors", async () => { + const diagnostics = await MyTester.diagnose(` + model Bar {} + `); + expectDiagnostics(diagnostics, { code: "...", message: "..." }); +}); +``` + +### `compileAndDiagnose` + +Returns a tuple of the result (same as `compile`) and the diagnostics (same as `diagnose`). + +```ts title="test/my-library.test.ts" +it("does this", async () => { + const [diagnostics, { Foo }] = await MyTester.compileAndDiagnose(t.code` + model ${t.model("Foo")} {} + `); + strictEqual(Foo.name, "Foo"); + expectDiagnostics(diagnostics, { code: "...", message: "..." }); +}); +``` + +## Tester chains + +The tester use a builder pattern to allow you to configure tester. Each pipe provide a clone of the tester allowing you to create different tester without modifying the original one. + +### `files` + +This will inject the given files in the tester. + +```ts +import { mockFile } from "@typespec/compiler/testing"; + +const TesterWithFoo = MyTester.files({ + "foo.tsp": ` + model Foo {} + `, + "bar.js": mockFile.js({ + $myDec: () => {}, + }), +}); + +await TesterWithFoo.compile(` + import "./foo.tsp"; + import "./bar.js"; +`); +``` + +### `import` + +Import the given path or libraries + +```ts +import { mockFile } from "@typespec/compiler/testing"; + +const TesterWithFoo = MyTester.import("my-library", "./foo.tsp"); + +await TesterWithFoo.compile(` + model Bar is Foo; +`); +``` + +Example combining with `files` + +```ts +import { mockFile } from "@typespec/compiler/testing"; + +const TesterWithFoo = MyTester.files({ + "foo.tsp": ` + model Foo {} + `, +}).import("./foo.tsp"); + +await TesterWithFoo.compile(` + model Bar is Foo; +`); +``` + +### `importLibraries` + +Import all the libraries originally defined in the `createTester` call. + +```ts +const MyTester = createTester({ + libraries: ["@typespec/http", "@typespec/openapi", "my-library"], // Add other libraries you depend on in your tests +}); + +MyTester.importLibraries(); + +// equivalent to +MyTester.import("@typespec/http", "@typespec/openapi", "my-library"); +``` + +### `using` + +Add the given using + +```ts +import { mockFile } from "@typespec/compiler/testing"; + +const TesterWithFoo = MyTester.using("Http", "MyOrg.MyLibrary"); +``` + +### `wrap` + +Wrap the source of the main file. + +```ts +import { mockFile } from "@typespec/compiler/testing"; + +const TesterWithFoo = MyTester.wrap(x=> ` + model Common {} + ${x} + `); +}); + +await TesterWithFoo.compile(` + model Bar is Common; +`); +``` + +## Collecting types + +The base tester provide a way to easily collect types from the test code in order to use them in the test. There is 3 ways this can be achieved: + +| Option | Type inferred/validated | +| -------------------------------------------- | ----------------------- | +| 1. `t` helper with `t.code` and `t.` | ✅ | +| 2. Flourslash syntax (`/*foo*/`) | | +| 3. `@test` decorator | | + +1. Using the `t` helper with `t.code` and `t.` + +```ts +const { Foo } = await MyTester.compile(t.code` + model ${t.model("Foo")} {} +`); // type of Foo is automatically inferred and validated to be a Model +strictEqual(Foo.name, "Foo"); +``` + +2. Using flourslash syntax to mark the types you want to collect (`/*foo*/`) + +```ts +const { Foo } = await MyTester.compile(t.code` + model /*foo*/Foo {} +`); // Foo is typed as an Entity +strictEqual(Foo.entityKind, "Type"); +strictEqual(Foo.type, "Model"); +strictEqual(Foo.name, "Foo"); +``` + +3. Using the `@test` decorator + +```ts +const { Foo } = await MyTester.compile(t.code` + @test model Foo {} +`); // Foo is typed as an Entity +strictEqual(Foo.entityKind, "Type"); +strictEqual(Foo.type, "Model"); +strictEqual(Foo.name, "Foo"); +``` From c59c9ecfc1715b79368d98ca7973853488f2d66f Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 3 Jun 2025 08:59:39 -0700 Subject: [PATCH 50/55] add docs for marked template --- packages/compiler/src/testing/fs.ts | 1 - .../compiler/src/testing/marked-template.ts | 111 ++++++++++++------ packages/compiler/src/testing/types.ts | 1 + 3 files changed, 73 insertions(+), 40 deletions(-) diff --git a/packages/compiler/src/testing/fs.ts b/packages/compiler/src/testing/fs.ts index 3bcb4d57a75..73a4c6f3bfb 100644 --- a/packages/compiler/src/testing/fs.ts +++ b/packages/compiler/src/testing/fs.ts @@ -7,7 +7,6 @@ import { createTestCompilerHost, TestHostOptions } from "./test-compiler-host.js import { findFilesFromPattern } from "./test-host.js"; import type { JsFile, MockFile, TestFileSystem, TypeSpecTestLibrary } from "./types.js"; -// TODO: can we get rid of this export function resolveVirtualPath(path: string, ...paths: string[]) { // NB: We should always resolve an absolute path, and there is no absolute // path that works across OSes. This ensures that we can still rely on API diff --git a/packages/compiler/src/testing/marked-template.ts b/packages/compiler/src/testing/marked-template.ts index 4fbb0e4fa87..2f285bed9fd 100644 --- a/packages/compiler/src/testing/marked-template.ts +++ b/packages/compiler/src/testing/marked-template.ts @@ -31,35 +31,15 @@ export type Marker = T extends Type : never; export interface TypeMarker { - entityKind: "Type"; - kind?: T["kind"]; - name: N; + readonly entityKind: "Type"; + readonly kind?: T["kind"]; + readonly name: N; } export interface ValueMarker { - entityKind: "Value"; - valueKind?: T["valueKind"]; - name: N; -} - -function typeMarker(kind?: T["kind"]) { - return (name: N): TypeMarker => { - return { - entityKind: "Type", - kind, - name, - }; - }; -} - -function valueMarker(valueKind?: T["valueKind"]) { - return (name: N): ValueMarker => { - return { - entityKind: "Value", - valueKind, - name, - }; - }; + readonly entityKind: "Value"; + readonly valueKind?: T["valueKind"]; + readonly name: N; } export type MarkerConfig> = { @@ -78,16 +58,8 @@ export const TemplateWithMarkers = { }, }; -type Prettify> = { - [K in keyof T]: T[K] & Entity; -} & {}; - -type InferType = T extends Marker ? K : never; -type CollectType | string>> = { - [K in T[number] as K extends Marker ? N : never]: InferType; -}; /** Specify that this value is dynamic and needs to be interpolated with the given keys */ -function extract | string)[]>( +function code | string)[]>( strings: TemplateStringsArray, ...keys: T ): TemplateWithMarkers>> { @@ -114,37 +86,98 @@ function extract | string)[]>( }; } +function typeMarker(kind?: T["kind"]) { + return (name: N): TypeMarker => { + return { + entityKind: "Type", + kind, + name, + }; + }; +} + +function valueMarker(valueKind?: T["valueKind"]) { + return (name: N): ValueMarker => { + return { + entityKind: "Value", + valueKind, + name, + }; + }; +} + /** TypeSpec template marker */ export const t = { - code: extract, - - // Types + /** + * Define a marked code block + * + * @example + * ```ts + * const code = t.code`model ${t.model("Foo")} { bar: string }`; + * ``` + */ + code: code, + + // -- Types -- + + /** Mark any type */ type: typeMarker(), + /** Mark a model */ model: typeMarker("Model"), + /** Mark an enum */ enum: typeMarker("Enum"), + /** Mark an union */ union: typeMarker("Union"), + /** Mark an interface */ interface: typeMarker("Interface"), + /** Mark an operation */ op: typeMarker("Operation"), + /** Mark an enum member */ enumMember: typeMarker("EnumMember"), + /** Mark a model property */ modelProperty: typeMarker("ModelProperty"), + /** Mark a namespace */ namespace: typeMarker("Namespace"), + /** Mark a scalar */ scalar: typeMarker("Scalar"), + /** Mark a union variant */ unionVariant: typeMarker("UnionVariant"), + /** Mark a boolean literal */ boolean: typeMarker("Boolean"), + /** Mark a number literal */ number: typeMarker("Number"), + /** Mark a string literal */ string: typeMarker("String"), - // Values + // -- Values -- + + /** Mark any value */ value: valueMarker(), + /** Mark an object value */ object: valueMarker("ObjectValue"), + /** Mark an array value */ array: valueMarker("ArrayValue"), + /** Mark a numeric value */ numericValue: valueMarker("NumericValue"), + /** Mark a string value */ stringValue: valueMarker("StringValue"), + /** Mark a boolean value */ booleanValue: valueMarker("BooleanValue"), + /** Mark a scalar value */ scalarValue: valueMarker("ScalarValue"), + /** Mark an enum value */ enumValue: valueMarker("EnumValue"), }; +type Prettify> = { + [K in keyof T]: T[K] & Entity; +} & {}; + +type InferType = T extends Marker ? K : never; +type CollectType | string>> = { + [K in T[number] as K extends Marker ? N : never]: InferType; +}; + type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; diff --git a/packages/compiler/src/testing/types.ts b/packages/compiler/src/testing/types.ts index db5a31e54db..eff19c472d1 100644 --- a/packages/compiler/src/testing/types.ts +++ b/packages/compiler/src/testing/types.ts @@ -16,6 +16,7 @@ export interface JsFile { export interface TestFileSystem { /** Raw files */ readonly fs: Map; + /** Compiler host */ readonly compilerHost: CompilerHost; /** From 6f6fd7a4118e463f2609c286cfbb80a96dd2c271 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 3 Jun 2025 09:09:00 -0700 Subject: [PATCH 51/55] add docs --- packages/compiler/src/testing/types.ts | 59 ++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/compiler/src/testing/types.ts b/packages/compiler/src/testing/types.ts index eff19c472d1..cc517f0bd0e 100644 --- a/packages/compiler/src/testing/types.ts +++ b/packages/compiler/src/testing/types.ts @@ -62,13 +62,58 @@ export interface TestCompileOptions { } interface Testable { + /** + * Compile the given code and validate no diagnostics(error or warnings) are present. + * Use {@link compileAndDiagnose} to get the compiler result and manage diagnostics yourself. + * + * @param code Can be the content of the `main.tsp` file or a record of files(MUST contains a main.tsp). + * @param options Optional test options. + * @returns {@link TestCompileResult} with the program and collected entities. + * + * @example + * ```ts + * const result = await tester.compile(t.code`model ${t.model("Foo")} { bar: string }`); + * // result.program is the program created + * // result.Foo is the model Foo created + * ``` + */ compile< T extends string | TemplateWithMarkers | Record>, >( code: T, options?: TestCompileOptions, ): Promise>>; + /** + * Compile the given code and return the list of diagnostics emitted. + * @param code Can be the content of the `main.tsp` file or a record of files(MUST contains a main.tsp). + * @param options Optional test options. + * @returns List of diagnostics emitted. + * + * @example + * ```ts + * const diagnostics = await tester.diagnose("model Foo {}"); + * expectDiagnostics(diagnostics, { + * code: "no-foo", + * message: "Do not use Foo as a model name", + * }); + * ``` + */ diagnose(main: string, options?: TestCompileOptions): Promise; + + /** + * Compile the given code and return the collected entities and diagnostics. + * + * @param code Can be the content of the `main.tsp` file or a record of files(MUST contains a main.tsp). + * @param options Optional test options. + * @returns {@link TestCompileResult} with the program and collected entities with the list of diagnostics emitted. + * + * @example + * ```ts + * const [result, diagnostics] = await tester.compileAndDiagnose(t.code`model ${t.model("Foo")} { bar: string }`); + * // result.program is the program created + * // result.Foo is the model Foo created + * ``` + */ compileAndDiagnose< T extends string | TemplateWithMarkers | Record>, >( @@ -125,8 +170,22 @@ export interface OutputTestable { export interface EmitterTester extends OutputTestable, TesterBuilder> { + /** + * Pipe the output of the emitter into a different structure + * + * @example + * ```ts + * const MyTester = Tester.emit("my-emitter").pipe((result) => { + * return JSON.parse(result.outputs["output.json"]); + * }); + * + * const result = await MyTester.compile("model Foo { bar: string }"); + * // result is the parsed JSON from the output.json file + * ``` + */ pipe(cb: (result: Result) => O): EmitterTester; + /** Create a mutable instance of the tester */ createInstance(): Promise>; } From d96573f3b846f0ca3a0669acab2536d4d258b4cd Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 3 Jun 2025 09:33:45 -0700 Subject: [PATCH 52/55] add back for back compat --- packages/compiler/src/testing/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/compiler/src/testing/index.ts b/packages/compiler/src/testing/index.ts index ec8044637db..80fbe8465c0 100644 --- a/packages/compiler/src/testing/index.ts +++ b/packages/compiler/src/testing/index.ts @@ -1,3 +1,8 @@ +export { + /** @deprecated Using this should be a noop. Prefer new test framework*/ + StandardTestLibrary, +} from "./test-compiler-host.js"; + export { expectCodeFixOnAst } from "./code-fix-testing.js"; export { expectDiagnosticEmpty, expectDiagnostics, type DiagnosticMatch } from "./expect.js"; export { createTestFileSystem, mockFile } from "./fs.js"; From 1db6ad0f5037d5814fbf7271819f24156483e498 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 9 Jun 2025 11:14:22 -0700 Subject: [PATCH 53/55] Apply suggestions from code review Co-authored-by: Christopher Radek <14189820+chrisradek@users.noreply.github.com> --- .../src/content/docs/docs/extending-typespec/testing.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/src/content/docs/docs/extending-typespec/testing.mdx b/website/src/content/docs/docs/extending-typespec/testing.mdx index 92e54168a4d..aec3f883cbf 100644 --- a/website/src/content/docs/docs/extending-typespec/testing.mdx +++ b/website/src/content/docs/docs/extending-typespec/testing.mdx @@ -63,7 +63,7 @@ const MyTester = createTester({ ```ts title="test/my-library.test.ts" import { t } from "@typespec/compiler/testing"; -import { MyTester } from "./test-host.js"; +import { MyTester } from "./tester.js"; import { it } from "vitest"; // Check everything works fine @@ -128,7 +128,7 @@ it("does this", async () => { ## Tester chains -The tester use a builder pattern to allow you to configure tester. Each pipe provide a clone of the tester allowing you to create different tester without modifying the original one. +The tester uses a builder pattern to allow you to configure a tester. Each pipe provides a clone of the tester allowing you to create different testers without modifying the original one. ### `files` @@ -227,7 +227,7 @@ await TesterWithFoo.compile(` ## Collecting types -The base tester provide a way to easily collect types from the test code in order to use them in the test. There is 3 ways this can be achieved: +The base tester provides a way to easily collect types from the test code in order to use them in the test. There are 3 ways this can be achieved: | Option | Type inferred/validated | | -------------------------------------------- | ----------------------- | From 729e5ebbbe5ed5dc802495956beab8c56375f942 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 21 Jun 2025 15:13:25 -0700 Subject: [PATCH 54/55] handle review comments --- packages/compiler/src/testing/tester.ts | 10 ++-- packages/compiler/src/testing/types.ts | 2 +- .../http/test/typekit/http-request.test.ts | 2 +- packages/openapi3/test/examples.test.ts | 9 --- packages/openapi3/test/test-host.ts | 10 ++-- .../docs/docs/extending-typespec/testing.mdx | 55 +++++++++++++++++++ 6 files changed, 67 insertions(+), 21 deletions(-) diff --git a/packages/compiler/src/testing/tester.ts b/packages/compiler/src/testing/tester.ts index 0b9225138b8..2b848a78073 100644 --- a/packages/compiler/src/testing/tester.ts +++ b/packages/compiler/src/testing/tester.ts @@ -259,14 +259,14 @@ async function createEmitterTesterInstance( code: string | Record, options?: TestCompileOptions, ): Promise<[Result, readonly Diagnostic[]]> { - if (options?.options?.emit !== undefined) { + if (options?.compilerOptions?.emit !== undefined) { throw new Error("Cannot set emit in options."); } const resolvedOptions: TestCompileOptions = { ...options, - options: { + compilerOptions: { ...params.compilerOptions, - ...options?.options, + ...options?.compilerOptions, outputDir: "tsp-output", emit: [params.emitter], }, @@ -274,7 +274,7 @@ async function createEmitterTesterInstance( const [result, diagnostics] = await tester.compileAndDiagnose(code, resolvedOptions); const outputs: Record = {}; const outputDir = - resolvedOptions.options?.options?.[params.emitter]?.["emitter-output-dir"] ?? + resolvedOptions.compilerOptions?.options?.[params.emitter]?.["emitter-output-dir"] ?? resolveVirtualPath(resolvePath("tsp-output", params.emitter)); for (const [name, value] of result.fs.fs) { if (name.startsWith(outputDir)) { @@ -375,7 +375,7 @@ async function createTesterInstance(params: TesterInternalParams): Promise> = T & { export interface TestCompileOptions { /** Optional compiler options */ - readonly options?: CompilerOptions; + readonly compilerOptions?: CompilerOptions; } interface Testable { diff --git a/packages/http/test/typekit/http-request.test.ts b/packages/http/test/typekit/http-request.test.ts index 1c24f2e1dfb..f81bf451f7c 100644 --- a/packages/http/test/typekit/http-request.test.ts +++ b/packages/http/test/typekit/http-request.test.ts @@ -10,7 +10,7 @@ describe("HttpRequest Body Parameters", () => { const { program, get } = await Tester.compile(t.code` model EmbeddingVector is Array; - @test op ${t.op("get")}(): EmbeddingVector; + op ${t.op("get")}(): EmbeddingVector; `); const tk = $(program); const httpOperation = tk.httpOperation.get(get); diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index b9c01ed79d2..df10f71f725 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -287,7 +287,6 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @route("/") op getColors(${param}): void; `, - undefined, { "experimental-parameter-examples": "data" }, ); expect((res.paths[path].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( @@ -419,7 +418,6 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @route("/") op getColors(${param}): void; `, - undefined, { "experimental-parameter-examples": "serialized" }, ); expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( @@ -567,7 +565,6 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @route("${route}") op getColors(${param}): void; `, - undefined, { "experimental-parameter-examples": "serialized" }, ); expect((res.paths[`/{color}`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( @@ -625,7 +622,6 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @route("/") op getColors(${param}): void; `, - undefined, { "experimental-parameter-examples": "serialized" }, ); expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( @@ -671,7 +667,6 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @route("/") op getColors(${param}): void; `, - undefined, { "experimental-parameter-examples": "serialized" }, ); expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).example).toEqual( @@ -696,7 +691,6 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @route("/") op getColors(@query color: string): void; `, - undefined, { "experimental-parameter-examples": "serialized" }, ); expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).examples).toEqual({ @@ -727,7 +721,6 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { @route("/") op getColors(@query color: string): void; `, - undefined, { "experimental-parameter-examples": "data" }, ); expect((res.paths[`/`].get?.parameters[0] as OpenAPI3Parameter).examples).toEqual({ @@ -781,7 +774,6 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { encodedHeader: utcDateTime; } `, - undefined, { "experimental-parameter-examples": "data" }, ); expect((res.components.parameters["Test.dob"] as OpenAPI3Parameter).examples).toEqual({ @@ -847,7 +839,6 @@ worksFor(["3.0.0", "3.1.0"], ({ openApiFor }) => { encodedHeader: utcDateTime; } `, - undefined, { "experimental-parameter-examples": "data" }, ); expect((res.components.parameters["Test.dob"] as OpenAPI3Parameter).example).toEqual( diff --git a/packages/openapi3/test/test-host.ts b/packages/openapi3/test/test-host.ts index 172e4f7750b..31f95a26618 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -44,7 +44,7 @@ export async function emitOpenApiWithDiagnostics( const fileType = options["file-type"] || "yaml"; const outputFile = resolveVirtualPath("openapi" + fileType === "json" ? ".json" : ".yaml"); const diagnostics = await runner.diagnose(code, { - options: { + compilerOptions: { options: { "@typespec/openapi3": { ...options, "output-file": outputFile }, }, @@ -58,7 +58,7 @@ export async function emitOpenApiWithDiagnostics( export async function diagnoseOpenApiFor(code: string, options: OpenAPI3EmitterOptions = {}) { const diagnostics = await SimpleTester.diagnose(code, { - options: { options: { "@typespec/openapi3": options as any } }, + compilerOptions: { options: { "@typespec/openapi3": options as any } }, }); return diagnostics; } @@ -67,7 +67,7 @@ export async function openApiFor(code: string, options: OpenAPI3EmitterOptions = const host = await SimpleTester.createInstance(); const outPath = "{emitter-output-dir}/openapi.json"; const { outputs } = await host.compile(code, { - options: { + compilerOptions: { options: { "@typespec/openapi3": { ...options, "output-file": outPath } }, }, }); @@ -82,7 +82,7 @@ export async function openApiForVersions( const host = await TesterWithVersioning.createInstance(); const outPath = "{emitter-output-dir}/{version}.openapi.json"; const { outputs } = await host.compile(code, { - options: { + compilerOptions: { options: { "@typespec/openapi3": { "output-file": outPath } }, }, }); @@ -131,7 +131,7 @@ export async function openapiWithOptions( const runner = await SimpleTester.createInstance(); const diagnostics = await runner.diagnose(code, { - options: { options: { "@typespec/openapi3": { ...options, "output-file": outPath } } }, + compilerOptions: { options: { "@typespec/openapi3": { ...options, "output-file": outPath } } }, }); expectDiagnosticEmpty(diagnostics); diff --git a/website/src/content/docs/docs/extending-typespec/testing.mdx b/website/src/content/docs/docs/extending-typespec/testing.mdx index aec3f883cbf..675c57520cf 100644 --- a/website/src/content/docs/docs/extending-typespec/testing.mdx +++ b/website/src/content/docs/docs/extending-typespec/testing.mdx @@ -8,6 +8,10 @@ import { Steps } from '@astrojs/starlight/components'; TypeSpec provides a testing framework to assist in testing libraries. The examples here are shown using vitest, but any other JS test framework can be used that will provide more advanced features like vitest, which is used in this project. +:::note +This is a documentation for the new Testing framework. To migrate from the old one see the [migration guide](#migrate-from-test-host) +::: + ## Setting up vitest This step is a basic explanation of how to setup vitest. Please refer to the [vitest documentation](https://vitest.dev/) for more details. @@ -59,6 +63,10 @@ const MyTester = createTester({ }); ``` +:::note +Unlike the old test wrapper this will not auto import anything. You can pipe with .importLibraries() to import all the libraries you defined in the `createTester` call. +::: + ### Write your first test ```ts title="test/my-library.test.ts" @@ -257,6 +265,9 @@ strictEqual(Foo.name, "Foo"); 3. Using the `@test` decorator +This is mostly kept for backwards compatibility with the old test host. It has the limitation of only being to target decorable types. +It is preferable to use the `t` helper when possible or the flourslash syntax for more complex cases. + ```ts const { Foo } = await MyTester.compile(t.code` @test model Foo {} @@ -265,3 +276,47 @@ strictEqual(Foo.entityKind, "Type"); strictEqual(Foo.type, "Model"); strictEqual(Foo.name, "Foo"); ``` + +## Migrate from test host + +PR with examples https://github.com/microsoft/typespec/pull/7151 + +```diff lang=ts title="test-host.ts" +- import { createTestHost, createTestWrapper } from "@typespec/compiler/testing"; +- import { HttpTestLibrary } from "@typespec/http/testing"; +- import { RestTestLibrary } from "@typespec/rest/testing"; +- import { MyTestLibrary } from "../src/testing/index.js"; +- +- export async function createMyTestHost() { +- return createTestHost({ +- libraries: [HttpTestLibrary, RestTestLibrary, MyTestLibrary], +- }); +- } +- export async function createMyTestRunner() { +- const host = await createOpenAPITestHost(); +- return createTestWrapper(host, { autoUsings: ["TypeSpec.My"] }); +- } + ++ import { resolvePath } from "@typespec/compiler"; ++ import { createTester } from "@typespec/compiler/testing"; ++ ++ export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { ++ libraries: ["@typespec/http", "@typespec/rest", "@typespec/my"], ++ }) ++ .importLibraries() ++ .using("My"); +``` + +In test files + +```diff lang=ts title="test/my-library.test.ts" + it("mark property as being an attribute", async () => { +- const { id } = (await runner.compile(`model Blob { +- @test @Xml.attribute id : string +- }`)) as { id: ModelProperty }; ++ const { id } = await Tester.compile(t.code`model Blob { ++ @Xml.attribute ${t.modelProperty("id")} : string ++ }`); + expect(isAttribute(runner.program, id)).toBe(true); + }); +``` From 983c2e4ed00b8c9045c150a0828c0d3150e5d2d1 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 21 Jun 2025 15:19:28 -0700 Subject: [PATCH 55/55] . --- packages/openapi3/test/output-file.test.ts | 2 +- packages/openapi3/test/output-spec-versions.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/openapi3/test/output-file.test.ts b/packages/openapi3/test/output-file.test.ts index 31032eee4fc..90ffd7165d9 100644 --- a/packages/openapi3/test/output-file.test.ts +++ b/packages/openapi3/test/output-file.test.ts @@ -42,7 +42,7 @@ describe("openapi3: output file", () => { }); async function compileOpenAPI(options: OpenAPI3EmitterOptions, code: string = ""): Promise { const diagnostics = await runner.diagnose(code, { - options: { + compilerOptions: { emit: ["@typespec/openapi3"], options: { "@typespec/openapi3": { ...options, "emitter-output-dir": outputDir } }, }, diff --git a/packages/openapi3/test/output-spec-versions.test.ts b/packages/openapi3/test/output-spec-versions.test.ts index 30fd89a1a13..d98fdc41d3b 100644 --- a/packages/openapi3/test/output-spec-versions.test.ts +++ b/packages/openapi3/test/output-spec-versions.test.ts @@ -17,7 +17,7 @@ beforeEach(async () => { async function compileOpenAPI(options: OpenAPI3EmitterOptions, code: string = ""): Promise { const diagnostics = await runner.diagnose(code, { - options: { + compilerOptions: { emit: ["@typespec/openapi3"], options: { "@typespec/openapi3": { ...options, "emitter-output-dir": outputDir } }, },