diff --git a/.chronus/changes/joheredi-feature-lifecycle-2025-7-1-14-16-12.md b/.chronus/changes/joheredi-feature-lifecycle-2025-7-1-14-16-12.md new file mode 100644 index 00000000000..969f6599df3 --- /dev/null +++ b/.chronus/changes/joheredi-feature-lifecycle-2025-7-1-14-16-12.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http-client" +--- + +Introduces @experimental decorator for annotating generated code as experimental. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index afd7b3de6a2..ab54b35cd6b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -36,6 +36,11 @@ cspell.yaml ###################### /packages/http-client-js/ @joheredi @timotheeguerin @timovv @qiaozha @MaryGao @xirzec @bterlson @markcowl @allenjzhang @witemple-msft @chrisradek @AlitzelMendez +###################### +# Client Framework +###################### +/packages/http-client/ @joheredi @timotheeguerin @timovv @qiaozha @MaryGao @xirzec @bterlson @markcowl @allenjzhang @witemple-msft @chrisradek @AlitzelMendez @tadelesh + ###################### # IDE ###################### diff --git a/packages/http-client/generated-defs/TypeSpec.HttpClient.ts b/packages/http-client/generated-defs/TypeSpec.HttpClient.ts new file mode 100644 index 00000000000..d6001ffcbe3 --- /dev/null +++ b/packages/http-client/generated-defs/TypeSpec.HttpClient.ts @@ -0,0 +1,15 @@ +import type { DecoratorContext, Type } from "@typespec/compiler"; + +export interface FeatureLifecycleOptions { + readonly emitterScope?: string; +} + +export type ExperimentalDecorator = ( + context: DecoratorContext, + target: Type, + options?: FeatureLifecycleOptions, +) => void; + +export type TypeSpecHttpClientDecorators = { + experimental: ExperimentalDecorator; +}; diff --git a/packages/http-client/generated-defs/TypeSpec.HttpClient.ts-test.ts b/packages/http-client/generated-defs/TypeSpec.HttpClient.ts-test.ts new file mode 100644 index 00000000000..619fd1f91e9 --- /dev/null +++ b/packages/http-client/generated-defs/TypeSpec.HttpClient.ts-test.ts @@ -0,0 +1,10 @@ +// An error in the imports would mean that the decorator is not exported or +// doesn't have the right name. + +import { $decorators } from "@typespec/http-client"; +import type { TypeSpecHttpClientDecorators } from "./TypeSpec.HttpClient.js"; + +/** + * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... + */ +const _: TypeSpecHttpClientDecorators = $decorators["TypeSpec.HttpClient"]; diff --git a/packages/http-client/lib/common.tsp b/packages/http-client/lib/common.tsp new file mode 100644 index 00000000000..32e10019630 --- /dev/null +++ b/packages/http-client/lib/common.tsp @@ -0,0 +1,9 @@ +namespace TypeSpec.HttpClient; + +/** + * Specifies the target emitters that the decorator should apply. If not set, the decorator will be applied to all emitters by default. + * You can use ”!” to exclude specific emitters, for example: `!@typespec/http-client-js, !@typespec/http-client-csharp` + */ +model ClientDecoratorOptions { + emitterScope?: string; +} diff --git a/packages/http-client/lib/decorators.tsp b/packages/http-client/lib/decorators.tsp new file mode 100644 index 00000000000..ae7b01da535 --- /dev/null +++ b/packages/http-client/lib/decorators.tsp @@ -0,0 +1,10 @@ +import "./common.tsp"; +import "../dist/src/tsp-index.js"; + +namespace TypeSpec.HttpClient; + +model FeatureLifecycleOptions { + ...ClientDecoratorOptions; +} + +extern dec experimental(target: unknown, options?: valueof FeatureLifecycleOptions); diff --git a/packages/http-client/lib/main.tsp b/packages/http-client/lib/main.tsp new file mode 100644 index 00000000000..b13f25fa234 --- /dev/null +++ b/packages/http-client/lib/main.tsp @@ -0,0 +1,4 @@ +import "./decorators.tsp"; +import "../dist/src/tsp-index.js"; + +namespace TypeSpec.HttpClient; diff --git a/packages/http-client/package.json b/packages/http-client/package.json index 9e87e0efb50..93763ba4ea9 100644 --- a/packages/http-client/package.json +++ b/packages/http-client/package.json @@ -4,12 +4,16 @@ "type": "module", "main": "dist/src/index.js", "license": "MIT", + "tspMain": "./lib/main.tsp", "exports": { ".": { - "import": "./dist/src/index.js" + "typespec": "./lib/main.tsp", + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" }, "./testing": { - "import": "./dist/src/testing/index.js" + "types": "./dist/src/testing/index.d.ts", + "default": "./dist/src/testing/index.js" }, "./typekit": { "import": "./dist/src/typekit/index.js" @@ -31,17 +35,21 @@ "@alloy-js/rollup-plugin": "^0.1.0", "@alloy-js/typescript": "^0.19.0", "@types/node": "~24.1.0", + "@typespec/tspd": "workspace:^", "@typespec/compiler": "workspace:^", "@typespec/emitter-framework": "workspace:^", "@typespec/http": "workspace:^", + "@typespec/library-linter": "workspace:^", "eslint": "^9.23.0", "prettier": "~3.6.2", "typescript": "~5.8.2", "vitest": "^3.1.2" }, "scripts": { - "build": "alloy build", + "build": "npm run gen-extern-signature && alloy build && npm run lint-typespec-library", + "lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit", "clean": "rimraf ./dist", + "gen-extern-signature": "tspd --enable-experimental gen-extern-signature .", "watch": "alloy build --watch", "test": "vitest run", "test:ui": "vitest --ui", diff --git a/packages/http-client/src/decorators/feature-lifecycle.ts b/packages/http-client/src/decorators/feature-lifecycle.ts new file mode 100644 index 00000000000..a7e85c4e8e8 --- /dev/null +++ b/packages/http-client/src/decorators/feature-lifecycle.ts @@ -0,0 +1,84 @@ +import { createDiagnosticCollector, DiagnosticResult, Program, Type } from "@typespec/compiler"; +import { useStateMap } from "@typespec/compiler/utils"; +import { ExperimentalDecorator } from "../../generated-defs/TypeSpec.HttpClient.js"; +import { createStateSymbol } from "../lib.js"; +import { parseScopeFilter, ScopedValue } from "./scope-cache.js"; + +const featureLifecycleStateSymbol = createStateSymbol("featureLifecycleState"); + +export type FeatureLifecycleStage = "Experimental"; + +const [getFeatureLifecycleState, setFeatureLifecycleState] = useStateMap< + Type, + ScopedValue +>(featureLifecycleStateSymbol); + +export const $experimental: ExperimentalDecorator = (context, target, options) => { + const scopeFilter = parseScopeFilter(options?.emitterScope); + if (scopeFilter.excludedEmitters.length > 0 && scopeFilter.includedEmitters.length > 0) { + context.program.reportDiagnostic({ + code: "include-and-exclude-scopes", + message: "The @experimental should only either include or exclude scopes, not both.", + severity: "error", + target, + }); + } + setFeatureLifecycleState(context.program, target, { + emitterFilter: scopeFilter, + value: "Experimental", + }); +}; + +export interface GetFeatureLifecycleOptions { + emitterName?: string; +} +export function getClientFeatureLifecycle( + program: Program, + target: Type, + options: GetFeatureLifecycleOptions = {}, +): DiagnosticResult { + const diagnostics = createDiagnosticCollector(); + + const lifecycle = getFeatureLifecycleState(program, target); + + if (!lifecycle) { + return diagnostics.wrap(undefined); + } + + const emitterScope = options.emitterName; + const lifecycleValue = lifecycle.value; + + if (!lifecycle.emitterFilter.isScoped) { + return diagnostics.wrap(lifecycleValue); + } + + // Lifecycle is scoped but no emitter scope is provided to the query function so we return undefined + // this is because we can't determine which emitter the lifecycle is associated with. + if (!emitterScope) { + diagnostics.add({ + code: "use-client-context-without-provider", + message: "No emitter scope provided to getClientFeatureLifecycle.", + target, + severity: "warning", + }); + return diagnostics.wrap(undefined); + } + + if (lifecycle.emitterFilter.includedEmitters.length) { + const value = lifecycle.emitterFilter.includedEmitters.includes(emitterScope) + ? lifecycleValue + : undefined; + + return diagnostics.wrap(value); + } + + if (lifecycle.emitterFilter.excludedEmitters.length) { + const value = lifecycle.emitterFilter.excludedEmitters.includes(emitterScope) + ? undefined + : lifecycleValue; + + return diagnostics.wrap(value); + } + + return diagnostics.wrap(undefined); +} diff --git a/packages/http-client/src/decorators/index.ts b/packages/http-client/src/decorators/index.ts new file mode 100644 index 00000000000..68ce9337d3e --- /dev/null +++ b/packages/http-client/src/decorators/index.ts @@ -0,0 +1 @@ +export * from "./feature-lifecycle.js"; diff --git a/packages/http-client/src/decorators/scope-cache.ts b/packages/http-client/src/decorators/scope-cache.ts new file mode 100644 index 00000000000..48c4e97c1b9 --- /dev/null +++ b/packages/http-client/src/decorators/scope-cache.ts @@ -0,0 +1,39 @@ +export interface EmitterFilter { + includedEmitters: string[]; + excludedEmitters: string[]; + isScoped: boolean; +} + +export interface ScopedValue { + emitterFilter: EmitterFilter; + value: T; +} + +export function parseScopeFilter(string: string | undefined): EmitterFilter { + if (!string) { + return { + excludedEmitters: [], + includedEmitters: [], + isScoped: false, + }; + } + + const parts = string.split(","); + const includedEmitters: string[] = []; + const excludedEmitters: string[] = []; + + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed.startsWith("!")) { + excludedEmitters.push(trimmed.substring(1)); + } else { + includedEmitters.push(trimmed); + } + } + + return { + excludedEmitters, + includedEmitters, + isScoped: true, + }; +} diff --git a/packages/http-client/src/index.ts b/packages/http-client/src/index.ts index e15a10b7fef..d8c0f943aaa 100644 --- a/packages/http-client/src/index.ts +++ b/packages/http-client/src/index.ts @@ -1,4 +1,6 @@ +export const namespace = "TypeSpec.HttpClient"; export * from "./context/index.js"; export type * from "./interfaces.js"; export { $lib } from "./lib.js"; +export { $decorators } from "./tsp-index.js"; export * from "./utils/index.js"; diff --git a/packages/http-client/src/lib.ts b/packages/http-client/src/lib.ts index f683687f0df..797b2f56e7e 100644 --- a/packages/http-client/src/lib.ts +++ b/packages/http-client/src/lib.ts @@ -39,4 +39,4 @@ export const $lib = createTypeSpecLibrary({ }, }); -export const { reportDiagnostic, createDiagnostic, stateKeys: StateKeys } = $lib; +export const { reportDiagnostic, createDiagnostic, stateKeys: StateKeys, createStateSymbol } = $lib; diff --git a/packages/http-client/src/testing/index.ts b/packages/http-client/src/testing/index.ts index a21748a39c8..405edb9f07e 100644 --- a/packages/http-client/src/testing/index.ts +++ b/packages/http-client/src/testing/index.ts @@ -1,8 +1,10 @@ -import { resolvePath } from "@typespec/compiler"; -import { createTestLibrary, TypeSpecTestLibrary } from "@typespec/compiler/testing"; -import { fileURLToPath } from "url"; +import { + TypeSpecTestLibrary, + createTestLibrary, + findTestPackageRoot, +} from "@typespec/compiler/testing"; -export const TypespecHttpClientLibraryTestLibrary: TypeSpecTestLibrary = createTestLibrary({ - name: "@typespec/http-client-library", - packageRoot: resolvePath(fileURLToPath(import.meta.url), "../../../../"), +export const HttpClientTestLibrary: TypeSpecTestLibrary = createTestLibrary({ + name: "@typespec/http-client", + packageRoot: await findTestPackageRoot(import.meta.url), }); diff --git a/packages/http-client/src/tsp-index.ts b/packages/http-client/src/tsp-index.ts new file mode 100644 index 00000000000..882a9d3ddbb --- /dev/null +++ b/packages/http-client/src/tsp-index.ts @@ -0,0 +1,9 @@ +import { TypeSpecHttpClientDecorators } from "../generated-defs/TypeSpec.HttpClient.js"; + +import { $experimental } from "./decorators/index.js"; + +export const $decorators = { + "TypeSpec.HttpClient": { + experimental: $experimental, + } satisfies TypeSpecHttpClientDecorators, +}; diff --git a/packages/http-client/src/typekit/kits/client.ts b/packages/http-client/src/typekit/kits/client.ts index 8d2842a27e7..4969f274288 100644 --- a/packages/http-client/src/typekit/kits/client.ts +++ b/packages/http-client/src/typekit/kits/client.ts @@ -6,8 +6,9 @@ import { Namespace, NoTarget, Operation, + Type, } from "@typespec/compiler"; -import { defineKit } from "@typespec/compiler/typekit"; +import { createDiagnosable, defineKit, Diagnosable } from "@typespec/compiler/typekit"; import { getHttpService, getServers, @@ -17,6 +18,10 @@ import { resolveAuthentication, } from "@typespec/http"; import "@typespec/http/experimental/typekit"; +import { + getClientFeatureLifecycle, + GetFeatureLifecycleOptions, +} from "../../decorators/feature-lifecycle.js"; import { InternalClient } from "../../interfaces.js"; import { reportDiagnostic } from "../../lib.js"; import { createBaseConstructor, getConstructors } from "../../utils/client-helpers.js"; @@ -24,6 +29,14 @@ import { getStringValue } from "../../utils/helpers.js"; import { NameKit } from "./utils.js"; interface ClientKit extends NameKit { + /** + * Get the feature lifecycle value for a given type + * @param type The type to get the feature lifecycle for + * @param options The options to use when getting the feature lifecycle + */ + getFeatureLifecycle: Diagnosable< + (type: Type, options?: GetFeatureLifecycleOptions) => string | undefined + >; /** * Get the parent of a client * @param type The client to get the parent of @@ -98,6 +111,9 @@ export const clientOperationCache = new Map(); defineKit({ client: { + getFeatureLifecycle: createDiagnosable(function (type, options) { + return getClientFeatureLifecycle(this.program, type, options); + }), getParent(client) { const type = client.kind === "Client" ? client.type : client; if (type.namespace && type.namespace !== this.program.getGlobalNamespaceType()) { diff --git a/packages/http-client/src/types/typespec-augmentations.d.ts b/packages/http-client/src/types/typespec-augmentations.d.ts index 33bf69f284d..a948d9c9928 100644 --- a/packages/http-client/src/types/typespec-augmentations.d.ts +++ b/packages/http-client/src/types/typespec-augmentations.d.ts @@ -1,5 +1,5 @@ import { HttpAuth } from "@typespec/http"; -import { authSchemeSymbol, credentialSymbol } from "./credential-symbol.ts"; +import { authSchemeSymbol, credentialSymbol } from "./credential-symbol.js"; declare module "@typespec/compiler" { interface ModelProperty { diff --git a/packages/http-client/test/feature-lifecycle.test.ts b/packages/http-client/test/feature-lifecycle.test.ts new file mode 100644 index 00000000000..a613d2529f3 --- /dev/null +++ b/packages/http-client/test/feature-lifecycle.test.ts @@ -0,0 +1,154 @@ +import { expectDiagnostics, t } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { expect, it } from "vitest"; +import "../src/typekit/index.js"; +import { Tester } from "./test-host.js"; + +it("should get the feature lifecycle for a model property", async () => { + const { betaProp, program } = await Tester.compile(t.code` + namespace Test; + + model MyModel { + id: string; + @experimental + ${t.modelProperty("betaProp")}: string; + } + `); + + const featureLifecycle = $(program).client.getFeatureLifecycle(betaProp); + expect(featureLifecycle).toBe("Experimental"); +}); + +it("should get the feature lifecycle for a model property within scope", async () => { + const { betaProp, program } = await Tester.compile(t.code` + namespace Test; + + model MyModel { + id: string; + @experimental(#{emitterScope: "myEmitter"}) + ${t.modelProperty("betaProp")}: string; + } + `); + + const featureLifecycle = $(program).client.getFeatureLifecycle(betaProp, { + emitterName: "myEmitter", + }); + expect(featureLifecycle).toBe("Experimental"); +}); + +it("should get the feature lifecycle for a model property within scope (multiple scopes)", async () => { + const { betaProp, program } = await Tester.compile(t.code` + namespace Test; + + model MyModel { + id: string; + @experimental(#{emitterScope: "myEmitter, otherEmitter"}) + ${t.modelProperty("betaProp")}: string; + } + `); + + const featureLifecycle = $(program).client.getFeatureLifecycle(betaProp, { + emitterName: "myEmitter", + }); + expect(featureLifecycle).toBe("Experimental"); +}); + +it("should get the feature lifecycle for a model property with scope and unscoped decorator", async () => { + const { betaProp, program } = await Tester.compile(t.code` + namespace Test; + + model MyModel { + id: string; + @experimental + ${t.modelProperty("betaProp")}: string; + } + `); + + const featureLifecycle = $(program).client.getFeatureLifecycle(betaProp, { + emitterName: "myEmitter", + }); + expect(featureLifecycle).toBe("Experimental"); +}); + +it("should not get featureLifecycle when not in scope", async () => { + const { betaProp, program } = await Tester.compile(t.code` + namespace Test; + + model MyModel { + id: string; + @experimental(#{emitterScope: "notMyEmitter"}) + ${t.modelProperty("betaProp")}: string; + } + `); + + const featureLifecycle = $(program).client.getFeatureLifecycle(betaProp, { + emitterName: "myEmitter", + }); + expect(featureLifecycle).toBeUndefined(); +}); + +it("should not get featureLifecycle when no scope passed to query", async () => { + const { betaProp, program } = await Tester.compile(t.code` + namespace Test; + + model MyModel { + id: string; + @experimental(#{emitterScope: "notMyEmitter"}) + ${t.modelProperty("betaProp")}: string; + } + `); + + const featureLifecycle = $(program).client.getFeatureLifecycle(betaProp); + expect(featureLifecycle).toBeUndefined(); +}); + +it("should get featureLifecycle when not in excluded scopes", async () => { + const { betaProp, program } = await Tester.compile(t.code` + namespace Test; + + model MyModel { + id: string; + @experimental(#{emitterScope: "!notMyEmitter"}) + ${t.modelProperty("betaProp")}: string; + } + `); + + const featureLifecycle = $(program).client.getFeatureLifecycle(betaProp, { + emitterName: "myEmitter", + }); + expect(featureLifecycle).toBe("Experimental"); +}); + +it("should not get featureLifecycle when in excluded scopes", async () => { + const { betaProp, program } = await Tester.compile(t.code` + namespace Test; + + model MyModel { + id: string; + @experimental(#{emitterScope: "!myEmitter"}) + ${t.modelProperty("betaProp")}: string; + } + `); + + const featureLifecycle = $(program).client.getFeatureLifecycle(betaProp, { + emitterName: "myEmitter", + }); + expect(featureLifecycle).toBeUndefined(); +}); + +it("should report diagnostics when both include and exclude are set", async () => { + const diagnostics = await Tester.diagnose(` + namespace Test; + + model MyModel { + id: string; + @experimental(#{emitterScope: "!myEmitter,otherEmitter"}) + betaProp: string; + } + `); + + expectDiagnostics(diagnostics, { + code: "include-and-exclude-scopes", + message: "The @experimental should only either include or exclude scopes, not both.", + }); +}); diff --git a/packages/http-client/test/test-host.ts b/packages/http-client/test/test-host.ts index 34916cb1d1b..f92fe56ecde 100644 --- a/packages/http-client/test/test-host.ts +++ b/packages/http-client/test/test-host.ts @@ -1,16 +1,8 @@ -import { createTestHost, createTestWrapper } from "@typespec/compiler/testing"; -import { HttpTestLibrary } from "@typespec/http/testing"; +import { resolvePath } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; -export async function createTypespecHttpClientLibraryTestHost() { - return createTestHost({ - libraries: [HttpTestLibrary], - }); -} - -export async function createTypespecHttpClientLibraryTestRunner() { - const host = await createTypespecHttpClientLibraryTestHost(); - - return createTestWrapper(host, { - autoUsings: ["TypeSpec.Http"], - }); -} +export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/http", "@typespec/http-client"], +}) + .importLibraries() + .using("HttpClient", "Http"); diff --git a/packages/http-client/test/typekit/client-library.test.ts b/packages/http-client/test/typekit/client-library.test.ts index ab3ee2ff0d8..6ea0a3622de 100644 --- a/packages/http-client/test/typekit/client-library.test.ts +++ b/packages/http-client/test/typekit/client-library.test.ts @@ -1,25 +1,18 @@ -import type { Namespace } from "@typespec/compiler"; -import type { 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/typekit/index.js"; -import { createTypespecHttpClientLibraryTestRunner } from "../test-host.js"; - -let runner: BasicTestRunner; - -beforeEach(async () => { - runner = await createTypespecHttpClientLibraryTestRunner(); -}); +import { Tester } from "../test-host.js"; describe("listNamespaces", () => { it("basic", async () => { - await runner.compile(` + const { program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) namespace DemoService; `); - const tk = $(runner.program); + const tk = $(program); expect(tk.clientLibrary.listNamespaces()).toHaveLength(1); expect(tk.clientLibrary.listNamespaces()[0].name).toEqual("DemoService"); @@ -27,7 +20,7 @@ describe("listNamespaces", () => { it("nested", async () => { // we only want to return the top level namespaces - await runner.compile(` + const { program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) @@ -38,7 +31,7 @@ describe("listNamespaces", () => { } } `); - const tk = $(runner.program); + const tk = $(program); expect(tk.clientLibrary.listNamespaces()).toHaveLength(1); expect(tk.clientLibrary.listNamespaces()[0].name).toEqual("DemoService"); @@ -55,10 +48,10 @@ describe("listNamespaces", () => { describe("listClients", () => { it("should only get clients for defined namespaces in the spec", async () => { - await runner.compile(` + const { program } = await Tester.compile(t.code` op foo(): void; `); - const tk = $(runner.program); + const tk = $(program); const namespace = tk.program.getGlobalNamespaceType(); const client = tk.client.getClient(namespace); @@ -69,28 +62,28 @@ describe("listClients", () => { }); it("should get the client", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const responses = tk.clientLibrary.listClients(DemoService); expect(responses).toHaveLength(1); expect(responses[0].name).toEqual("DemoServiceClient"); }); it("get subclients", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService { - namespace NestedService {}; + namespace ${t.namespace("DemoService")} { + namespace ${t.namespace("NestedService")} {}; } - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + `); + const tk = $(program); const responses = tk.clientLibrary.listClients(DemoService); expect(responses).toHaveLength(1); diff --git a/packages/http-client/test/typekit/client.test.ts b/packages/http-client/test/typekit/client.test.ts index b46736924b7..3cfa862758d 100644 --- a/packages/http-client/test/typekit/client.test.ts +++ b/packages/http-client/test/typekit/client.test.ts @@ -1,26 +1,20 @@ -import { Interface, Namespace, StringLiteral, StringValue, Union } from "@typespec/compiler"; -import type { BasicTestRunner } from "@typespec/compiler/testing"; +import { StringLiteral, StringValue, Union } from "@typespec/compiler"; +import { t } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { ok } from "assert"; -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import "../../src/typekit/index.js"; -import { createTypespecHttpClientLibraryTestRunner } from "../test-host.js"; - -let runner: BasicTestRunner; - -beforeEach(async () => { - runner = await createTypespecHttpClientLibraryTestRunner(); -}); +import { Tester } from "../test-host.js"; describe("isSameConstructor", () => { it("should return true for the same client", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.client.getClient(DemoService); @@ -28,17 +22,17 @@ describe("isSameConstructor", () => { }); it("should return false for the clients with different constructors", async () => { - const { DemoService, SubClient } = (await runner.compile(` + const { DemoService, SubClient, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService { + namespace ${t.namespace("DemoService")} { @useAuth(ApiKeyAuth) - @test namespace SubClient { + namespace ${t.namespace("SubClient")} { } } - `)) as { DemoService: Namespace; SubClient: Namespace }; - const tk = $(runner.program); + `); + const tk = $(program); const client = tk.client.getClient(DemoService); const subClient = tk.client.getClient(SubClient); @@ -47,17 +41,17 @@ describe("isSameConstructor", () => { }); it.skip("should return true when subclient doesn't override the client params", async () => { - const { DemoService, SubClient } = (await runner.compile(` + const { DemoService, SubClient, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) @useAuth(ApiKeyAuth) - @test namespace DemoService { - @test namespace SubClient { + namespace ${t.namespace("DemoService")} { + namespace ${t.namespace("SubClient")} { } } - `)) as { DemoService: Namespace; SubClient: Namespace }; - const tk = $(runner.program); + `); + const tk = $(program); const demoClient = tk.client.getClient(DemoService); const subClient = tk.client.getClient(SubClient); @@ -66,16 +60,16 @@ describe("isSameConstructor", () => { }); it("should return false for the clients with different constructor", async () => { - const { DemoService, SubClient } = (await runner.compile(` + const { DemoService, SubClient, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService { - @test namespace SubClient { + namespace ${t.namespace("DemoService")} { + @test namespace ${t.namespace("SubClient")} { } } - `)) as { DemoService: Namespace; SubClient: Namespace }; - const tk = $(runner.program); + `); + const tk = $(program); const client = tk.client.getClient(DemoService); const subClient = tk.client.getClient(SubClient); @@ -86,10 +80,10 @@ describe("isSameConstructor", () => { describe("getClient", () => { it("should get a client from the globalNamespace", async () => { - (await runner.compile(` + const { program } = await Tester.compile(t.code` op foo(): void; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + `); + const tk = $(program); const namespace = tk.program.getGlobalNamespaceType(); const client = tk.client.getClient(namespace); @@ -98,13 +92,13 @@ describe("getClient", () => { }); it("should get the client", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.client.getClient(DemoService); @@ -114,13 +108,13 @@ describe("getClient", () => { }); it("should preserve client object identity", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client1 = tk.client.getClient(DemoService); const client2 = tk.client.getClient(DemoService); @@ -128,21 +122,21 @@ describe("getClient", () => { }); it("should get a flattened list of clients", async () => { - const { DemoService, BarBaz } = (await runner.compile(` + const { DemoService, BarBaz, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService { - @test namespace Foo { - @test namespace FooBaz {} + namespace ${t.namespace("DemoService")} { + namespace Foo { + namespace FooBaz {} } @test namespace Bar { - @test interface BarBaz {} + interface ${t.interface("BarBaz")} {} } } - `)) as { DemoService: Namespace; BarBaz: Interface }; - const tk = $(runner.program); + `); + const tk = $(program); const client = tk.client.getClient(DemoService); const flatClients = tk.client.flat(client); @@ -156,13 +150,13 @@ describe("getClient", () => { describe("getConstructor", () => { describe("credential parameter", () => { it("none", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const constructor = tk.client.getConstructor(client); @@ -174,14 +168,14 @@ describe("getConstructor", () => { expect(tk.scalar.isString(params[0].type)).toBeTruthy(); }); it("apikey", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) @useAuth(ApiKeyAuth) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const constructor = tk.client.getConstructor(client); @@ -204,7 +198,7 @@ describe("getConstructor", () => { * - A single constructor with an endpoint parameter that is required * - A single constructor with a credential parameter that is required. */ - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) @@ -213,9 +207,9 @@ describe("getConstructor", () => { authorizationUrl: "https://login.microsoftonline.com/common/oauth2/authorize"; scopes: ["https://security.microsoft.com/.default"]; }]>) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const constructor = tk.client.getConstructor(client); @@ -233,13 +227,13 @@ describe("getConstructor", () => { }); describe("endpoint", () => { it("no servers", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const constructor = tk.client.getConstructor(client); @@ -251,14 +245,14 @@ describe("getConstructor", () => { expect(tk.scalar.isString(params[0].type)).toBeTruthy(); }); it("one server, no params", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @server("https://example.com", "The service endpoint") @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const constructor = tk.client.getConstructor(client); @@ -278,14 +272,14 @@ describe("getConstructor", () => { * - The endpoint default value is the url template https://example.com/{name}/foo * - There is a required name parameter of type string */ - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @server("https://example.com/{name}/foo", "My service url", { name: string }) @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const constructor = tk.client.getConstructor(client); @@ -321,14 +315,14 @@ describe("getConstructor", () => { * - A constructor parameter named endpoint which maps to the template variable that is required but has a default value * - A constructor parameter named _endpoint (due to collission) which has a default value which is the url template https://{endpoint}/foo */ - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @server("https://{endpoint}/foo", "My service url", { endpoint: string }) @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const constructor = tk.client.getConstructor(client); @@ -362,15 +356,15 @@ describe("getConstructor", () => { * - The endpoint parameter has a type of union including the 2 clientDefaultValues plus string. */ - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @server("https://example.com", "The service endpoint") @server("https://example.org", "The service endpoint") @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; // There is a single constructor so no overloads. @@ -399,13 +393,13 @@ describe("getConstructor", () => { describe("isPubliclyInitializable", () => { it("namespace", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const responses = tk.clientLibrary.listClients(DemoService); expect(responses).toHaveLength(1); @@ -413,15 +407,15 @@ describe("isPubliclyInitializable", () => { expect(tk.client.isPubliclyInitializable(responses[0])).toBeTruthy(); }); it("nested namespace", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService { + namespace ${t.namespace("DemoService")} { namespace NestedService {}; } - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + `); + const tk = $(program); const responses = tk.clientLibrary.listClients(DemoService); expect(responses).toHaveLength(1); @@ -434,15 +428,15 @@ describe("isPubliclyInitializable", () => { expect(tk.client.isPubliclyInitializable(subclients[0])).toBeTruthy(); }); it("nested interface", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService { + namespace ${t.namespace("DemoService")} { interface NestedInterface {}; } - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + `); + const tk = $(program); const responses = tk.clientLibrary.listClients(DemoService); expect(responses).toHaveLength(1); @@ -458,10 +452,10 @@ describe("isPubliclyInitializable", () => { describe("listServiceOperations", () => { it("should list only operations defined in the spec", async () => { - await runner.compile(` + const { program } = await Tester.compile(t.code` op foo(): void; `); - const tk = $(runner.program); + const tk = $(program); const namespace = tk.program.getGlobalNamespaceType(); const client = tk.client.getClient(namespace); @@ -473,32 +467,32 @@ describe("listServiceOperations", () => { }); it("no operations", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const operations = tk.client.listHttpOperations(client); expect(operations).toHaveLength(0); }); it("nested namespace", async () => { - const { DemoService, NestedService } = (await runner.compile(` + const { DemoService, NestedService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService { + namespace ${t.namespace("DemoService")} { @route("demo") op demoServiceOp(): void; - @test namespace NestedService { + namespace ${t.namespace("NestedService")} { @route("nested") op nestedServiceOp(): void; }; } - `)) as { DemoService: Namespace; NestedService: Namespace }; - const tk = $(runner.program); + `); + const tk = $(program); const demoServiceClient = tk.clientLibrary.listClients(DemoService)[0]; expect(tk.client.listHttpOperations(demoServiceClient)).toHaveLength(1); diff --git a/packages/http-client/test/typekit/model-property.test.ts b/packages/http-client/test/typekit/model-property.test.ts index 0237bb07827..5fa4824cc93 100644 --- a/packages/http-client/test/typekit/model-property.test.ts +++ b/packages/http-client/test/typekit/model-property.test.ts @@ -1,27 +1,21 @@ -import { Namespace, StringValue } from "@typespec/compiler"; -import type { BasicTestRunner } from "@typespec/compiler/testing"; +import { StringValue } from "@typespec/compiler"; +import { t } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { ok } from "assert"; -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import "../../src/typekit/index.js"; -import { createTypespecHttpClientLibraryTestRunner } from "../test-host.js"; - -let runner: BasicTestRunner; - -beforeEach(async () => { - runner = await createTypespecHttpClientLibraryTestRunner(); -}); +import { Tester } from "../test-host.js"; describe("getCredentialAuth", () => { it("should return the correct http scheme", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) @useAuth(ApiKeyAuth) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.client.getClient(DemoService); const constructor = tk.client.getConstructor(client); @@ -37,7 +31,7 @@ describe("getCredentialAuth", () => { }); it("should return the correct http schemes", async () => { - const { DemoService } = (await runner.compile(` + const { program, DemoService } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) @@ -46,9 +40,9 @@ describe("getCredentialAuth", () => { authorizationUrl: "https://login.microsoftonline.com/common/oauth2/authorize"; scopes: ["https://security.microsoft.com/.default"]; }]>) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.client.getClient(DemoService); const constructor = tk.client.getConstructor(client); @@ -68,13 +62,13 @@ describe("getCredentialAuth", () => { describe("isOnClient", () => { describe("endpoint", () => { it("no servers", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const constructor = tk.client.getConstructor(client); @@ -84,14 +78,14 @@ describe("isOnClient", () => { expect(tk.modelProperty.isOnClient(client, params[0])).toBe(true); }); it("one server, no params", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @server("https://example.com", "The service endpoint") @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const constructor = tk.client.getConstructor(client); @@ -101,14 +95,14 @@ describe("isOnClient", () => { expect(tk.modelProperty.isOnClient(client, params[0])).toBe(true); }); it("one server with parameter", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @server("https://example.com/{name}/foo", "My service url", { name: string }) @service(#{ title: "Widget Service", }) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const constructor = tk.client.getConstructor(client); @@ -132,14 +126,14 @@ describe("isOnClient", () => { }); describe("credential", () => { it("apikey", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) @useAuth(ApiKeyAuth) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.client.getClient(DemoService); const constructor = tk.client.getConstructor(client); @@ -151,7 +145,7 @@ describe("isOnClient", () => { expect(tk.modelProperty.isOnClient(client, credential)).toBe(true); }); it("bearer", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) @@ -160,9 +154,9 @@ describe("isOnClient", () => { authorizationUrl: "https://login.microsoftonline.com/common/oauth2/authorize"; scopes: ["https://security.microsoft.com/.default"]; }]>) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const constructor = tk.client.getConstructor(client); @@ -179,14 +173,14 @@ describe("isOnClient", () => { describe("isCredential", () => { it("apikey", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) @useAuth(ApiKeyAuth) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const constructor = tk.client.getConstructor(client); @@ -198,7 +192,7 @@ describe("isCredential", () => { expect(tk.modelProperty.isCredential(credential)).toBe(true); }); it("bearer", async () => { - const { DemoService } = (await runner.compile(` + const { DemoService, program } = await Tester.compile(t.code` @service(#{ title: "Widget Service", }) @@ -207,9 +201,9 @@ describe("isCredential", () => { authorizationUrl: "https://login.microsoftonline.com/common/oauth2/authorize"; scopes: ["https://security.microsoft.com/.default"]; }]>) - @test namespace DemoService; - `)) as { DemoService: Namespace }; - const tk = $(runner.program); + namespace ${t.namespace("DemoService")}; + `); + const tk = $(program); const client = tk.clientLibrary.listClients(DemoService)[0]; const constructor = tk.client.getConstructor(client); diff --git a/packages/http-client/vitest.config.ts b/packages/http-client/vitest.config.ts index 0221876ef0e..16a593472d0 100644 --- a/packages/http-client/vitest.config.ts +++ b/packages/http-client/vitest.config.ts @@ -8,6 +8,7 @@ export default mergeConfig( test: { include: ["test/**/*.test.ts"], passWithNoTests: true, + testTimeout: 10_000, }, esbuild: { jsx: "preserve", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afd177adfe3..32f37b689ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -680,6 +680,12 @@ importers: '@typespec/http': specifier: workspace:^ version: link:../http + '@typespec/library-linter': + specifier: workspace:^ + version: link:../library-linter + '@typespec/tspd': + specifier: workspace:^ + version: link:../tspd eslint: specifier: ^9.23.0 version: 9.32.0