diff --git a/packages/samples/README.md b/packages/samples/README.md index c65acd9de41..2b8cd9bb30d 100644 --- a/packages/samples/README.md +++ b/packages/samples/README.md @@ -7,6 +7,11 @@ It is not published as an npm package. Samples should teach a distinct TypeSpec concept or realistic workflow. Narrow protocol test cases belong in the relevant package test suite instead of this collection. +Each sample directory has a `sample-config.yaml` containing a unique `title` and a +`description`. Set `playground: false` for samples that require multiple files or are not suitable +for the playground. A directory config uses `directory: true` and can provide a gallery `label`, +`danger`, or an inherited `playground: false`. + ```bash npm run test # Check Samples match snapshots npm run test:ci # run test same as CI diff --git a/packages/samples/package.json b/packages/samples/package.json index 63551636d72..d039df0934f 100644 --- a/packages/samples/package.json +++ b/packages/samples/package.json @@ -58,7 +58,8 @@ "@typespec/rest": "workspace:^", "@typespec/sse": "workspace:^", "@typespec/streams": "workspace:^", - "@typespec/versioning": "workspace:^" + "@typespec/versioning": "workspace:^", + "yaml": "catalog:" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/samples/specs/authentication/sample-config.yaml b/packages/samples/specs/authentication/sample-config.yaml new file mode 100644 index 00000000000..d446705ee5b --- /dev/null +++ b/packages/samples/specs/authentication/sample-config.yaml @@ -0,0 +1,3 @@ +title: Authentication +description: Apply authentication schemes at service, interface, and operation scope. +playground: false diff --git a/packages/samples/specs/binary/sample-config.yaml b/packages/samples/specs/binary/sample-config.yaml new file mode 100644 index 00000000000..9a863c88598 --- /dev/null +++ b/packages/samples/specs/binary/sample-config.yaml @@ -0,0 +1,3 @@ +title: Binary data +description: Model binary request and response payloads. +playground: false diff --git a/packages/samples/specs/documentation/sample-config.yaml b/packages/samples/specs/documentation/sample-config.yaml new file mode 100644 index 00000000000..b8b4726775d --- /dev/null +++ b/packages/samples/specs/documentation/sample-config.yaml @@ -0,0 +1,3 @@ +title: Documentation +description: Add documentation to TypeSpec declarations and emitted API descriptions. +playground: false diff --git a/packages/samples/specs/emitters/graphql/sample-config.yaml b/packages/samples/specs/emitters/graphql/sample-config.yaml new file mode 100644 index 00000000000..76a07f3f43b --- /dev/null +++ b/packages/samples/specs/emitters/graphql/sample-config.yaml @@ -0,0 +1,2 @@ +title: GraphQL +description: Generate a GraphQL schema with queries, mutations, models, and enums. diff --git a/packages/samples/specs/emitters/json-schema/sample-config.yaml b/packages/samples/specs/emitters/json-schema/sample-config.yaml new file mode 100644 index 00000000000..a468dc3c2c1 --- /dev/null +++ b/packages/samples/specs/emitters/json-schema/sample-config.yaml @@ -0,0 +1,2 @@ +title: JSON Schema +description: Generate JSON Schema documents from TypeSpec models and constraints. diff --git a/packages/samples/specs/emitters/protobuf-kiosk/sample-config.yaml b/packages/samples/specs/emitters/protobuf-kiosk/sample-config.yaml new file mode 100644 index 00000000000..614f424dd4d --- /dev/null +++ b/packages/samples/specs/emitters/protobuf-kiosk/sample-config.yaml @@ -0,0 +1,2 @@ +title: Protobuf kiosk +description: Generate Protocol Buffer service and message definitions for a kiosk system. diff --git a/packages/samples/specs/emitters/sample-config.yaml b/packages/samples/specs/emitters/sample-config.yaml new file mode 100644 index 00000000000..3ce47eb7474 --- /dev/null +++ b/packages/samples/specs/emitters/sample-config.yaml @@ -0,0 +1,2 @@ +directory: true +label: Emitters diff --git a/packages/samples/specs/encoded-names/sample-config.yaml b/packages/samples/specs/encoded-names/sample-config.yaml new file mode 100644 index 00000000000..ed17c381cd0 --- /dev/null +++ b/packages/samples/specs/encoded-names/sample-config.yaml @@ -0,0 +1,3 @@ +title: Encoded names +description: Customize declaration names for specific emitters. +playground: false diff --git a/packages/samples/specs/encoding/sample-config.yaml b/packages/samples/specs/encoding/sample-config.yaml new file mode 100644 index 00000000000..7dc42c72e36 --- /dev/null +++ b/packages/samples/specs/encoding/sample-config.yaml @@ -0,0 +1,3 @@ +title: Value encoding +description: Configure serialization formats for scalar values. +playground: false diff --git a/packages/samples/specs/getting-started/http-service/sample-config.yaml b/packages/samples/specs/getting-started/http-service/sample-config.yaml new file mode 100644 index 00000000000..1f76c07ca5b --- /dev/null +++ b/packages/samples/specs/getting-started/http-service/sample-config.yaml @@ -0,0 +1,2 @@ +title: HTTP service +description: Build an HTTP service with routes, parameters, request bodies, and responses. diff --git a/packages/samples/specs/getting-started/rest-framework/sample-config.yaml b/packages/samples/specs/getting-started/rest-framework/sample-config.yaml new file mode 100644 index 00000000000..7bac7cefa34 --- /dev/null +++ b/packages/samples/specs/getting-started/rest-framework/sample-config.yaml @@ -0,0 +1,2 @@ +title: REST framework +description: Use resource-oriented REST templates to define a service with less repetition. diff --git a/packages/samples/specs/getting-started/sample-config.yaml b/packages/samples/specs/getting-started/sample-config.yaml new file mode 100644 index 00000000000..2153bcb8727 --- /dev/null +++ b/packages/samples/specs/getting-started/sample-config.yaml @@ -0,0 +1,2 @@ +directory: true +label: Getting Started diff --git a/packages/samples/specs/grpc-kiosk-example/sample-config.yaml b/packages/samples/specs/grpc-kiosk-example/sample-config.yaml new file mode 100644 index 00000000000..c1fdfdfc354 --- /dev/null +++ b/packages/samples/specs/grpc-kiosk-example/sample-config.yaml @@ -0,0 +1,3 @@ +title: Advanced gRPC kiosk +description: Demonstrate a multi-file Protobuf-oriented kiosk service. +playground: false diff --git a/packages/samples/specs/grpc-library-example/sample-config.yaml b/packages/samples/specs/grpc-library-example/sample-config.yaml new file mode 100644 index 00000000000..6b8b2d29b6b --- /dev/null +++ b/packages/samples/specs/grpc-library-example/sample-config.yaml @@ -0,0 +1,3 @@ +title: Protobuf library +description: Build and consume reusable Protobuf declarations. +playground: false diff --git a/packages/samples/specs/init/sample-config.yaml b/packages/samples/specs/init/sample-config.yaml new file mode 100644 index 00000000000..09c903dd11a --- /dev/null +++ b/packages/samples/specs/init/sample-config.yaml @@ -0,0 +1,3 @@ +title: Project initialization +description: Show the service generated by the TypeSpec project initializer. +playground: false diff --git a/packages/samples/specs/language/api-versioning/sample-config.yaml b/packages/samples/specs/language/api-versioning/sample-config.yaml new file mode 100644 index 00000000000..fadbf887afa --- /dev/null +++ b/packages/samples/specs/language/api-versioning/sample-config.yaml @@ -0,0 +1,2 @@ +title: API versioning +description: Evolve an API across versions with version-aware types and operations. diff --git a/packages/samples/specs/language/discriminated-unions/sample-config.yaml b/packages/samples/specs/language/discriminated-unions/sample-config.yaml new file mode 100644 index 00000000000..b96a5edfb4b --- /dev/null +++ b/packages/samples/specs/language/discriminated-unions/sample-config.yaml @@ -0,0 +1,2 @@ +title: Discriminated unions +description: Model polymorphic values with a discriminated union. diff --git a/packages/samples/specs/language/sample-config.yaml b/packages/samples/specs/language/sample-config.yaml new file mode 100644 index 00000000000..dd6c729a355 --- /dev/null +++ b/packages/samples/specs/language/sample-config.yaml @@ -0,0 +1,2 @@ +directory: true +label: Language diff --git a/packages/samples/specs/local-typespec/sample-config.yaml b/packages/samples/specs/local-typespec/sample-config.yaml new file mode 100644 index 00000000000..f0f127fea64 --- /dev/null +++ b/packages/samples/specs/local-typespec/sample-config.yaml @@ -0,0 +1,3 @@ +title: Local TypeSpec installation +description: Configure an editor to use a project-local TypeSpec compiler. +playground: false diff --git a/packages/samples/specs/multipart/sample-config.yaml b/packages/samples/specs/multipart/sample-config.yaml new file mode 100644 index 00000000000..821e94f5001 --- /dev/null +++ b/packages/samples/specs/multipart/sample-config.yaml @@ -0,0 +1,3 @@ +title: Multipart requests +description: Define multipart form data with files and structured fields. +playground: false diff --git a/packages/samples/specs/openapi-extensions/sample-config.yaml b/packages/samples/specs/openapi-extensions/sample-config.yaml new file mode 100644 index 00000000000..d4e66f4dd5c --- /dev/null +++ b/packages/samples/specs/openapi-extensions/sample-config.yaml @@ -0,0 +1,3 @@ +title: OpenAPI extensions +description: Attach custom OpenAPI extensions to emitted declarations. +playground: false diff --git a/packages/samples/specs/param-decorators/sample-config.yaml b/packages/samples/specs/param-decorators/sample-config.yaml new file mode 100644 index 00000000000..32eb0c2abd2 --- /dev/null +++ b/packages/samples/specs/param-decorators/sample-config.yaml @@ -0,0 +1,3 @@ +title: Parameter decorators +description: Apply decorators through reusable operation parameters. +playground: false diff --git a/packages/samples/specs/petstore/sample-config.yaml b/packages/samples/specs/petstore/sample-config.yaml new file mode 100644 index 00000000000..b32cb866a1d --- /dev/null +++ b/packages/samples/specs/petstore/sample-config.yaml @@ -0,0 +1,3 @@ +title: Extensible Pet Store +description: Extend TypeSpec with a local decorator and custom project files. +playground: false diff --git a/packages/samples/specs/polymorphism/sample-config.yaml b/packages/samples/specs/polymorphism/sample-config.yaml new file mode 100644 index 00000000000..70c2bc8fca3 --- /dev/null +++ b/packages/samples/specs/polymorphism/sample-config.yaml @@ -0,0 +1,3 @@ +title: Polymorphic models +description: Model inheritance and discriminated polymorphic responses. +playground: false diff --git a/packages/samples/specs/rest-metadata-emitter/sample-config.yaml b/packages/samples/specs/rest-metadata-emitter/sample-config.yaml new file mode 100644 index 00000000000..cc8b0a0ed3a --- /dev/null +++ b/packages/samples/specs/rest-metadata-emitter/sample-config.yaml @@ -0,0 +1,3 @@ +title: REST metadata emitter +description: Inspect REST metadata from a custom emitter. +playground: false diff --git a/packages/samples/specs/rest/petstore/sample-config.yaml b/packages/samples/specs/rest/petstore/sample-config.yaml new file mode 100644 index 00000000000..347b239cb9c --- /dev/null +++ b/packages/samples/specs/rest/petstore/sample-config.yaml @@ -0,0 +1,3 @@ +title: REST Pet Store +description: Define a resource-oriented Pet Store service. +playground: false diff --git a/packages/samples/specs/signatures/sample-config.yaml b/packages/samples/specs/signatures/sample-config.yaml new file mode 100644 index 00000000000..a08e5308e92 --- /dev/null +++ b/packages/samples/specs/signatures/sample-config.yaml @@ -0,0 +1,3 @@ +title: Operation signatures +description: Compare operation declaration and template signatures. +playground: false diff --git a/packages/samples/specs/status-code-ranges/sample-config.yaml b/packages/samples/specs/status-code-ranges/sample-config.yaml new file mode 100644 index 00000000000..8bde09833b2 --- /dev/null +++ b/packages/samples/specs/status-code-ranges/sample-config.yaml @@ -0,0 +1,3 @@ +title: Status code ranges +description: Describe HTTP responses covering status code ranges. +playground: false diff --git a/packages/samples/specs/string-template/sample-config.yaml b/packages/samples/specs/string-template/sample-config.yaml new file mode 100644 index 00000000000..0283f256347 --- /dev/null +++ b/packages/samples/specs/string-template/sample-config.yaml @@ -0,0 +1,3 @@ +title: String templates +description: Construct values using TypeSpec string templates. +playground: false diff --git a/packages/samples/specs/tags/sample-config.yaml b/packages/samples/specs/tags/sample-config.yaml new file mode 100644 index 00000000000..b33bcb3e2fa --- /dev/null +++ b/packages/samples/specs/tags/sample-config.yaml @@ -0,0 +1,3 @@ +title: OpenAPI tags +description: Group emitted operations with tags. +playground: false diff --git a/packages/samples/specs/todoApp/sample-config.yaml b/packages/samples/specs/todoApp/sample-config.yaml new file mode 100644 index 00000000000..568693e6875 --- /dev/null +++ b/packages/samples/specs/todoApp/sample-config.yaml @@ -0,0 +1,3 @@ +title: Todo application +description: Define a complete HTTP API for a small todo application. +playground: false diff --git a/packages/samples/specs/use-versioned-lib/sample-config.yaml b/packages/samples/specs/use-versioned-lib/sample-config.yaml new file mode 100644 index 00000000000..0558bdb7e51 --- /dev/null +++ b/packages/samples/specs/use-versioned-lib/sample-config.yaml @@ -0,0 +1,3 @@ +title: Versioned library +description: Consume a library whose API evolves across versions. +playground: false diff --git a/packages/samples/specs/versioning/sample-config.yaml b/packages/samples/specs/versioning/sample-config.yaml new file mode 100644 index 00000000000..ec681dc7090 --- /dev/null +++ b/packages/samples/specs/versioning/sample-config.yaml @@ -0,0 +1,3 @@ +title: Advanced versioning +description: Coordinate service and library versions across multiple files. +playground: false diff --git a/packages/samples/specs/visibility/sample-config.yaml b/packages/samples/specs/visibility/sample-config.yaml new file mode 100644 index 00000000000..5629bbf9674 --- /dev/null +++ b/packages/samples/specs/visibility/sample-config.yaml @@ -0,0 +1,3 @@ +title: Lifecycle visibility +description: Control how model properties participate in lifecycle operations. +playground: false diff --git a/packages/samples/src/index.ts b/packages/samples/src/index.ts index 4a2e207b030..9c67c0047c6 100644 --- a/packages/samples/src/index.ts +++ b/packages/samples/src/index.ts @@ -1 +1,2 @@ +export * from "./sample-config.js"; export * from "./sample-snapshot-testing.js"; diff --git a/packages/samples/src/sample-config.ts b/packages/samples/src/sample-config.ts new file mode 100644 index 00000000000..451fa06f3fc --- /dev/null +++ b/packages/samples/src/sample-config.ts @@ -0,0 +1,262 @@ +import { readdir, readFile } from "node:fs/promises"; +import { dirname, relative, resolve, sep } from "node:path"; +import { parse } from "yaml"; + +export interface SampleConfig { + directory?: false; + title: string; + description: string; + llmstxt?: boolean; + danger?: string; + playground?: boolean; +} + +export interface SampleDirectoryConfig { + directory: true; + label?: string; + danger?: string; + playground?: boolean; +} + +export interface SampleCategory { + id: string; + label: string; +} + +export interface SampleEntry { + id: string; + directory: string; + config: SampleConfig; + category?: SampleCategory; + playground: boolean; + tspFiles: string[]; +} + +export interface SampleCatalog { + samples: SampleEntry[]; + directories: Map; +} + +interface LoadedConfig { + id: string; + directory: string; + config: SampleConfig | SampleDirectoryConfig; +} + +/** + * Discover and validate samples below the given specs directory. + */ +export async function loadSampleCatalog(specsDir: string): Promise { + const root = resolve(specsDir); + const loadedConfigs: LoadedConfig[] = []; + const entrypointDirs = new Set(); + + await walk(root); + + const configByDirectory = new Map(loadedConfigs.map((x) => [x.directory, x])); + const directories = new Map(); + const samples: SampleEntry[] = []; + const titles = new Map(); + + for (const loaded of loadedConfigs) { + if (loaded.config.directory === true) { + directories.set(loaded.id, loaded.config); + continue; + } + + const existingTitle = titles.get(loaded.config.title); + if (existingTitle) { + throw new Error( + `Sample "${loaded.id}" has duplicate title "${loaded.config.title}" also used by "${existingTitle}".`, + ); + } + titles.set(loaded.config.title, loaded.id); + + const tspFiles = await findTspFiles(loaded.directory); + const hasMain = tspFiles.some((x) => x === resolve(loaded.directory, "main.tsp")); + const hasPackage = entrypointDirs.has(loaded.directory); + if (!hasMain && !hasPackage) { + throw new Error( + `Sample "${loaded.id}" must contain main.tsp or a package.json TypeSpec entrypoint.`, + ); + } + + const ancestors = getAncestorDirectories(loaded.directory, root); + const directoryConfigs = ancestors + .map((x) => configByDirectory.get(x)) + .filter( + (x): x is LoadedConfig & { config: SampleDirectoryConfig } => x?.config.directory === true, + ); + const categoryConfig = directoryConfigs.at(-1); + + samples.push({ + id: loaded.id, + directory: loaded.directory, + config: loaded.config, + category: categoryConfig + ? { + id: categoryConfig.id, + label: categoryConfig.config.label ?? formatLabel(categoryConfig.id.split("/").at(-1)!), + } + : undefined, + playground: + loaded.config.playground !== false && + directoryConfigs.every((x) => x.config.playground !== false), + tspFiles, + }); + } + + for (const entrypointDir of entrypointDirs) { + const loaded = configByDirectory.get(entrypointDir); + if (!loaded || loaded.config.directory === true) { + throw new Error( + `Sample at "${toPosix(relative(root, entrypointDir))}" is missing sample-config.yaml.`, + ); + } + } + + samples.sort((a, b) => a.id.localeCompare(b.id)); + + return { samples, directories }; + + async function walk(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const fullPath = resolve(directory, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + } else if (entry.name === "sample-config.yaml") { + const id = toPosix(relative(root, directory)); + loadedConfigs.push({ + id, + directory, + config: parseSampleConfig(await readFile(fullPath, "utf-8"), fullPath), + }); + } else if (entry.name === "main.tsp" || entry.name === "package.json") { + entrypointDirs.add(directory); + } + } + } +} + +function parseSampleConfig( + content: string, + configPath: string, +): SampleConfig | SampleDirectoryConfig { + let value: unknown; + try { + value = parse(content); + } catch (error) { + throw new Error(`Invalid YAML in ${configPath}.`, { cause: error }); + } + + if (!isRecord(value)) { + throw new Error(`Sample config at ${configPath} must be a YAML object.`); + } + + if (value.directory === true) { + validateKnownKeys(value, new Set(["directory", "label", "danger", "playground"]), configPath); + validateOptionalString(value, "label", configPath); + validateOptionalString(value, "danger", configPath); + validateOptionalBoolean(value, "playground", configPath); + return value as unknown as SampleDirectoryConfig; + } + + validateKnownKeys( + value, + new Set(["directory", "title", "description", "llmstxt", "danger", "playground"]), + configPath, + ); + if (value.directory !== undefined && value.directory !== false) { + throw new Error(`Sample config at ${configPath} has invalid "directory"; expected false.`); + } + validateRequiredString(value, "title", configPath); + validateRequiredString(value, "description", configPath); + validateOptionalBoolean(value, "llmstxt", configPath); + validateOptionalString(value, "danger", configPath); + validateOptionalBoolean(value, "playground", configPath); + return value as unknown as SampleConfig; +} + +function validateKnownKeys( + value: Record, + keys: Set, + configPath: string, +): void { + const unknown = Object.keys(value).find((x) => !keys.has(x)); + if (unknown) { + throw new Error(`Sample config at ${configPath} has unknown field "${unknown}".`); + } +} + +async function findTspFiles(directory: string): Promise { + const files: string[] = []; + await walk(directory); + return files.sort(); + + async function walk(current: string): Promise { + for (const entry of await readdir(current, { withFileTypes: true })) { + const fullPath = resolve(current, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + } else if (entry.name.endsWith(".tsp")) { + files.push(fullPath); + } + } + } +} + +function getAncestorDirectories(directory: string, root: string): string[] { + const ancestors: string[] = []; + let current = dirname(directory); + while (current !== root && current.startsWith(`${root}${sep}`)) { + ancestors.unshift(current); + current = dirname(current); + } + return ancestors; +} + +function formatLabel(value: string): string { + return value + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function toPosix(path: string): string { + return path.split(sep).join("/"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validateRequiredString( + value: Record, + key: string, + configPath: string, +): void { + if (typeof value[key] !== "string" || value[key].trim() === "") { + throw new Error(`Sample config at ${configPath} requires a non-empty "${key}" field.`); + } +} + +function validateOptionalString( + value: Record, + key: string, + configPath: string, +): void { + if (value[key] !== undefined && typeof value[key] !== "string") { + throw new Error(`Sample config at ${configPath} has invalid "${key}"; expected a string.`); + } +} + +function validateOptionalBoolean( + value: Record, + key: string, + configPath: string, +): void { + if (value[key] !== undefined && typeof value[key] !== "boolean") { + throw new Error(`Sample config at ${configPath} has invalid "${key}"; expected a boolean.`); + } +} diff --git a/packages/samples/test/sample-config.test.ts b/packages/samples/test/sample-config.test.ts new file mode 100644 index 00000000000..a2c31cab97f --- /dev/null +++ b/packages/samples/test/sample-config.test.ts @@ -0,0 +1,114 @@ +import { findTestPackageRoot } from "@typespec/compiler/testing"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { loadSampleCatalog } from "../src/sample-config.js"; + +describe("sample config", () => { + it("loads the canonical playground samples in path order", async () => { + const packageRoot = await findTestPackageRoot(import.meta.url); + const catalog = await loadSampleCatalog(resolve(packageRoot, "specs")); + + expect(catalog.samples.filter((x) => x.playground).map((x) => x.config.title)).toEqual([ + "GraphQL", + "JSON Schema", + "Protobuf kiosk", + "HTTP service", + "REST framework", + "API versioning", + "Discriminated unions", + ]); + }); + + it("inherits playground exclusions from directory configs", async () => { + await withTempCatalog( + { + "legacy/sample-config.yaml": "directory: true\nplayground: false\n", + "legacy/widget/sample-config.yaml": "title: Legacy widget\ndescription: A legacy sample.\n", + "legacy/widget/main.tsp": "model Widget {}", + }, + async (root) => { + const catalog = await loadSampleCatalog(root); + expect(catalog.samples[0].playground).toBe(false); + }, + ); + }); + + it("reports missing required metadata", async () => { + await withTempCatalog( + { + "widget/sample-config.yaml": "title: Widget\n", + "widget/main.tsp": "model Widget {}", + }, + async (root) => { + await expect(loadSampleCatalog(root)).rejects.toThrow( + 'requires a non-empty "description" field', + ); + }, + ); + }); + + it("reports samples without metadata", async () => { + await withTempCatalog({ "widget/main.tsp": "model Widget {}" }, async (root) => { + await expect(loadSampleCatalog(root)).rejects.toThrow("is missing sample-config.yaml"); + }); + }); + + it("reports duplicate sample titles", async () => { + await withTempCatalog( + { + "one/sample-config.yaml": "title: Widget\ndescription: First sample.\n", + "one/main.tsp": "model One {}", + "two/sample-config.yaml": "title: Widget\ndescription: Second sample.\n", + "two/main.tsp": "model Two {}", + }, + async (root) => { + await expect(loadSampleCatalog(root)).rejects.toThrow('has duplicate title "Widget"'); + }, + ); + }); + + it("reports malformed and unknown metadata", async () => { + await withTempCatalog( + { + "widget/sample-config.yaml": + "title: Widget\ndescription: A widget sample.\nunexpected: true\n", + "widget/main.tsp": "model Widget {}", + }, + async (root) => { + await expect(loadSampleCatalog(root)).rejects.toThrow('unknown field "unexpected"'); + }, + ); + }); + + it("reports metadata without a sample entrypoint", async () => { + await withTempCatalog( + { + "widget/sample-config.yaml": "title: Widget\ndescription: A widget sample.\n", + }, + async (root) => { + await expect(loadSampleCatalog(root)).rejects.toThrow( + "must contain main.tsp or a package.json TypeSpec entrypoint", + ); + }, + ); + }); +}); + +async function withTempCatalog( + files: Record, + callback: (root: string) => Promise, +): Promise { + const root = await mkdtemp(resolve(tmpdir(), "typespec-samples-")); + try { + for (const [name, content] of Object.entries(files)) { + const path = resolve(root, name); + await mkdir(resolve(path, ".."), { recursive: true }); + await writeFile(path, content); + } + await callback(root); + } finally { + await rm(root, { recursive: true }); + } +} diff --git a/packages/samples/vitest.config.ts b/packages/samples/vitest.config.ts index bd5b317b122..6151b95852f 100644 --- a/packages/samples/vitest.config.ts +++ b/packages/samples/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + include: ["test/**/*.test.ts"], environment: "node", testTimeout: 10000, isolate: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f952d0d5ead..ac8d7dc8988 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2259,6 +2259,9 @@ importers: '@typespec/versioning': specifier: workspace:^ version: link:../versioning + yaml: + specifier: 'catalog:' + version: 2.9.0 devDependencies: '@types/node': specifier: 'catalog:'