From faabf5ca251137819ad1eb01e21986c4a90fba1f Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 12 Apr 2026 17:35:22 -0400 Subject: [PATCH 1/5] Add simple type components (Enum, Scalar, Union) with component tests Add the first set of Alloy JSX components for GraphQL type emission: - EnumType: renders GraphQL enum definitions with member descriptions and deprecation - ScalarType: renders custom scalar definitions with @specifiedBy support - UnionType: renders union type definitions with model and scalar variant members - GraphQLSchema: root context wrapper providing TspContext and GraphQLSchemaContext Add component-level tests using renderSchema + printSchema to exercise the real Alloy rendering pipeline. Each component is tested in isolation with a lightweight test helper (renderComponentToSDL) that provides minimal context. 14 new tests covering: basic rendering, doc comments, deprecation, name sanitization, @specifiedBy, union member registration, and scalar wrappers. --- .../graphql/src/components/graphql-schema.tsx | 32 ++++ .../src/components/types/enum-type.tsx | 34 ++++ .../graphql/src/components/types/index.ts | 3 + .../src/components/types/scalar-type.tsx | 27 ++++ .../src/components/types/union-type.tsx | 57 +++++++ packages/graphql/src/types.d.ts | 23 +-- .../test/components/component-test-utils.tsx | 50 ++++++ .../test/components/enum-type.test.tsx | 107 +++++++++++++ .../test/components/scalar-type.test.tsx | 102 ++++++++++++ .../test/components/union-type.test.tsx | 145 ++++++++++++++++++ 10 files changed, 560 insertions(+), 20 deletions(-) create mode 100644 packages/graphql/src/components/graphql-schema.tsx create mode 100644 packages/graphql/src/components/types/enum-type.tsx create mode 100644 packages/graphql/src/components/types/index.ts create mode 100644 packages/graphql/src/components/types/scalar-type.tsx create mode 100644 packages/graphql/src/components/types/union-type.tsx create mode 100644 packages/graphql/test/components/component-test-utils.tsx create mode 100644 packages/graphql/test/components/enum-type.test.tsx create mode 100644 packages/graphql/test/components/scalar-type.test.tsx create mode 100644 packages/graphql/test/components/union-type.test.tsx diff --git a/packages/graphql/src/components/graphql-schema.tsx b/packages/graphql/src/components/graphql-schema.tsx new file mode 100644 index 00000000000..94b78461316 --- /dev/null +++ b/packages/graphql/src/components/graphql-schema.tsx @@ -0,0 +1,32 @@ +import { type Children } from "@alloy-js/core"; +import type { Program } from "@typespec/compiler"; +import { TspContext } from "@typespec/emitter-framework"; +import { + GraphQLSchemaContext, + type GraphQLSchemaContextValue, +} from "../context/index.js"; + +export interface GraphQLSchemaProps { + /** TypeSpec program instance */ + program: Program; + /** Context value containing classified types and type maps */ + contextValue: GraphQLSchemaContextValue; + /** Child components to render */ + children?: Children; +} + +/** + * Root component for GraphQL schema generation + * + * Provides TspContext (program + typekit) from @typespec/emitter-framework + * and GraphQL-specific context to all child components. + */ +export function GraphQLSchema(props: GraphQLSchemaProps) { + return ( + + + {props.children} + + + ); +} diff --git a/packages/graphql/src/components/types/enum-type.tsx b/packages/graphql/src/components/types/enum-type.tsx new file mode 100644 index 00000000000..68a19ea1d60 --- /dev/null +++ b/packages/graphql/src/components/types/enum-type.tsx @@ -0,0 +1,34 @@ +import { type Enum, getDoc, getDeprecationDetails } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; + +export interface EnumTypeProps { + /** The enum type to render */ + type: Enum; +} + +/** + * Renders a GraphQL enum type declaration with members + */ +export function EnumType(props: EnumTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + const members = Array.from(props.type.members.values()); + + return ( + + {members.map((member) => { + const memberDoc = getDoc(program, member); + const deprecation = getDeprecationDetails(program, member); + + return ( + + ); + })} + + ); +} diff --git a/packages/graphql/src/components/types/index.ts b/packages/graphql/src/components/types/index.ts new file mode 100644 index 00000000000..f0c8f56e711 --- /dev/null +++ b/packages/graphql/src/components/types/index.ts @@ -0,0 +1,3 @@ +export { ScalarType, type ScalarTypeProps } from "./scalar-type.js"; +export { EnumType, type EnumTypeProps } from "./enum-type.js"; +export { UnionType, type UnionTypeProps } from "./union-type.js"; diff --git a/packages/graphql/src/components/types/scalar-type.tsx b/packages/graphql/src/components/types/scalar-type.tsx new file mode 100644 index 00000000000..8a8926e8e94 --- /dev/null +++ b/packages/graphql/src/components/types/scalar-type.tsx @@ -0,0 +1,27 @@ +import { type Scalar, getDoc } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { useGraphQLSchema } from "../../context/index.js"; + +export interface ScalarTypeProps { + /** The scalar type to render */ + type: Scalar; +} + +/** + * Renders a GraphQL scalar type declaration with optional @specifiedBy directive + */ +export function ScalarType(props: ScalarTypeProps) { + const { program } = useTsp(); + const { scalarSpecifications } = useGraphQLSchema(); + const doc = getDoc(program, props.type); + const specificationUrl = scalarSpecifications.get(props.type.name); + + return ( + + ); +} diff --git a/packages/graphql/src/components/types/union-type.tsx b/packages/graphql/src/components/types/union-type.tsx new file mode 100644 index 00000000000..07b8f683725 --- /dev/null +++ b/packages/graphql/src/components/types/union-type.tsx @@ -0,0 +1,57 @@ +import { type Type, type Union, getDoc } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { getUnionName, toTypeName } from "../../lib/type-utils.js"; + +export interface UnionTypeProps { + /** The union type to render */ + type: Union; +} + +/** + * Check if a type is a scalar (built-in or custom) + */ +function isScalarType(type: Type): boolean { + return type.kind === "Scalar" || type.kind === "Intrinsic"; +} + +/** + * Renders a GraphQL union type declaration + * Scalars are wrapped in object types since GraphQL unions can only contain object types + * This wrapping is done by the mutation engine + */ +export function UnionType(props: UnionTypeProps) { + const { program } = useTsp(); + const name = getUnionName(props.type, program); + const doc = getDoc(program, props.type); + const variants = Array.from(props.type.variants.values()); + + // Build the union member list, using wrapper names for scalars + // The wrapper models are created by the mutation engine + const unionMembers = variants.map((variant) => { + const variantName = + typeof variant.name === "string" ? variant.name : String(variant.name); + + if (isScalarType(variant.type)) { + // Reference the wrapper type for scalars (created by mutation engine) + // Include union name to match wrapper model naming convention + return toTypeName(name) + toTypeName(variantName) + "UnionVariant"; + } else { + // For non-scalars, use the type name directly + if (variant.type.kind === "Model") { + return variant.type.name; + } else if ( + "name" in variant.type && + typeof variant.type.name === "string" + ) { + return variant.type.name; + } + throw new Error( + `Unexpected union variant type kind "${variant.type.kind}" in union "${name}". ` + + `This is a bug in the GraphQL emitter.`, + ); + } + }); + + return ; +} diff --git a/packages/graphql/src/types.d.ts b/packages/graphql/src/types.d.ts index 651cc1bb81f..d8115c70622 100644 --- a/packages/graphql/src/types.d.ts +++ b/packages/graphql/src/types.d.ts @@ -1,22 +1,5 @@ -import type { Diagnostic } from "@typespec/compiler"; -import type { GraphQLSchema } from "graphql"; -import type { Schema } from "./lib/schema.ts"; - -/** - * A record containing the GraphQL schema corresponding to - * a particular schema definition. - */ -export interface GraphQLSchemaRecord { - /** The declared schema that generated this GraphQL schema */ - readonly schema: Schema; - - /** The GraphQLSchema */ - readonly graphQLSchema: GraphQLSchema; - - /** The diagnostics created for this schema */ - readonly diagnostics: readonly Diagnostic[]; -} - declare const tags: unique symbol; -type Tagged = BaseType & { [tags]: { [K in Tag]: void } }; +export type Tagged = BaseType & { + [tags]: { [K in Tag]: void }; +}; diff --git a/packages/graphql/test/components/component-test-utils.tsx b/packages/graphql/test/components/component-test-utils.tsx new file mode 100644 index 00000000000..a70bd980e77 --- /dev/null +++ b/packages/graphql/test/components/component-test-utils.tsx @@ -0,0 +1,50 @@ +import { type Children } from "@alloy-js/core"; +import * as gql from "@alloy-js/graphql"; +import { renderSchema, printSchema } from "@alloy-js/graphql"; +import type { Program } from "@typespec/compiler"; +import { GraphQLSchema } from "../../src/components/graphql-schema.js"; +import type { GraphQLSchemaContextValue } from "../../src/context/index.js"; + +/** + * Renders GraphQL components in isolation and returns the printed SDL. + * + * Wraps children in the required context providers (TspContext + GraphQLSchemaContext) + * and always includes a placeholder Query type (required by graphql-js). + * + * Tests should assert on fragments of the returned SDL, ignoring the placeholder Query. + */ +export function renderComponentToSDL( + program: Program, + children: Children, + contextOverrides?: Partial, +): string { + const contextValue: GraphQLSchemaContextValue = { + classifiedTypes: { + interfaces: [], + outputModels: [], + inputModels: [], + enums: [], + scalars: [], + scalarVariants: [], + unions: [], + queries: [], + mutations: [], + subscriptions: [], + }, + modelVariants: { outputModels: new Map(), inputModels: new Map() }, + scalarSpecifications: new Map(), + ...contextOverrides, + }; + + const schema = renderSchema( + + {children} + + + + , + { namePolicy: null }, + ); + + return printSchema(schema); +} diff --git a/packages/graphql/test/components/enum-type.test.tsx b/packages/graphql/test/components/enum-type.test.tsx new file mode 100644 index 00000000000..418579ac461 --- /dev/null +++ b/packages/graphql/test/components/enum-type.test.tsx @@ -0,0 +1,107 @@ +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it, beforeEach } from "vitest"; +import { EnumType } from "../../src/components/types/index.js"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("EnumType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic enum", async () => { + const { Color } = await tester.compile( + t.code`enum ${t.enum("Color")} { Red, Green, Blue }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Color).mutatedType; + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("enum Color {"); + expect(sdl).toContain("Red"); + expect(sdl).toContain("Green"); + expect(sdl).toContain("Blue"); + }); + + it("renders enum with doc comment description", async () => { + const { Role } = await tester.compile( + t.code` + /** The role a user can have */ + enum ${t.enum("Role")} { Admin, User } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Role).mutatedType; + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("The role a user can have"); + expect(sdl).toContain("enum Role {"); + }); + + it("renders enum with member descriptions", async () => { + const { Status } = await tester.compile( + t.code` + enum ${t.enum("Status")} { + /** Currently active */ + Active, + /** No longer active */ + Inactive, + } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Status).mutatedType; + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("Currently active"); + expect(sdl).toContain("Active"); + expect(sdl).toContain("No longer active"); + expect(sdl).toContain("Inactive"); + }); + + it("renders enum with deprecated members", async () => { + const { Status } = await tester.compile( + t.code` + enum ${t.enum("Status")} { + Active, + #deprecated "use Active instead" + Legacy, + } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Status).mutatedType; + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("Active"); + expect(sdl).toContain("Legacy"); + expect(sdl).toContain("@deprecated"); + expect(sdl).toContain("use Active instead"); + }); + + it("renders enum with sanitized member names", async () => { + const { E } = await tester.compile( + t.code`enum ${t.enum("E")} { \`$val1$\`, \`val-2\` }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(E).mutatedType; + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("_val1_"); + expect(sdl).toContain("val_2"); + expect(sdl).not.toContain("$val1$"); + expect(sdl).not.toContain("val-2"); + }); +}); diff --git a/packages/graphql/test/components/scalar-type.test.tsx b/packages/graphql/test/components/scalar-type.test.tsx new file mode 100644 index 00000000000..14da1e0b36a --- /dev/null +++ b/packages/graphql/test/components/scalar-type.test.tsx @@ -0,0 +1,102 @@ +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it, beforeEach } from "vitest"; +import { ScalarType } from "../../src/components/types/index.js"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { getSpecifiedBy } from "../../src/lib/specified-by.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("ScalarType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a custom scalar", async () => { + const { DateTime } = await tester.compile( + t.code`scalar ${t.scalar("DateTime")} extends string;`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(DateTime); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("scalar DateTime"); + }); + + it("renders a scalar with doc comment description", async () => { + const { JSON } = await tester.compile( + t.code` + /** Arbitrary JSON blob */ + scalar ${t.scalar("JSON")} extends string; + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(JSON); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("Arbitrary JSON blob"); + expect(sdl).toContain("scalar JSON"); + }); + + it("renders a scalar with @specifiedBy from context", async () => { + const { MyScalar } = await tester.compile( + t.code` + @specifiedBy("https://example.com/spec") + scalar ${t.scalar("MyScalar")} extends string; + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(MyScalar); + + // Build scalarSpecifications map like the emitter does + const specUrl = getSpecifiedBy(tester.program, mutation.mutatedType); + const scalarSpecifications = new Map(); + if (specUrl) { + scalarSpecifications.set(mutation.mutatedType.name, specUrl); + } + + const sdl = renderComponentToSDL( + tester.program, + , + { scalarSpecifications }, + ); + + expect(sdl).toContain("scalar MyScalar"); + expect(sdl).toContain("@specifiedBy"); + expect(sdl).toContain("https://example.com/spec"); + }); + + it("renders a scalar without @specifiedBy when not in context", async () => { + const { MyScalar } = await tester.compile( + t.code`scalar ${t.scalar("MyScalar")} extends string;`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(MyScalar); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("scalar MyScalar"); + expect(sdl).not.toContain("@specifiedBy"); + }); + + it("renders a scalar with sanitized name", async () => { + await tester.compile( + t.code`scalar ${t.scalar("$Bad$")} extends string;`, + ); + + const BadScalar = tester.program.getGlobalNamespaceType().scalars.get("$Bad$")!; + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(BadScalar); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("scalar _Bad_"); + expect(sdl).not.toContain("$Bad$"); + }); +}); diff --git a/packages/graphql/test/components/union-type.test.tsx b/packages/graphql/test/components/union-type.test.tsx new file mode 100644 index 00000000000..870f2b93ee5 --- /dev/null +++ b/packages/graphql/test/components/union-type.test.tsx @@ -0,0 +1,145 @@ +import { type Union } from "@typespec/compiler"; +import { t } from "@typespec/compiler/testing"; +import * as gql from "@alloy-js/graphql"; +import { describe, expect, it, beforeEach } from "vitest"; +import { UnionType } from "../../src/components/types/index.js"; +import { + createGraphQLMutationEngine, + GraphQLTypeContext, +} from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("UnionType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a union of model types", async () => { + const { Pet } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + union ${t.union("Pet")} { cat: Cat; dog: Dog; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); + + // Union members must be registered in the schema for buildSchema to resolve them + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toContain("union Pet ="); + expect(sdl).toContain("Cat"); + expect(sdl).toContain("Dog"); + }); + + it("renders a union with doc comment description", async () => { + const { Result } = await tester.compile( + t.code` + model ${t.model("Success")} { value: string; } + model ${t.model("Failure")} { message: string; } + /** The result of an operation */ + union ${t.union("Result")} { success: Success; failure: Failure; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Result, GraphQLTypeContext.Output); + + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toContain("The result of an operation"); + expect(sdl).toContain("union Result ="); + }); + + it("renders a union with multiple model members", async () => { + const { Shape } = await tester.compile( + t.code` + model ${t.model("Circle")} { radius: float32; } + model ${t.model("Square")} { side: float32; } + model ${t.model("Triangle")} { base: float32; } + union ${t.union("Shape")} { circle: Circle; square: Square; triangle: Triangle; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Shape, GraphQLTypeContext.Output); + + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + + + + + + , + ); + + expect(sdl).toContain("union Shape ="); + expect(sdl).toContain("Circle"); + expect(sdl).toContain("Square"); + expect(sdl).toContain("Triangle"); + }); + + it("references wrapper type names for scalar variants", async () => { + const { Mixed } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Mixed")} { cat: Cat; text: string; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output); + + // Register Cat and the wrapper type that the union will reference + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + + + , + ); + + // Scalar variant should reference wrapper type name + expect(sdl).toContain("union Mixed ="); + expect(sdl).toContain("MixedTextUnionVariant"); + expect(sdl).toContain("Cat"); + }); +}); From 0ca300edd30ea81d5158b58d9ad8ac10d7286b96 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 12 Apr 2026 19:44:02 -0400 Subject: [PATCH 2/5] Fix review findings: extract isScalarLikeType, safe union type assertion - Extract isScalarLikeType() to type-utils.ts shared utility, replacing duplicated inline check in union-type.tsx - Replace unsafe `as Union` casts in union-type tests with assertUnionResult() helper that provides a clear error if the mutation returns a Model --- .../src/components/types/union-type.tsx | 13 +++-------- .../test/components/union-type.test.tsx | 23 +++++++++++++++---- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/graphql/src/components/types/union-type.tsx b/packages/graphql/src/components/types/union-type.tsx index 07b8f683725..4e950106d8b 100644 --- a/packages/graphql/src/components/types/union-type.tsx +++ b/packages/graphql/src/components/types/union-type.tsx @@ -1,20 +1,13 @@ -import { type Type, type Union, getDoc } from "@typespec/compiler"; +import { type Union, getDoc } from "@typespec/compiler"; import * as gql from "@alloy-js/graphql"; import { useTsp } from "@typespec/emitter-framework"; -import { getUnionName, toTypeName } from "../../lib/type-utils.js"; +import { getUnionName, isScalarLikeType, toTypeName } from "../../lib/type-utils.js"; export interface UnionTypeProps { /** The union type to render */ type: Union; } -/** - * Check if a type is a scalar (built-in or custom) - */ -function isScalarType(type: Type): boolean { - return type.kind === "Scalar" || type.kind === "Intrinsic"; -} - /** * Renders a GraphQL union type declaration * Scalars are wrapped in object types since GraphQL unions can only contain object types @@ -32,7 +25,7 @@ export function UnionType(props: UnionTypeProps) { const variantName = typeof variant.name === "string" ? variant.name : String(variant.name); - if (isScalarType(variant.type)) { + if (isScalarLikeType(variant.type)) { // Reference the wrapper type for scalars (created by mutation engine) // Include union name to match wrapper model naming convention return toTypeName(name) + toTypeName(variantName) + "UnionVariant"; diff --git a/packages/graphql/test/components/union-type.test.tsx b/packages/graphql/test/components/union-type.test.tsx index 870f2b93ee5..5b19b2e7d0a 100644 --- a/packages/graphql/test/components/union-type.test.tsx +++ b/packages/graphql/test/components/union-type.test.tsx @@ -5,11 +5,22 @@ import { describe, expect, it, beforeEach } from "vitest"; import { UnionType } from "../../src/components/types/index.js"; import { createGraphQLMutationEngine, + type GraphQLUnionMutation, GraphQLTypeContext, } from "../../src/mutation-engine/index.js"; import { Tester } from "../test-host.js"; import { renderComponentToSDL } from "./component-test-utils.js"; +/** Assert that an output-context union mutation produced a Union (not a Model). */ +function assertUnionResult(mutation: GraphQLUnionMutation): Union { + if (mutation.mutatedType.kind !== "Union") { + throw new Error( + `Expected Union from output-context mutation, got ${mutation.mutatedType.kind}`, + ); + } + return mutation.mutatedType; +} + describe("UnionType component", () => { let tester: Awaited>; beforeEach(async () => { @@ -27,6 +38,7 @@ describe("UnionType component", () => { const engine = createGraphQLMutationEngine(tester.program); const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); + const mutatedUnion = assertUnionResult(mutation); // Union members must be registered in the schema for buildSchema to resolve them const sdl = renderComponentToSDL( @@ -38,7 +50,7 @@ describe("UnionType component", () => { - + , ); @@ -59,6 +71,7 @@ describe("UnionType component", () => { const engine = createGraphQLMutationEngine(tester.program); const mutation = engine.mutateUnion(Result, GraphQLTypeContext.Output); + const mutatedUnion = assertUnionResult(mutation); const sdl = renderComponentToSDL( tester.program, @@ -69,7 +82,7 @@ describe("UnionType component", () => { - + , ); @@ -89,6 +102,7 @@ describe("UnionType component", () => { const engine = createGraphQLMutationEngine(tester.program); const mutation = engine.mutateUnion(Shape, GraphQLTypeContext.Output); + const mutatedUnion = assertUnionResult(mutation); const sdl = renderComponentToSDL( tester.program, @@ -102,7 +116,7 @@ describe("UnionType component", () => { - + , ); @@ -122,6 +136,7 @@ describe("UnionType component", () => { const engine = createGraphQLMutationEngine(tester.program); const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output); + const mutatedUnion = assertUnionResult(mutation); // Register Cat and the wrapper type that the union will reference const sdl = renderComponentToSDL( @@ -133,7 +148,7 @@ describe("UnionType component", () => { - + , ); From d7744e6907f8a60138f793f81882ffd7e81117e0 Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 1 May 2026 15:08:55 -0400 Subject: [PATCH 3/5] Refactor component tests to use toMatchInlineSnapshot() Replace fragile .toContain() assertions with inline snapshots to match TSP ecosystem best practices. This makes test output more readable and maintainable by showing the complete expected SDL in one place. --- .../test/components/enum-type.test.tsx | 73 ++++++++++++----- .../test/components/scalar-type.test.tsx | 46 ++++++++--- .../test/components/union-type.test.tsx | 78 +++++++++++++++---- 3 files changed, 156 insertions(+), 41 deletions(-) diff --git a/packages/graphql/test/components/enum-type.test.tsx b/packages/graphql/test/components/enum-type.test.tsx index 418579ac461..f43a7ee8efa 100644 --- a/packages/graphql/test/components/enum-type.test.tsx +++ b/packages/graphql/test/components/enum-type.test.tsx @@ -21,10 +21,17 @@ describe("EnumType component", () => { const sdl = renderComponentToSDL(tester.program, ); - expect(sdl).toContain("enum Color {"); - expect(sdl).toContain("Red"); - expect(sdl).toContain("Green"); - expect(sdl).toContain("Blue"); + expect(sdl).toMatchInlineSnapshot(` + "enum Color { + Red + Green + Blue + } + + type Query { + _placeholder: Boolean + }" + `); }); it("renders enum with doc comment description", async () => { @@ -40,8 +47,17 @@ describe("EnumType component", () => { const sdl = renderComponentToSDL(tester.program, ); - expect(sdl).toContain("The role a user can have"); - expect(sdl).toContain("enum Role {"); + expect(sdl).toMatchInlineSnapshot(` + """"The role a user can have""" + enum Role { + Admin + User + } + + type Query { + _placeholder: Boolean + }" + `); }); it("renders enum with member descriptions", async () => { @@ -61,10 +77,19 @@ describe("EnumType component", () => { const sdl = renderComponentToSDL(tester.program, ); - expect(sdl).toContain("Currently active"); - expect(sdl).toContain("Active"); - expect(sdl).toContain("No longer active"); - expect(sdl).toContain("Inactive"); + expect(sdl).toMatchInlineSnapshot(` + "enum Status { + """Currently active""" + Active + + """No longer active""" + Inactive + } + + type Query { + _placeholder: Boolean + }" + `); }); it("renders enum with deprecated members", async () => { @@ -83,10 +108,16 @@ describe("EnumType component", () => { const sdl = renderComponentToSDL(tester.program, ); - expect(sdl).toContain("Active"); - expect(sdl).toContain("Legacy"); - expect(sdl).toContain("@deprecated"); - expect(sdl).toContain("use Active instead"); + expect(sdl).toMatchInlineSnapshot(` + "enum Status { + Active + Legacy @deprecated(reason: "use Active instead") + } + + type Query { + _placeholder: Boolean + }" + `); }); it("renders enum with sanitized member names", async () => { @@ -99,9 +130,15 @@ describe("EnumType component", () => { const sdl = renderComponentToSDL(tester.program, ); - expect(sdl).toContain("_val1_"); - expect(sdl).toContain("val_2"); - expect(sdl).not.toContain("$val1$"); - expect(sdl).not.toContain("val-2"); + expect(sdl).toMatchInlineSnapshot(` + "enum E { + _val1_ + val_2 + } + + type Query { + _placeholder: Boolean + }" + `); }); }); diff --git a/packages/graphql/test/components/scalar-type.test.tsx b/packages/graphql/test/components/scalar-type.test.tsx index 14da1e0b36a..114f09f6073 100644 --- a/packages/graphql/test/components/scalar-type.test.tsx +++ b/packages/graphql/test/components/scalar-type.test.tsx @@ -22,7 +22,13 @@ describe("ScalarType component", () => { const sdl = renderComponentToSDL(tester.program, ); - expect(sdl).toContain("scalar DateTime"); + expect(sdl).toMatchInlineSnapshot(` + "scalar DateTime + + type Query { + _placeholder: Boolean + }" + `); }); it("renders a scalar with doc comment description", async () => { @@ -38,8 +44,14 @@ describe("ScalarType component", () => { const sdl = renderComponentToSDL(tester.program, ); - expect(sdl).toContain("Arbitrary JSON blob"); - expect(sdl).toContain("scalar JSON"); + expect(sdl).toMatchInlineSnapshot(` + """"Arbitrary JSON blob""" + scalar JSON + + type Query { + _placeholder: Boolean + }" + `); }); it("renders a scalar with @specifiedBy from context", async () => { @@ -66,9 +78,13 @@ describe("ScalarType component", () => { { scalarSpecifications }, ); - expect(sdl).toContain("scalar MyScalar"); - expect(sdl).toContain("@specifiedBy"); - expect(sdl).toContain("https://example.com/spec"); + expect(sdl).toMatchInlineSnapshot(` + "scalar MyScalar @specifiedBy(url: "https://example.com/spec") + + type Query { + _placeholder: Boolean + }" + `); }); it("renders a scalar without @specifiedBy when not in context", async () => { @@ -81,8 +97,13 @@ describe("ScalarType component", () => { const sdl = renderComponentToSDL(tester.program, ); - expect(sdl).toContain("scalar MyScalar"); - expect(sdl).not.toContain("@specifiedBy"); + expect(sdl).toMatchInlineSnapshot(` + "scalar MyScalar + + type Query { + _placeholder: Boolean + }" + `); }); it("renders a scalar with sanitized name", async () => { @@ -96,7 +117,12 @@ describe("ScalarType component", () => { const sdl = renderComponentToSDL(tester.program, ); - expect(sdl).toContain("scalar _Bad_"); - expect(sdl).not.toContain("$Bad$"); + expect(sdl).toMatchInlineSnapshot(` + "scalar _Bad_ + + type Query { + _placeholder: Boolean + }" + `); }); }); diff --git a/packages/graphql/test/components/union-type.test.tsx b/packages/graphql/test/components/union-type.test.tsx index 5b19b2e7d0a..a1970b419e2 100644 --- a/packages/graphql/test/components/union-type.test.tsx +++ b/packages/graphql/test/components/union-type.test.tsx @@ -54,9 +54,21 @@ describe("UnionType component", () => { , ); - expect(sdl).toContain("union Pet ="); - expect(sdl).toContain("Cat"); - expect(sdl).toContain("Dog"); + expect(sdl).toMatchInlineSnapshot(` + "type Cat { + name: String! + } + + type Dog { + breed: String! + } + + union Pet = Cat | Dog + + type Query { + _placeholder: Boolean + }" + `); }); it("renders a union with doc comment description", async () => { @@ -86,8 +98,22 @@ describe("UnionType component", () => { , ); - expect(sdl).toContain("The result of an operation"); - expect(sdl).toContain("union Result ="); + expect(sdl).toMatchInlineSnapshot(` + "type Success { + value: String! + } + + type Failure { + message: String! + } + + """The result of an operation""" + union Result = Success | Failure + + type Query { + _placeholder: Boolean + }" + `); }); it("renders a union with multiple model members", async () => { @@ -120,10 +146,25 @@ describe("UnionType component", () => { , ); - expect(sdl).toContain("union Shape ="); - expect(sdl).toContain("Circle"); - expect(sdl).toContain("Square"); - expect(sdl).toContain("Triangle"); + expect(sdl).toMatchInlineSnapshot(` + "type Circle { + radius: Float! + } + + type Square { + side: Float! + } + + type Triangle { + base: Float! + } + + union Shape = Circle | Square | Triangle + + type Query { + _placeholder: Boolean + }" + `); }); it("references wrapper type names for scalar variants", async () => { @@ -152,9 +193,20 @@ describe("UnionType component", () => { , ); - // Scalar variant should reference wrapper type name - expect(sdl).toContain("union Mixed ="); - expect(sdl).toContain("MixedTextUnionVariant"); - expect(sdl).toContain("Cat"); + expect(sdl).toMatchInlineSnapshot(` + "type Cat { + name: String! + } + + type MixedTextUnionVariant { + value: String! + } + + union Mixed = Cat | MixedTextUnionVariant + + type Query { + _placeholder: Boolean + }" + `); }); }); From 390fba651b30101aafeb4364c59348eea69dc9d8 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 2 Jun 2026 16:31:37 -0400 Subject: [PATCH 4/5] Add isScalarLikeType to type-utils.ts (lost during rebase) The function was extracted from union-type.tsx in commit 6d47132e4 but the type-utils.ts hunk was dropped during the rebase due to conflicts with 'Remove dead code from type-utils.ts' in the base. --- packages/graphql/src/lib/type-utils.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/graphql/src/lib/type-utils.ts b/packages/graphql/src/lib/type-utils.ts index 2cd0f5b22e0..f84912c9a5e 100644 --- a/packages/graphql/src/lib/type-utils.ts +++ b/packages/graphql/src/lib/type-utils.ts @@ -218,6 +218,11 @@ function getUnionNameForOperation(program: Program, union: Union): string { return toTypeName(getTypeName(operation)); } +/** Check if a type is a scalar (built-in or custom) or an intrinsic type like `unknown`. */ +export function isScalarLikeType(type: Type): boolean { + return type.kind === "Scalar" || type.kind === "Intrinsic"; +} + /** Get the GraphQL description for a type from its doc comments. */ export function getGraphQLDoc(program: Program, type: Type): string | undefined { // GraphQL uses CommonMark for descriptions From 7628d9956e3cb7ecfcf4b4e160ec537b61e34873 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 3 Jun 2026 13:59:12 -0400 Subject: [PATCH 5/5] Adapt components to TypeGraph context - ScalarType: take specificationUrl as prop instead of from context - component-test-utils: use minimal TypeGraph context - scalar-type.test: pass specificationUrl as prop --- .../graphql/src/components/types/scalar-type.tsx | 7 +++---- .../test/components/component-test-utils.tsx | 15 ++------------- .../graphql/test/components/scalar-type.test.tsx | 11 +++-------- 3 files changed, 8 insertions(+), 25 deletions(-) diff --git a/packages/graphql/src/components/types/scalar-type.tsx b/packages/graphql/src/components/types/scalar-type.tsx index 8a8926e8e94..7ecb6b3dde4 100644 --- a/packages/graphql/src/components/types/scalar-type.tsx +++ b/packages/graphql/src/components/types/scalar-type.tsx @@ -1,11 +1,12 @@ import { type Scalar, getDoc } from "@typespec/compiler"; import * as gql from "@alloy-js/graphql"; import { useTsp } from "@typespec/emitter-framework"; -import { useGraphQLSchema } from "../../context/index.js"; export interface ScalarTypeProps { /** The scalar type to render */ type: Scalar; + /** Optional @specifiedBy URL for the scalar */ + specificationUrl?: string; } /** @@ -13,15 +14,13 @@ export interface ScalarTypeProps { */ export function ScalarType(props: ScalarTypeProps) { const { program } = useTsp(); - const { scalarSpecifications } = useGraphQLSchema(); const doc = getDoc(program, props.type); - const specificationUrl = scalarSpecifications.get(props.type.name); return ( ); } diff --git a/packages/graphql/test/components/component-test-utils.tsx b/packages/graphql/test/components/component-test-utils.tsx index a70bd980e77..3e62b419ee1 100644 --- a/packages/graphql/test/components/component-test-utils.tsx +++ b/packages/graphql/test/components/component-test-utils.tsx @@ -19,20 +19,9 @@ export function renderComponentToSDL( contextOverrides?: Partial, ): string { const contextValue: GraphQLSchemaContextValue = { - classifiedTypes: { - interfaces: [], - outputModels: [], - inputModels: [], - enums: [], - scalars: [], - scalarVariants: [], - unions: [], - queries: [], - mutations: [], - subscriptions: [], + typeGraph: { + globalNamespace: program.getGlobalNamespaceType(), }, - modelVariants: { outputModels: new Map(), inputModels: new Map() }, - scalarSpecifications: new Map(), ...contextOverrides, }; diff --git a/packages/graphql/test/components/scalar-type.test.tsx b/packages/graphql/test/components/scalar-type.test.tsx index 114f09f6073..6bd723a4fad 100644 --- a/packages/graphql/test/components/scalar-type.test.tsx +++ b/packages/graphql/test/components/scalar-type.test.tsx @@ -54,7 +54,7 @@ describe("ScalarType component", () => { `); }); - it("renders a scalar with @specifiedBy from context", async () => { + it("renders a scalar with @specifiedBy from prop", async () => { const { MyScalar } = await tester.compile( t.code` @specifiedBy("https://example.com/spec") @@ -65,17 +65,12 @@ describe("ScalarType component", () => { const engine = createGraphQLMutationEngine(tester.program); const mutation = engine.mutateScalar(MyScalar); - // Build scalarSpecifications map like the emitter does + // Get spec URL like the emitter does const specUrl = getSpecifiedBy(tester.program, mutation.mutatedType); - const scalarSpecifications = new Map(); - if (specUrl) { - scalarSpecifications.set(mutation.mutatedType.name, specUrl); - } const sdl = renderComponentToSDL( tester.program, - , - { scalarSpecifications }, + , ); expect(sdl).toMatchInlineSnapshot(`