From 46f19114c25627aad60ac46a0755a6e2494fe5c7 Mon Sep 17 00:00:00 2001 From: "maxholman[bot]" <321308195+maxholman[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:48:53 +0800 Subject: [PATCH] test: snapshot generated files instead of matching fragments Codegen output is deterministic, so each regression test now writes the files it covers to __tests__/__snapshots__/// with toMatchFileSnapshot and the fragment assertions go. The nullables .snap entries move to the same layout. The snapshot folder is excluded from oxlint, oxfmt and the fixture typecheck, since it holds generator output compared byte for byte. Co-Authored-By: LLM --- .oxfmtrc.json | 8 +- .../types.ts | 12 + .../valibot.ts | 6 + .../valibot.ts | 6 + .../valibot.ts | 6 + .../valibot.ts | 14 + .../valibot.ts | 14 + .../valibot.ts | 14 + .../valibot.ts | 14 + .../valibot.ts | 14 + .../valibot.ts | 14 + .../types.ts | 5 + .../valibot.ts | 6 + .../main.ts | 14 + .../commands.ts | 17 ++ .../types.ts | 13 + .../types.ts | 5 + .../commands.ts | 16 ++ .../types.ts | 15 ++ .../main.ts | 14 + .../types.ts | 15 ++ .../types.ts | 9 + .../types.ts | 9 + .../__snapshots__/nullables.test.ts.snap | 218 --------------- .../nullables/const-values/types.ts | 4 + .../nullables/const-values/valibot.ts | 10 + .../valibot.ts | 6 + .../header-parameters/commands-validated.ts | 6 + .../nullables/header-parameters/commands.ts | 25 ++ .../nullables/header-parameters/hono.ts | 26 ++ .../nullables/header-parameters/types.ts | 13 + .../nullables/header-parameters/valibot.ts | 22 ++ .../valibot.ts | 4 + .../nullables/nullables/types.ts | 1 + .../valibot.ts | 4 + .../types.ts | 13 + .../valibot.ts | 22 ++ .../rfc-3339-temporal-formats/types.ts | 4 + .../rfc-3339-temporal-formats/valibot.ts | 10 + .../top-level-type-array-with-null/enums.ts | 1 + .../top-level-type-array-with-null/types.ts | 4 + .../top-level-type-array-with-null/valibot.ts | 10 + .../types.ts | 1 + .../types.ts | 1 + .../types.ts | 1 + __tests__/codegen-regressions.test.ts | 248 ++++-------------- __tests__/generated-snapshot.ts | 28 ++ __tests__/nullables.test.ts | 85 ++---- __tests__/tsconfig.json | 3 +- __tests__/typescript-hint.test.ts | 21 +- oxlint.config.ts | 3 + 51 files changed, 562 insertions(+), 492 deletions(-) create mode 100644 __tests__/__snapshots__/codegen-regressions/a-oneof-query-param-keeps-the-stringish-wire-types-in-every-branch/types.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-anyof/valibot.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-oneof/valibot.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-two-member-oneof/valibot.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/additionalproperties-alongside-properties-keeps-the-extra-keys-valid/valibot.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-empty-schema/valibot.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-false/valibot.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-string-schema/valibot.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-true/valibot.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/additionalproperties-true-accepts-any-key/valibot.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/types.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/valibot.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/allinputs-union-carries-every-command-that-takes-an-input/main.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/commands.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/types.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/an-empty-properties-bag-is-a-record-not-an-empty-object-type/types.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/an-operation-with-both-a-200-and-a-204-emits-one-output-type-argument/commands.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/json-request-body-members-keep-their-real-json-types-nested-included/types.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/main-ts-emits-file-level-import-type-for-type-only-imports/main.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/nested-query-param-members-get-the-same-stringish-treatment-as-top-level-ones/types.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/optional-query-params-do-not-carry-undefined-in-their-property-type/types.ts create mode 100644 __tests__/__snapshots__/codegen-regressions/the-generated-json-body-type-is-pascalcase/types.ts delete mode 100644 __tests__/__snapshots__/nullables.test.ts.snap create mode 100644 __tests__/__snapshots__/nullables/const-values/types.ts create mode 100644 __tests__/__snapshots__/nullables/const-values/valibot.ts create mode 100644 __tests__/__snapshots__/nullables/enums-short-circuit-type-constraints-picklist-only/valibot.ts create mode 100644 __tests__/__snapshots__/nullables/header-parameters/commands-validated.ts create mode 100644 __tests__/__snapshots__/nullables/header-parameters/commands.ts create mode 100644 __tests__/__snapshots__/nullables/header-parameters/hono.ts create mode 100644 __tests__/__snapshots__/nullables/header-parameters/types.ts create mode 100644 __tests__/__snapshots__/nullables/header-parameters/valibot.ts create mode 100644 __tests__/__snapshots__/nullables/input-only-mode-omits-wire-schemas/valibot.ts create mode 100644 __tests__/__snapshots__/nullables/nullables/types.ts create mode 100644 __tests__/__snapshots__/nullables/oneof-with-type-null-generates-v-null/valibot.ts create mode 100644 __tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/types.ts create mode 100644 __tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/valibot.ts create mode 100644 __tests__/__snapshots__/nullables/rfc-3339-temporal-formats/types.ts create mode 100644 __tests__/__snapshots__/nullables/rfc-3339-temporal-formats/valibot.ts create mode 100644 __tests__/__snapshots__/nullables/top-level-type-array-with-null/enums.ts create mode 100644 __tests__/__snapshots__/nullables/top-level-type-array-with-null/types.ts create mode 100644 __tests__/__snapshots__/nullables/top-level-type-array-with-null/valibot.ts create mode 100644 __tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-anyof-branches/types.ts create mode 100644 __tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-oneof-branches/types.ts create mode 100644 __tests__/__snapshots__/typescript-hint/x-typescript-hint-on-top-level-string-schema/types.ts create mode 100644 __tests__/generated-snapshot.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 0efea76..5425467 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -2,7 +2,13 @@ "$schema": "./node_modules/oxfmt/configuration_schema.json", "useTabs": true, "printWidth": 80, - "ignorePatterns": ["**/dist", "**/build", "**/node_modules", "**/.claude"], + "ignorePatterns": [ + "**/dist", + "**/build", + "**/node_modules", + "**/.claude", + "__tests__/__snapshots__" + ], "sortPackageJson": { "sortScripts": true }, diff --git a/__tests__/__snapshots__/codegen-regressions/a-oneof-query-param-keeps-the-stringish-wire-types-in-every-branch/types.ts b/__tests__/__snapshots__/codegen-regressions/a-oneof-query-param-keeps-the-stringish-wire-types-in-every-branch/types.ts new file mode 100644 index 0000000..125522a --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/a-oneof-query-param-keeps-the-stringish-wire-types-in-every-branch/types.ts @@ -0,0 +1,12 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type ListThingsCommandQuery = { + size?: `${number}` | string; + flag?: "true" | "false" | string; + nested?: { + "count"?: `${number}` | string; + }; + }; +export type ListThingsCommandInput = ListThingsCommandQuery; +export type ListThingsCommandOutput = string | undefined; +export type InputListThingsCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-anyof/valibot.ts b/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-anyof/valibot.ts new file mode 100644 index 0000000..999d3a4 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-anyof/valibot.ts @@ -0,0 +1,6 @@ +import * as v from "valibot"; + +export const inputOnlySchema = v.string(); +export const onlySchema = v.pipe(v.string(), v.trim()); +export const inputGetThingCommandResponseSchema = inputOnlySchema; +export const getThingCommandResponseSchema = onlySchema; diff --git a/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-oneof/valibot.ts b/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-oneof/valibot.ts new file mode 100644 index 0000000..999d3a4 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-oneof/valibot.ts @@ -0,0 +1,6 @@ +import * as v from "valibot"; + +export const inputOnlySchema = v.string(); +export const onlySchema = v.pipe(v.string(), v.trim()); +export const inputGetThingCommandResponseSchema = inputOnlySchema; +export const getThingCommandResponseSchema = onlySchema; diff --git a/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-two-member-oneof/valibot.ts b/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-two-member-oneof/valibot.ts new file mode 100644 index 0000000..cc3c4da --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-two-member-oneof/valibot.ts @@ -0,0 +1,6 @@ +import * as v from "valibot"; + +export const inputOnlySchema = v.union([v.string(), v.number()]); +export const onlySchema = v.union([v.pipe(v.string(), v.trim()), v.number()]); +export const inputGetThingCommandResponseSchema = inputOnlySchema; +export const getThingCommandResponseSchema = onlySchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-alongside-properties-keeps-the-extra-keys-valid/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-alongside-properties-keeps-the-extra-keys-valid/valibot.ts new file mode 100644 index 0000000..131918b --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-alongside-properties-keeps-the-extra-keys-valid/valibot.ts @@ -0,0 +1,14 @@ +import * as v from "valibot"; + +export const inputConfigSchema = v.objectWithRest( + { + "name": v.optional(v.string()) + , + }, v.number()); +export const configSchema = v.objectWithRest( + { + "name": v.exactOptional(v.pipe(v.string(), v.trim())) + , + }, v.number()); +export const inputGetThingCommandResponseSchema = inputConfigSchema; +export const getThingCommandResponseSchema = configSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-empty-schema/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-empty-schema/valibot.ts new file mode 100644 index 0000000..5c53bc7 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-empty-schema/valibot.ts @@ -0,0 +1,14 @@ +import * as v from "valibot"; + +export const inputOpenSchema = v.looseObject( + { + "a": v.optional(v.string()) + , + }); +export const openSchema = v.looseObject( + { + "a": v.exactOptional(v.pipe(v.string(), v.trim())) + , + }); +export const inputGetThingCommandResponseSchema = inputOpenSchema; +export const getThingCommandResponseSchema = openSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-false/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-false/valibot.ts new file mode 100644 index 0000000..1c97502 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-false/valibot.ts @@ -0,0 +1,14 @@ +import * as v from "valibot"; + +export const inputOpenSchema = v.strictObject( + { + "a": v.optional(v.string()) + , + }); +export const openSchema = v.strictObject( + { + "a": v.exactOptional(v.pipe(v.string(), v.trim())) + , + }); +export const inputGetThingCommandResponseSchema = inputOpenSchema; +export const getThingCommandResponseSchema = openSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-string-schema/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-string-schema/valibot.ts new file mode 100644 index 0000000..bb6c2da --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-string-schema/valibot.ts @@ -0,0 +1,14 @@ +import * as v from "valibot"; + +export const inputOpenSchema = v.objectWithRest( + { + "a": v.optional(v.string()) + , + }, v.string()); +export const openSchema = v.objectWithRest( + { + "a": v.exactOptional(v.pipe(v.string(), v.trim())) + , + }, v.pipe(v.string(), v.trim())); +export const inputGetThingCommandResponseSchema = inputOpenSchema; +export const getThingCommandResponseSchema = openSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-true/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-true/valibot.ts new file mode 100644 index 0000000..5c53bc7 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-true/valibot.ts @@ -0,0 +1,14 @@ +import * as v from "valibot"; + +export const inputOpenSchema = v.looseObject( + { + "a": v.optional(v.string()) + , + }); +export const openSchema = v.looseObject( + { + "a": v.exactOptional(v.pipe(v.string(), v.trim())) + , + }); +export const inputGetThingCommandResponseSchema = inputOpenSchema; +export const getThingCommandResponseSchema = openSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-true-accepts-any-key/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-true-accepts-any-key/valibot.ts new file mode 100644 index 0000000..d13b4c4 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-true-accepts-any-key/valibot.ts @@ -0,0 +1,14 @@ +import * as v from "valibot"; + +export const inputOpenSchema = v.looseObject( + { + "name": v.optional(v.string()) + , + }); +export const openSchema = v.looseObject( + { + "name": v.exactOptional(v.pipe(v.string(), v.trim())) + , + }); +export const inputGetThingCommandResponseSchema = inputOpenSchema; +export const getThingCommandResponseSchema = openSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/types.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/types.ts new file mode 100644 index 0000000..5bbfb1d --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/types.ts @@ -0,0 +1,5 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type Labels = Record; +export type GetThingCommandInput = never; +export type InputGetThingCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/valibot.ts new file mode 100644 index 0000000..984fbbd --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/valibot.ts @@ -0,0 +1,6 @@ +import * as v from "valibot"; + +export const inputLabelsSchema = v.record(v.string(), v.string()); +export const labelsSchema = inputLabelsSchema; +export const inputGetThingCommandResponseSchema = inputLabelsSchema; +export const getThingCommandResponseSchema = labelsSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/allinputs-union-carries-every-command-that-takes-an-input/main.ts b/__tests__/__snapshots__/codegen-regressions/allinputs-union-carries-every-command-that-takes-an-input/main.ts new file mode 100644 index 0000000..dcab762 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/allinputs-union-carries-every-command-that-takes-an-input/main.ts @@ -0,0 +1,14 @@ +import { RestServiceClient, type RestServiceClientConfig } from "@block65/rest-client"; +import type { UndefinedOnPartialDeep } from "type-fest"; +import type { AlphaCommandOutput, BetaCommandOutput, GammaCommandOutput, WithBodyCommandInput, WithBodyCommandOutput, WithQueryCommandInput, WithQueryCommandOutput } from "./types.js"; + +export { ResponseValidationError } from "@block65/rest-client"; + +type AllInputs = UndefinedOnPartialDeep | UndefinedOnPartialDeep; +type AllOutputs = AlphaCommandOutput | BetaCommandOutput | GammaCommandOutput | WithBodyCommandOutput | WithQueryCommandOutput; + +export class TestRestClient extends RestServiceClient { + constructor(baseUrl: string | URL = new URL('https://api.example.com/'), config?: RestServiceClientConfig) { + super(baseUrl, config); + } +} diff --git a/__tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/commands.ts b/__tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/commands.ts new file mode 100644 index 0000000..fb77ee8 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/commands.ts @@ -0,0 +1,17 @@ +import { Command, stripUndefined, jsonStringify } from "@block65/rest-client"; +import type { UndefinedOnPartialDeep } from "type-fest"; +import type { PluginPullCommandQuery, PluginPullCommandInput, PluginPullCommandOutput } from "./types.js"; + + +/** + * PluginPullCommand + * + */ +export class PluginPullCommand extends Command, PluginPullCommandOutput, PluginPullCommandQuery> { + public override method = "post" as const; + + constructor(input: UndefinedOnPartialDeep) { + const {remote, body } = input; + super("/plugins/pull", jsonStringify(body), stripUndefined({remote})); + } +} diff --git a/__tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/types.ts b/__tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/types.ts new file mode 100644 index 0000000..81fac9e --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/types.ts @@ -0,0 +1,13 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type PluginPullCommandQuery = { + remote: string; + }; +type PluginPullCommandJsonBody = readonly (string)[]; +export type PluginPullCommandBody = PluginPullCommandJsonBody; +type PluginPullCommandBodyWrapper = { + body: PluginPullCommandJsonBody; + }; +export type PluginPullCommandInput = PluginPullCommandBodyWrapper & PluginPullCommandQuery; +export type PluginPullCommandOutput = string | undefined; +export type InputPluginPullCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/an-empty-properties-bag-is-a-record-not-an-empty-object-type/types.ts b/__tests__/__snapshots__/codegen-regressions/an-empty-properties-bag-is-a-record-not-an-empty-object-type/types.ts new file mode 100644 index 0000000..21cf87b --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/an-empty-properties-bag-is-a-record-not-an-empty-object-type/types.ts @@ -0,0 +1,5 @@ +import type { Jsonifiable, UndefinedOnPartialDeep } from "type-fest"; + +export type Empty = Record; +export type GetThingCommandInput = never; +export type InputGetThingCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/an-operation-with-both-a-200-and-a-204-emits-one-output-type-argument/commands.ts b/__tests__/__snapshots__/codegen-regressions/an-operation-with-both-a-200-and-a-204-emits-one-output-type-argument/commands.ts new file mode 100644 index 0000000..05c8bd3 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/an-operation-with-both-a-200-and-a-204-emits-one-output-type-argument/commands.ts @@ -0,0 +1,16 @@ +import { Command } from "@block65/rest-client"; +import type { UndefinedOnPartialDeep } from "type-fest"; +import type { SystemAuthCommandInput, SystemAuthCommandOutput } from "./types.js"; + + +/** + * SystemAuthCommand + * + */ +export class SystemAuthCommand extends Command, SystemAuthCommandOutput> { + public override method = "post" as const; + + constructor() { + super("/auth"); + } +} diff --git a/__tests__/__snapshots__/codegen-regressions/json-request-body-members-keep-their-real-json-types-nested-included/types.ts b/__tests__/__snapshots__/codegen-regressions/json-request-body-members-keep-their-real-json-types-nested-included/types.ts new file mode 100644 index 0000000..4b211f4 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/json-request-body-members-keep-their-real-json-types-nested-included/types.ts @@ -0,0 +1,15 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +type CreateContainerCommandJsonBody = { + "tty"?: boolean; + "retries"?: number; + "health"?: { + "interval"?: bigint; + "enabled"?: boolean; + }; + "sizes"?: readonly (number)[]; + }; +export type CreateContainerCommandBody = CreateContainerCommandJsonBody; +export type CreateContainerCommandInput = CreateContainerCommandJsonBody; +export type CreateContainerCommandOutput = string | undefined; +export type InputCreateContainerCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/main-ts-emits-file-level-import-type-for-type-only-imports/main.ts b/__tests__/__snapshots__/codegen-regressions/main-ts-emits-file-level-import-type-for-type-only-imports/main.ts new file mode 100644 index 0000000..bfb52e8 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/main-ts-emits-file-level-import-type-for-type-only-imports/main.ts @@ -0,0 +1,14 @@ +import { RestServiceClient, type RestServiceClientConfig } from "@block65/rest-client"; +import type { UndefinedOnPartialDeep } from "type-fest"; +import type { OneCommandInput, OneCommandOutput } from "./types.js"; + +export { ResponseValidationError } from "@block65/rest-client"; + +type AllInputs = UndefinedOnPartialDeep; +type AllOutputs = OneCommandOutput; + +export class TestRestClient extends RestServiceClient { + constructor(baseUrl: string | URL = new URL('https://api.example.com/'), config?: RestServiceClientConfig) { + super(baseUrl, config); + } +} diff --git a/__tests__/__snapshots__/codegen-regressions/nested-query-param-members-get-the-same-stringish-treatment-as-top-level-ones/types.ts b/__tests__/__snapshots__/codegen-regressions/nested-query-param-members-get-the-same-stringish-treatment-as-top-level-ones/types.ts new file mode 100644 index 0000000..4d8aea6 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/nested-query-param-members-get-the-same-stringish-treatment-as-top-level-ones/types.ts @@ -0,0 +1,15 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type SearchCommandQuery = { + limit?: `${number}`; + filter?: { + "age"?: `${number}`; + "big"?: `${bigint}`; + "active"?: "true" | "false"; + "label"?: string; + }; + ids?: readonly (`${number}`)[]; + }; +export type SearchCommandInput = SearchCommandQuery; +export type SearchCommandOutput = string | undefined; +export type InputSearchCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/optional-query-params-do-not-carry-undefined-in-their-property-type/types.ts b/__tests__/__snapshots__/codegen-regressions/optional-query-params-do-not-carry-undefined-in-their-property-type/types.ts new file mode 100644 index 0000000..4f971be --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/optional-query-params-do-not-carry-undefined-in-their-property-type/types.ts @@ -0,0 +1,9 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type ListFilesCommandQuery = { + purpose?: string; + limit?: `${number}`; + }; +export type ListFilesCommandInput = ListFilesCommandQuery; +export type ListFilesCommandOutput = string | undefined; +export type InputListFilesCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/the-generated-json-body-type-is-pascalcase/types.ts b/__tests__/__snapshots__/codegen-regressions/the-generated-json-body-type-is-pascalcase/types.ts new file mode 100644 index 0000000..dd36a71 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/the-generated-json-body-type-is-pascalcase/types.ts @@ -0,0 +1,9 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +type CreateBatchJsonBody = { + "input_file_id"?: string; + }; +export type CreateBatchCommandBody = CreateBatchJsonBody; +export type CreateBatchCommandInput = CreateBatchJsonBody; +export type CreateBatchCommandOutput = string | undefined; +export type InputCreateBatchCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/nullables.test.ts.snap b/__tests__/__snapshots__/nullables.test.ts.snap deleted file mode 100644 index 107a4f3..0000000 --- a/__tests__/__snapshots__/nullables.test.ts.snap +++ /dev/null @@ -1,218 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`const values > types 1`] = ` -"export type StringConst = "hello"; -export type NumberConst = 42; -export type BooleanConst = true; -export type NullConst = null; -" -`; - -exports[`const values > valibot 1`] = ` -"import * as v from "valibot"; - -export const inputStringConstSchema = v.literal("hello"); -export const stringConstSchema = inputStringConstSchema; -export const inputNumberConstSchema = v.literal(42); -export const numberConstSchema = inputNumberConstSchema; -export const inputBooleanConstSchema = v.literal(true); -export const booleanConstSchema = inputBooleanConstSchema; -export const inputNullConstSchema = v.null(); -export const nullConstSchema = inputNullConstSchema; -" -`; - -exports[`header parameters > commands 1`] = ` -"import { Command } from "@block65/rest-client"; -import type { UndefinedOnPartialDeep } from "type-fest"; -import type { UploadDataCommandHeader, UploadDataCommandInput, UploadStatus } from "./types.js"; - -/** - * Tagged template literal that applies encodeURIComponent to all interpolated - * values, protecting path integrity from characters like \`/\` and \`#\`. - * @example encodePath\`/users/\${userId}\` // "/users/foo%2Fbar" - */ -function encodePath(strings: TemplateStringsArray, ...values: string[]) { - return String.raw({ raw: strings }, ...values.map((value) => encodeURIComponent(value))); -} - -/** - * UploadDataCommand - * - */ -export class UploadDataCommand extends Command, UploadStatus, never, UploadDataCommandHeader> { - public override method = "post" as const; - - constructor(input: UndefinedOnPartialDeep, headers: UploadDataCommandHeader) { - const {uploadId } = input; - super(encodePath\`/uploads/\${uploadId}\`, undefined, undefined, headers); - } -} -" -`; - -exports[`header parameters > commands-validated 1`] = ` -"import * as commands from "./commands.js"; -import * as schemas from "./valibot.js"; - -export class UploadDataCommand extends commands.UploadDataCommand { - static responseSchema = schemas.uploadDataCommandResponseSchema; -} -" -`; - -exports[`header parameters > hono 1`] = ` -"import { sValidator } from "@hono/standard-validator"; -import type { ValidationTargets } from "hono"; -import { PublicValidationError } from "@block65/rest-client"; -import { uploadDataCommandParamsSchema } from "./valibot.js"; - -type StandardSchema = Parameters[1]; - -// The query arrives here as Hono parsed it, and nothing below decodes -// it. A lone value for a repeated-key array stays a string, comma, -// space and pipe joined values stay joined, and a deepObject key stays -// bracketed, so each of those fails validation. A caller sending them -// decodes the query before this middleware or replaces it, reading the -// style and explode from the query spec each operation exports below. -function validate(target: TTarget, schema: TSchema) { - - return sValidator(target, schema, (result) => { - if (!result.success) { - throw PublicValidationError.fromIssues(result.error); - } - }); - -} - -export const uploadData = [ - validate("param", uploadDataCommandParamsSchema), - ] as const; -" -`; - -exports[`header parameters > types 1`] = ` -"import type { UndefinedOnPartialDeep } from "type-fest"; - -export type UploadStatus = "pending" | "complete"; -export type UploadDataCommandHeader = { - "content-type": "application/json" | "text/csv" | "application/xml"; - "content-length": \`\${bigint}\`; - "x-idempotency-key"?: string; - }; -export type UploadDataCommandParams = { - uploadId: string; - }; -export type UploadDataCommandInput = UploadDataCommandParams; -export type InputUploadDataCommandResponse = UndefinedOnPartialDeep; -" -`; - -exports[`header parameters > valibot 1`] = ` -"import * as v from "valibot"; - -export const inputUploadStatusSchema = v.picklist(["pending", "complete"]); -export const uploadStatusSchema = inputUploadStatusSchema; -export const inputUploadDataCommandResponseSchema = inputUploadStatusSchema; -export const uploadDataCommandResponseSchema = uploadStatusSchema; -export const inputUploadDataCommandParamsSchema = v.strictObject({ - "uploadId": v.string() - }); -export const uploadDataCommandParamsSchema = v.strictObject({ - "uploadId": v.pipe(v.string(), v.trim()) - }); -export const inputUploadDataCommandHeaderSchema = v.object({ - "content-type": v.picklist(["application/json", "text/csv", "application/xml"]), - "content-length": v.bigint(), - "x-idempotency-key": v.optional(v.pipe(v.string(), v.uuid())) - }); -export const uploadDataCommandHeaderSchema = v.object({ - "content-type": v.picklist(["application/json", "text/csv", "application/xml"]), - "content-length": v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.bigint()), v.pipe(v.number(), v.integer(), v.toBigint(), v.bigint()), v.bigint()]), - "x-idempotency-key": v.exactOptional(v.pipe(v.string(), v.uuid())) - }); -" -`; - -exports[`nullables > types 1`] = ` -"export type MySchemaLolOrNullable = "lol" | "kek" | null; -" -`; - -exports[`oneOf with type null generates v.null() > valibot 1`] = ` -"import * as v from "valibot"; - -export const inputNullableImageSchema = v.union([v.string(), v.null()]); -export const nullableImageSchema = v.union([v.pipe(v.string(), v.trim()), v.null()]); -" -`; - -exports[`query and header integer params coerce strings to numbers > types 1`] = ` -"import type { UndefinedOnPartialDeep } from "type-fest"; - -export type Dummy = string; -export type ExpireTime = bigint; -export type ListFilesCommandQuery = { - exp: \`\${bigint}\`; - limit?: \`\${number}\`; - }; -export type ListFilesCommandHeader = { - "x-rate-limit": \`\${number}\`; - }; -export type ListFilesCommandInput = ListFilesCommandQuery; -export type InputListFilesCommandResponse = UndefinedOnPartialDeep; -" -`; - -exports[`query and header integer params coerce strings to numbers > valibot 1`] = ` -"import * as v from "valibot"; - -export const inputDummySchema = v.string(); -export const dummySchema = v.pipe(v.string(), v.trim()); -export const inputExpireTimeSchema = v.pipe(v.bigint(), v.minValue(0n)); -export const expireTimeSchema = v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.number(), v.integer(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.bigint(), v.minValue(0n))]); -export const inputListFilesCommandResponseSchema = inputDummySchema; -export const listFilesCommandResponseSchema = dummySchema; -export const inputListFilesCommandQuerySchema = v.strictObject({ - "exp": v.pipe(v.bigint(), v.minValue(0n)), - "limit": v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))) - }); -export const listFilesCommandQuerySchema = v.strictObject({ - "exp": v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.number(), v.integer(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.bigint(), v.minValue(0n))]), - "limit": v.exactOptional(v.union([v.pipe(v.string(), v.decimal(), v.toNumber(), v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))), v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))])) - }); -export const inputListFilesCommandHeaderSchema = v.object({ - "x-rate-limit": v.pipe(v.number(), v.integer(), v.minValue(0)) - }); -export const listFilesCommandHeaderSchema = v.object({ - "x-rate-limit": v.union([v.pipe(v.string(), v.decimal(), v.toNumber(), v.pipe(v.number(), v.integer(), v.minValue(0))), v.pipe(v.number(), v.integer(), v.minValue(0))]) - }); -" -`; - -exports[`top-level type array with null > enums 1`] = ` -"export const nullableStringEnum = ["active", "inactive"] as const; -" -`; - -exports[`top-level type array with null > types 1`] = ` -"export type NullableString = string | null; -export type NullableStringEnum = "active" | "inactive" | null; -export type NullableInteger = number | null; -export type MultiType = string | number; -" -`; - -exports[`top-level type array with null > valibot 1`] = ` -"import * as v from "valibot"; - -export const inputNullableStringSchema = v.nullable(v.string()); -export const nullableStringSchema = inputNullableStringSchema; -export const inputNullableStringEnumSchema = v.nullable(v.picklist(["active", "inactive"])); -export const nullableStringEnumSchema = inputNullableStringEnumSchema; -export const inputNullableIntegerSchema = v.nullable(v.pipe(v.number(), v.integer())); -export const nullableIntegerSchema = inputNullableIntegerSchema; -export const inputMultiTypeSchema = v.union([v.string(), v.number()]); -export const multiTypeSchema = inputMultiTypeSchema; -" -`; diff --git a/__tests__/__snapshots__/nullables/const-values/types.ts b/__tests__/__snapshots__/nullables/const-values/types.ts new file mode 100644 index 0000000..92be38c --- /dev/null +++ b/__tests__/__snapshots__/nullables/const-values/types.ts @@ -0,0 +1,4 @@ +export type StringConst = "hello"; +export type NumberConst = 42; +export type BooleanConst = true; +export type NullConst = null; diff --git a/__tests__/__snapshots__/nullables/const-values/valibot.ts b/__tests__/__snapshots__/nullables/const-values/valibot.ts new file mode 100644 index 0000000..d880cb4 --- /dev/null +++ b/__tests__/__snapshots__/nullables/const-values/valibot.ts @@ -0,0 +1,10 @@ +import * as v from "valibot"; + +export const inputStringConstSchema = v.literal("hello"); +export const stringConstSchema = inputStringConstSchema; +export const inputNumberConstSchema = v.literal(42); +export const numberConstSchema = inputNumberConstSchema; +export const inputBooleanConstSchema = v.literal(true); +export const booleanConstSchema = inputBooleanConstSchema; +export const inputNullConstSchema = v.null(); +export const nullConstSchema = inputNullConstSchema; diff --git a/__tests__/__snapshots__/nullables/enums-short-circuit-type-constraints-picklist-only/valibot.ts b/__tests__/__snapshots__/nullables/enums-short-circuit-type-constraints-picklist-only/valibot.ts new file mode 100644 index 0000000..4b8cd8d --- /dev/null +++ b/__tests__/__snapshots__/nullables/enums-short-circuit-type-constraints-picklist-only/valibot.ts @@ -0,0 +1,6 @@ +import * as v from "valibot"; + +export const inputIntegerEnumSchema = v.picklist([0, 1, 2]); +export const integerEnumSchema = inputIntegerEnumSchema; +export const inputStringEnumSchema = v.picklist(["a@example.com", "b@example.com"]); +export const stringEnumSchema = inputStringEnumSchema; diff --git a/__tests__/__snapshots__/nullables/header-parameters/commands-validated.ts b/__tests__/__snapshots__/nullables/header-parameters/commands-validated.ts new file mode 100644 index 0000000..1ea8051 --- /dev/null +++ b/__tests__/__snapshots__/nullables/header-parameters/commands-validated.ts @@ -0,0 +1,6 @@ +import * as commands from "./commands.js"; +import * as schemas from "./valibot.js"; + +export class UploadDataCommand extends commands.UploadDataCommand { + static responseSchema = schemas.uploadDataCommandResponseSchema; +} diff --git a/__tests__/__snapshots__/nullables/header-parameters/commands.ts b/__tests__/__snapshots__/nullables/header-parameters/commands.ts new file mode 100644 index 0000000..3814d94 --- /dev/null +++ b/__tests__/__snapshots__/nullables/header-parameters/commands.ts @@ -0,0 +1,25 @@ +import { Command } from "@block65/rest-client"; +import type { UndefinedOnPartialDeep } from "type-fest"; +import type { UploadDataCommandHeader, UploadDataCommandInput, UploadStatus } from "./types.js"; + +/** + * Tagged template literal that applies encodeURIComponent to all interpolated + * values, protecting path integrity from characters like `/` and `#`. + * @example encodePath`/users/${userId}` // "/users/foo%2Fbar" + */ +function encodePath(strings: TemplateStringsArray, ...values: string[]) { + return String.raw({ raw: strings }, ...values.map((value) => encodeURIComponent(value))); +} + +/** + * UploadDataCommand + * + */ +export class UploadDataCommand extends Command, UploadStatus, never, UploadDataCommandHeader> { + public override method = "post" as const; + + constructor(input: UndefinedOnPartialDeep, headers: UploadDataCommandHeader) { + const {uploadId } = input; + super(encodePath`/uploads/${uploadId}`, undefined, undefined, headers); + } +} diff --git a/__tests__/__snapshots__/nullables/header-parameters/hono.ts b/__tests__/__snapshots__/nullables/header-parameters/hono.ts new file mode 100644 index 0000000..b4b5679 --- /dev/null +++ b/__tests__/__snapshots__/nullables/header-parameters/hono.ts @@ -0,0 +1,26 @@ +import { sValidator } from "@hono/standard-validator"; +import type { ValidationTargets } from "hono"; +import { PublicValidationError } from "@block65/rest-client"; +import { uploadDataCommandParamsSchema } from "./valibot.js"; + +type StandardSchema = Parameters[1]; + +// The query arrives here as Hono parsed it, and nothing below decodes +// it. A lone value for a repeated-key array stays a string, comma, +// space and pipe joined values stay joined, and a deepObject key stays +// bracketed, so each of those fails validation. A caller sending them +// decodes the query before this middleware or replaces it, reading the +// style and explode from the query spec each operation exports below. +function validate(target: TTarget, schema: TSchema) { + + return sValidator(target, schema, (result) => { + if (!result.success) { + throw PublicValidationError.fromIssues(result.error); + } + }); + +} + +export const uploadData = [ + validate("param", uploadDataCommandParamsSchema), + ] as const; diff --git a/__tests__/__snapshots__/nullables/header-parameters/types.ts b/__tests__/__snapshots__/nullables/header-parameters/types.ts new file mode 100644 index 0000000..fa46be2 --- /dev/null +++ b/__tests__/__snapshots__/nullables/header-parameters/types.ts @@ -0,0 +1,13 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type UploadStatus = "pending" | "complete"; +export type UploadDataCommandHeader = { + "content-type": "application/json" | "text/csv" | "application/xml"; + "content-length": `${bigint}`; + "x-idempotency-key"?: string; + }; +export type UploadDataCommandParams = { + uploadId: string; + }; +export type UploadDataCommandInput = UploadDataCommandParams; +export type InputUploadDataCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/nullables/header-parameters/valibot.ts b/__tests__/__snapshots__/nullables/header-parameters/valibot.ts new file mode 100644 index 0000000..25ede4c --- /dev/null +++ b/__tests__/__snapshots__/nullables/header-parameters/valibot.ts @@ -0,0 +1,22 @@ +import * as v from "valibot"; + +export const inputUploadStatusSchema = v.picklist(["pending", "complete"]); +export const uploadStatusSchema = inputUploadStatusSchema; +export const inputUploadDataCommandResponseSchema = inputUploadStatusSchema; +export const uploadDataCommandResponseSchema = uploadStatusSchema; +export const inputUploadDataCommandParamsSchema = v.strictObject({ + "uploadId": v.string() + }); +export const uploadDataCommandParamsSchema = v.strictObject({ + "uploadId": v.pipe(v.string(), v.trim()) + }); +export const inputUploadDataCommandHeaderSchema = v.object({ + "content-type": v.picklist(["application/json", "text/csv", "application/xml"]), + "content-length": v.bigint(), + "x-idempotency-key": v.optional(v.pipe(v.string(), v.uuid())) + }); +export const uploadDataCommandHeaderSchema = v.object({ + "content-type": v.picklist(["application/json", "text/csv", "application/xml"]), + "content-length": v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.bigint()), v.pipe(v.number(), v.integer(), v.toBigint(), v.bigint()), v.bigint()]), + "x-idempotency-key": v.exactOptional(v.pipe(v.string(), v.uuid())) + }); diff --git a/__tests__/__snapshots__/nullables/input-only-mode-omits-wire-schemas/valibot.ts b/__tests__/__snapshots__/nullables/input-only-mode-omits-wire-schemas/valibot.ts new file mode 100644 index 0000000..25a2198 --- /dev/null +++ b/__tests__/__snapshots__/nullables/input-only-mode-omits-wire-schemas/valibot.ts @@ -0,0 +1,4 @@ +import * as v from "valibot"; + +export const inputNameSchema = v.pipe(v.string(), v.minLength(1)); +export const inputAmountSchema = v.pipe(v.bigint(), v.minValue(0n)); diff --git a/__tests__/__snapshots__/nullables/nullables/types.ts b/__tests__/__snapshots__/nullables/nullables/types.ts new file mode 100644 index 0000000..1689904 --- /dev/null +++ b/__tests__/__snapshots__/nullables/nullables/types.ts @@ -0,0 +1 @@ +export type MySchemaLolOrNullable = "lol" | "kek" | null; diff --git a/__tests__/__snapshots__/nullables/oneof-with-type-null-generates-v-null/valibot.ts b/__tests__/__snapshots__/nullables/oneof-with-type-null-generates-v-null/valibot.ts new file mode 100644 index 0000000..47d8bcb --- /dev/null +++ b/__tests__/__snapshots__/nullables/oneof-with-type-null-generates-v-null/valibot.ts @@ -0,0 +1,4 @@ +import * as v from "valibot"; + +export const inputNullableImageSchema = v.union([v.string(), v.null()]); +export const nullableImageSchema = v.union([v.pipe(v.string(), v.trim()), v.null()]); diff --git a/__tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/types.ts b/__tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/types.ts new file mode 100644 index 0000000..615a4a0 --- /dev/null +++ b/__tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/types.ts @@ -0,0 +1,13 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type Dummy = string; +export type ExpireTime = bigint; +export type ListFilesCommandQuery = { + exp: `${bigint}`; + limit?: `${number}`; + }; +export type ListFilesCommandHeader = { + "x-rate-limit": `${number}`; + }; +export type ListFilesCommandInput = ListFilesCommandQuery; +export type InputListFilesCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/valibot.ts b/__tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/valibot.ts new file mode 100644 index 0000000..bd36c75 --- /dev/null +++ b/__tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/valibot.ts @@ -0,0 +1,22 @@ +import * as v from "valibot"; + +export const inputDummySchema = v.string(); +export const dummySchema = v.pipe(v.string(), v.trim()); +export const inputExpireTimeSchema = v.pipe(v.bigint(), v.minValue(0n)); +export const expireTimeSchema = v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.number(), v.integer(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.bigint(), v.minValue(0n))]); +export const inputListFilesCommandResponseSchema = inputDummySchema; +export const listFilesCommandResponseSchema = dummySchema; +export const inputListFilesCommandQuerySchema = v.strictObject({ + "exp": v.pipe(v.bigint(), v.minValue(0n)), + "limit": v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))) + }); +export const listFilesCommandQuerySchema = v.strictObject({ + "exp": v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.number(), v.integer(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.bigint(), v.minValue(0n))]), + "limit": v.exactOptional(v.union([v.pipe(v.string(), v.decimal(), v.toNumber(), v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))), v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))])) + }); +export const inputListFilesCommandHeaderSchema = v.object({ + "x-rate-limit": v.pipe(v.number(), v.integer(), v.minValue(0)) + }); +export const listFilesCommandHeaderSchema = v.object({ + "x-rate-limit": v.union([v.pipe(v.string(), v.decimal(), v.toNumber(), v.pipe(v.number(), v.integer(), v.minValue(0))), v.pipe(v.number(), v.integer(), v.minValue(0))]) + }); diff --git a/__tests__/__snapshots__/nullables/rfc-3339-temporal-formats/types.ts b/__tests__/__snapshots__/nullables/rfc-3339-temporal-formats/types.ts new file mode 100644 index 0000000..5d23cac --- /dev/null +++ b/__tests__/__snapshots__/nullables/rfc-3339-temporal-formats/types.ts @@ -0,0 +1,4 @@ +export type MyDate = `${number}-${number}-${number}`; +export type MyTime = `${number}:${number}:${number}${string}`; +export type MyDateTime = `${number}-${number}-${number}T${number}:${number}:${number}${string}`; +export type MyDuration = `P${string}`; diff --git a/__tests__/__snapshots__/nullables/rfc-3339-temporal-formats/valibot.ts b/__tests__/__snapshots__/nullables/rfc-3339-temporal-formats/valibot.ts new file mode 100644 index 0000000..2a8123d --- /dev/null +++ b/__tests__/__snapshots__/nullables/rfc-3339-temporal-formats/valibot.ts @@ -0,0 +1,10 @@ +import * as v from "valibot"; + +export const inputMyDateSchema = v.pipe(v.string(), v.regex(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u, "date"), v.custom<`${number}-${number}-${number}`>(() => true)); +export const myDateSchema = v.pipe(v.string(), v.trim(), v.regex(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u, "date"), v.custom<`${number}-${number}-${number}`>(() => true)); +export const inputMyTimeSchema = v.pipe(v.string(), v.regex(/^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "time"), v.custom<`${number}:${number}:${number}${string}`>(() => true)); +export const myTimeSchema = v.pipe(v.string(), v.trim(), v.regex(/^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "time"), v.custom<`${number}:${number}:${number}${string}`>(() => true)); +export const inputMyDateTimeSchema = v.pipe(v.string(), v.regex(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time"), v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>(() => true)); +export const myDateTimeSchema = v.pipe(v.string(), v.trim(), v.regex(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time"), v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>(() => true)); +export const inputMyDurationSchema = v.pipe(v.string(), v.regex(/^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u, "duration"), v.custom<`P${string}`>(() => true)); +export const myDurationSchema = v.pipe(v.string(), v.trim(), v.regex(/^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u, "duration"), v.custom<`P${string}`>(() => true)); diff --git a/__tests__/__snapshots__/nullables/top-level-type-array-with-null/enums.ts b/__tests__/__snapshots__/nullables/top-level-type-array-with-null/enums.ts new file mode 100644 index 0000000..a417b46 --- /dev/null +++ b/__tests__/__snapshots__/nullables/top-level-type-array-with-null/enums.ts @@ -0,0 +1 @@ +export const nullableStringEnum = ["active", "inactive"] as const; diff --git a/__tests__/__snapshots__/nullables/top-level-type-array-with-null/types.ts b/__tests__/__snapshots__/nullables/top-level-type-array-with-null/types.ts new file mode 100644 index 0000000..b96214b --- /dev/null +++ b/__tests__/__snapshots__/nullables/top-level-type-array-with-null/types.ts @@ -0,0 +1,4 @@ +export type NullableString = string | null; +export type NullableStringEnum = "active" | "inactive" | null; +export type NullableInteger = number | null; +export type MultiType = string | number; diff --git a/__tests__/__snapshots__/nullables/top-level-type-array-with-null/valibot.ts b/__tests__/__snapshots__/nullables/top-level-type-array-with-null/valibot.ts new file mode 100644 index 0000000..42e7b9e --- /dev/null +++ b/__tests__/__snapshots__/nullables/top-level-type-array-with-null/valibot.ts @@ -0,0 +1,10 @@ +import * as v from "valibot"; + +export const inputNullableStringSchema = v.nullable(v.string()); +export const nullableStringSchema = inputNullableStringSchema; +export const inputNullableStringEnumSchema = v.nullable(v.picklist(["active", "inactive"])); +export const nullableStringEnumSchema = inputNullableStringEnumSchema; +export const inputNullableIntegerSchema = v.nullable(v.pipe(v.number(), v.integer())); +export const nullableIntegerSchema = inputNullableIntegerSchema; +export const inputMultiTypeSchema = v.union([v.string(), v.number()]); +export const multiTypeSchema = inputMultiTypeSchema; diff --git a/__tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-anyof-branches/types.ts b/__tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-anyof-branches/types.ts new file mode 100644 index 0000000..b1ee74b --- /dev/null +++ b/__tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-anyof-branches/types.ts @@ -0,0 +1 @@ +export type MaybeUrl = AbsoluteUrl | RelativeUrl; diff --git a/__tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-oneof-branches/types.ts b/__tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-oneof-branches/types.ts new file mode 100644 index 0000000..194ccc0 --- /dev/null +++ b/__tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-oneof-branches/types.ts @@ -0,0 +1 @@ +export type EventSource = "native" | EmbedUrl | SyndicatedUrl; diff --git a/__tests__/__snapshots__/typescript-hint/x-typescript-hint-on-top-level-string-schema/types.ts b/__tests__/__snapshots__/typescript-hint/x-typescript-hint-on-top-level-string-schema/types.ts new file mode 100644 index 0000000..a00244e --- /dev/null +++ b/__tests__/__snapshots__/typescript-hint/x-typescript-hint-on-top-level-string-schema/types.ts @@ -0,0 +1 @@ +export type EmbedUrl = `https://embed.example.com/${string}`; diff --git a/__tests__/codegen-regressions.test.ts b/__tests__/codegen-regressions.test.ts index 034e90d..d090143 100644 --- a/__tests__/codegen-regressions.test.ts +++ b/__tests__/codegen-regressions.test.ts @@ -1,6 +1,7 @@ import type { oas31 } from "openapi3-ts"; -import { assert, expect, test } from "vitest"; +import { test } from "vitest"; import { processOpenApiDocument } from "../lib/process-document.ts"; +import { expectGenerated } from "./generated-snapshot.ts"; const respOk = { "200": { @@ -34,18 +35,8 @@ test("main.ts emits file-level `import type` for type-only imports", async () => }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const mainText = result.mainFile.getText(); - expect(mainText).toMatch( - /import type \{[^}]*OneCommandInput[^}]*\}\s+from\s+"\.\/types\.js"/, - ); - expect(mainText).toMatch( - /import type \{[^}]*UndefinedOnPartialDeep[^}]*\}\s+from\s+"type-fest"/, - ); - expect(mainText).not.toMatch(/import \{[^}]*type\s+OneCommandInput/); - expect(mainText).not.toMatch( - /import \{[^}]*type\s+UndefinedOnPartialDeep[^}]*\}\s+from\s+"type-fest"/, - ); + await expectGenerated([result.mainFile]); }); test("optional query params do not carry `| undefined` in their property type", async () => { @@ -77,15 +68,8 @@ test("optional query params do not carry `| undefined` in their property type", }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const typesText = result.typesFile.getText(); - const queryBlock = typesText.match( - /export type ListFilesCommandQuery = \{[\s\S]*?\};/, - )?.[0]; - assert.isDefined(queryBlock, "ListFilesCommandQuery"); - - expect(queryBlock).toContain("purpose?: string"); - expect(queryBlock).toContain("limit?: `${number}`"); - expect(queryBlock).not.toContain("undefined"); + + await expectGenerated([result.typesFile]); }); test("AllInputs union carries every command that takes an input", async () => { @@ -136,113 +120,50 @@ test("AllInputs union carries every command that takes an input", async () => { }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const mainText = result.mainFile.getText(); - const commandsText = result.commandsFile.getText(); - const typesText = result.typesFile.getText(); - - const allInputsBlock = mainText.match(/type AllInputs =[\s\S]*?;/)?.[0]; - assert.isDefined(allInputsBlock, "AllInputs"); - - const commandNames = [ - ...commandsText.matchAll(/^export class (\w+Command) extends Command { - assert.isDefined(name, "command name"); - - return name; - }); - - expect(commandNames.length).toBeGreaterThan(0); - - // a command declaring only responses gets an input of `never` - const hasNeverInput = (name: string) => - typesText.includes(`export type ${name}Input = never;`); - const missing = commandNames - .filter((name) => !hasNeverInput(name)) - .filter((name) => !allInputsBlock.includes(`${name}Input`)); - - expect(missing).toEqual([]); - - // `never` adds nothing to the union, and the three empty paths above put - // it on these three commands - const emptyCommands = commandNames.filter((name) => hasNeverInput(name)); - - expect(emptyCommands.toSorted()).toEqual([ - "AlphaCommand", - "BetaCommand", - "GammaCommand", - ]); - - const carried = emptyCommands.filter((name) => - allInputsBlock.includes(`${name}Input`), - ); - - expect(carried).toEqual([]); + await expectGenerated([result.mainFile]); }); // An empty schema permits any value, so the keys outside `properties` are // unconstrained and the object is loose -test("additionalProperties chooses the object schema", async () => { - const cases = [ - [{}, "v.looseObject("], - [{ type: "string" }, "v.objectWithRest("], - [false, "v.strictObject("], - [true, "v.looseObject("], - ] as const; - - const emitted = await Promise.all( - cases.map(async ([additionalProperties]) => { - const result = await processOpenApiDocument( - "/tmp/additional-properties", - docWithSchema("Open", { - type: "object", - properties: { a: { type: "string" } }, - additionalProperties, - }), - ); - - const text = result.valibotFile.getText(); - - return text.slice(text.indexOf("export const openSchema"), -1); - }), - ); +test.for([ + ["empty schema", {}], + ["string schema", { type: "string" }], + ["false", false], + ["true", true], +] as const)( + "additionalProperties chooses the object schema: %s", + async ([, additionalProperties]) => { + const result = await processOpenApiDocument( + "/tmp/additional-properties", + docWithSchema("Open", { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties, + }), + ); - for (const [index, [, expected]] of cases.entries()) { - expect(emitted[index]).toContain(expected); - } -}); + await expectGenerated([result.valibotFile]); + }, +); // A one-member `anyOf` or `oneOf` is that member. `v.union` of one option // only wraps its issues, and the block65 valibot rules reject it -test("a single-member combinator emits the member alone", async () => { - const cases: [oas31.SchemaObject, string][] = [ - [{ anyOf: [{ type: "string" }] }, "v.string()"], - [{ oneOf: [{ type: "string" }] }, "v.string()"], - [ - { oneOf: [{ type: "string" }, { type: "number" }] }, - "v.union([v.string(), v.number()])", - ], - ]; - - const emitted = await Promise.all( - cases.map(async ([schema]) => { - const result = await processOpenApiDocument( - "/tmp/single-member-combinator", - docWithSchema("Only", schema), - ); - - const text = result.valibotFile.getText(); - - return text.slice(text.indexOf("export const inputOnlySchema"), -1); - }), - ); - - for (const [index, [, expected]] of cases.entries()) { - expect(emitted[index]).toContain( - `export const inputOnlySchema = ${expected};`, +test.for<[string, oas31.SchemaObject]>([ + ["anyOf", { anyOf: [{ type: "string" }] }], + ["oneOf", { oneOf: [{ type: "string" }] }], + ["two-member oneOf", { oneOf: [{ type: "string" }, { type: "number" }] }], +])( + "a single-member combinator emits the member alone: %s", + async ([, schema]) => { + const result = await processOpenApiDocument( + "/tmp/single-member-combinator", + docWithSchema("Only", schema), ); - } -}); + + await expectGenerated([result.valibotFile]); + }, +); function docWithSchema(name: string, schema: oas31.SchemaObject) { return { @@ -278,12 +199,7 @@ test("additionalProperties types the record value instead of widening to unknown }), ); - expect(result.typesFile.getText()).toContain( - "Record", - ); - expect(result.valibotFile.getText()).toContain( - "v.record(v.string(), v.string())", - ); + await expectGenerated([result.typesFile, result.valibotFile]); }); test("additionalProperties alongside properties keeps the extra keys valid", async () => { @@ -296,10 +212,7 @@ test("additionalProperties alongside properties keeps the extra keys valid", asy }), ); - const valibotText = result.valibotFile.getText(); - - expect(valibotText).toContain("v.objectWithRest("); - expect(valibotText).not.toMatch(/v\.strictObject\(\{\s*name:/); + await expectGenerated([result.valibotFile]); }); test("additionalProperties true accepts any key", async () => { @@ -312,7 +225,7 @@ test("additionalProperties true accepts any key", async () => { }), ); - expect(result.valibotFile.getText()).toContain("v.looseObject("); + await expectGenerated([result.valibotFile]); }); test("an empty properties bag is a record, not an empty object type", async () => { @@ -321,10 +234,7 @@ test("an empty properties bag is a record, not an empty object type", async () = docWithSchema("Empty", { type: "object", properties: {} }), ); - const typesText = result.typesFile.getText(); - - expect(typesText).toContain("Record"); - expect(typesText).not.toMatch(/=\s*\{\};/); + await expectGenerated([result.typesFile]); }); test("the generated JSON body type is PascalCase", async () => { @@ -353,7 +263,7 @@ test("the generated JSON body type is PascalCase", async () => { const result = await processOpenApiDocument("/tmp/whatever", schema); - expect(result.typesFile.getText()).toContain("type CreateBatchJsonBody"); + await expectGenerated([result.typesFile]); }); test("an operation with both a 200 and a 204 emits one output type argument", async () => { @@ -384,15 +294,8 @@ test("an operation with both a 200 and a 204 emits one output type argument", as }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const extendsClause = - result.commandsFile - .getClass("SystemAuthCommand") - ?.getExtends() - ?.getTypeArguments() - .map((arg) => arg.getText()) ?? []; - - expect(extendsClause).not.toContain("undefined"); - expect(extendsClause.length).toBeLessThanOrEqual(2); + + await expectGenerated([result.commandsFile]); }); test("an array request body with parameters stays readable as both", async () => { @@ -425,16 +328,8 @@ test("an array request body with parameters stays readable as both", async () => }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const inputBlock = result.typesFile - .getTypeAlias("PluginPullCommandInput") - ?.getTypeNode() - ?.getText(); - assert.isDefined(inputBlock, "PluginPullCommandInput"); - - expect(inputBlock).toContain("PluginPullCommandBodyWrapper"); - expect(result.commandsFile.getText()).toMatch( - /const \{\s*remote,\s*body\s*\} = input/, - ); + + await expectGenerated([result.typesFile, result.commandsFile]); }); test("nested query param members get the same stringish treatment as top-level ones", async () => { @@ -480,19 +375,8 @@ test("nested query param members get the same stringish treatment as top-level o }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const queryBlock = result.typesFile - .getTypeAlias("SearchCommandQuery") - ?.getTypeNode() - ?.getText(); - assert.isDefined(queryBlock, "SearchCommandQuery"); - - expect(queryBlock).toMatch(/limit\?: `\$\{number\}`/); - expect(queryBlock).toMatch(/"age"\?: `\$\{number\}`/); - expect(queryBlock).toMatch(/"big"\?: `\$\{bigint\}`/); - expect(queryBlock).toMatch(/"active"\?: "true" \| "false"/); - expect(queryBlock).toMatch(/"label"\?: string/); - expect(queryBlock).toMatch(/ids\?: readonly \(`\$\{number\}`\)\[\]/); - expect(queryBlock).not.toMatch(/"age"\?: number/); + + await expectGenerated([result.typesFile]); }); test("json request body members keep their real JSON types, nested included", async () => { @@ -531,21 +415,12 @@ test("json request body members keep their real JSON types, nested included", as }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const bodyBlock = result.typesFile - .getTypeAlias("CreateContainerCommandJsonBody") - ?.getTypeNode() - ?.getText(); - assert.isDefined(bodyBlock, "CreateContainerCommandJsonBody"); - - expect(bodyBlock).toMatch(/"tty"\?: boolean/); - expect(bodyBlock).toMatch(/"retries"\?: number/); - expect(bodyBlock).toMatch(/"interval"\?: bigint/); - expect(bodyBlock).toMatch(/"enabled"\?: boolean/); - expect(bodyBlock).toMatch(/"sizes"\?: readonly \(number\)\[\]/); - expect(bodyBlock).not.toContain('"true" | "false"'); - expect(bodyBlock).not.toContain("`${number}`"); + + await expectGenerated([result.typesFile]); }); +// Composition has to forward the codegen options the same way the array and +// object branches do, or a oneOf collapses back to the JSON types test("a oneOf query param keeps the stringish wire types in every branch", async () => { const schema: oas31.OpenAPIObject = { openapi: "3.1.0", @@ -592,17 +467,6 @@ test("a oneOf query param keeps the stringish wire types in every branch", async }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const queryBlock = result.typesFile - .getTypeAlias("ListThingsCommandQuery") - ?.getTypeNode() - ?.getText(); - assert.isDefined(queryBlock, "ListThingsCommandQuery"); - - // Composition has to forward the codegen options the same way the array - // and object branches do, or a oneOf collapses back to the JSON types - expect(queryBlock).toContain("`${number}`"); - expect(queryBlock).toContain('"true" | "false"'); - expect(queryBlock).not.toMatch(/size\?: number/); - expect(queryBlock).not.toMatch(/flag\?: boolean/); - expect(queryBlock).not.toMatch(/"count"\?: number/); + + await expectGenerated([result.typesFile]); }); diff --git a/__tests__/generated-snapshot.ts b/__tests__/generated-snapshot.ts new file mode 100644 index 0000000..2d7d997 --- /dev/null +++ b/__tests__/generated-snapshot.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import { expect } from "vitest"; + +type GeneratedFile = { getBaseName(): string; getText(): string }; + +function slug(text: string) { + return text + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/^-|-$/g, ""); +} + +/** + * Snapshots each file into a folder named for the current test, so a generator + * change reviews as a diff of the code it emits + */ +export async function expectGenerated(files: GeneratedFile[]) { + const { currentTestName, testPath } = expect.getState(); + const folder = `${path.basename(testPath ?? "unknown", ".test.ts")}/${slug(currentTestName ?? "unknown")}`; + + await Promise.all( + files.map((file) => + expect(file.getText()).toMatchFileSnapshot( + `__snapshots__/${folder}/${file.getBaseName()}`, + ), + ), + ); +} diff --git a/__tests__/nullables.test.ts b/__tests__/nullables.test.ts index 6c3311e..9fff29f 100644 --- a/__tests__/nullables.test.ts +++ b/__tests__/nullables.test.ts @@ -1,6 +1,7 @@ import type { oas31 } from "openapi3-ts"; -import { expect, test } from "vitest"; +import { test } from "vitest"; import { processOpenApiDocument } from "../lib/process-document.ts"; +import { expectGenerated } from "./generated-snapshot.ts"; test("nullables", async () => { const result = await processOpenApiDocument( @@ -28,7 +29,7 @@ test("nullables", async () => { [], ); - expect(result.typesFile.getText()).toMatchSnapshot("types"); + await expectGenerated([result.typesFile]); }); test("top-level type array with null", async () => { @@ -62,9 +63,11 @@ test("top-level type array with null", async () => { [], ); - expect(result.typesFile.getText()).toMatchSnapshot("types"); - expect(result.valibotFile?.getText()).toMatchSnapshot("valibot"); - expect(result.enumsFile.getText()).toMatchSnapshot("enums"); + await expectGenerated([ + result.typesFile, + result.valibotFile, + result.enumsFile, + ]); }); test("const values", async () => { @@ -100,8 +103,7 @@ test("const values", async () => { [], ); - expect(result.typesFile.getText()).toMatchSnapshot("types"); - expect(result.valibotFile?.getText()).toMatchSnapshot("valibot"); + await expectGenerated([result.typesFile, result.valibotFile]); }); test("RFC 3339 temporal formats", async () => { @@ -119,31 +121,7 @@ test("RFC 3339 temporal formats", async () => { }, }); - const types = result.typesFile.getText(); - - // Template-literal string types capture the digit shape for every format - expect(types).toContain( - "export type MyDate = `${number}-${number}-${number}`", - ); - expect(types).toContain( - "export type MyTime = `${number}:${number}:${number}${string}`", - ); - expect(types).toContain( - "export type MyDateTime = `${number}-${number}-${number}T${number}:${number}:${number}${string}`", - ); - expect(types).toContain("export type MyDuration = `P${string}`"); - - const valibot = result.valibotFile.getText(); - - // Each format gets a runtime regex plus a v.custom<...> type hint, in both - // the input and wire schema (8 of each across the four formats) - expect(valibot.match(/v\.regex\(/g)?.length).toBe(8); - expect(valibot.match(/v\.custom(() => true)", - ); + await expectGenerated([result.typesFile, result.valibotFile]); }); test("enums short-circuit type constraints (picklist only)", async () => { @@ -171,18 +149,7 @@ test("enums short-circuit type constraints (picklist only)", async () => { }, }); - const valibot = result.valibotFile.getText(); - expect(valibot).toContain( - "export const inputIntegerEnumSchema = v.picklist([0, 1, 2])", - ); - expect(valibot).toContain( - 'export const inputStringEnumSchema = v.picklist(["a@example.com", "b@example.com"])', - ); - - // No leftover type-specific constraints leaked onto the enums - expect(valibot).not.toContain("v.minValue"); - expect(valibot).not.toContain("v.minLength"); - expect(valibot).not.toContain("v.email"); + await expectGenerated([result.valibotFile]); }); test("oneOf with type null generates v.null()", async () => { @@ -199,7 +166,7 @@ test("oneOf with type null generates v.null()", async () => { }, }); - expect(result.valibotFile.getText()).toMatchSnapshot("valibot"); + await expectGenerated([result.valibotFile]); }); test("query and header integer params coerce strings to numbers", async () => { @@ -274,8 +241,7 @@ test("query and header integer params coerce strings to numbers", async () => { schema, ); - expect(result.typesFile.getText()).toMatchSnapshot("types"); - expect(result.valibotFile.getText()).toMatchSnapshot("valibot"); + await expectGenerated([result.typesFile, result.valibotFile]); }); test("header parameters", async () => { @@ -354,13 +320,13 @@ test("header parameters", async () => { schema, ); - expect(result.typesFile.getText()).toMatchSnapshot("types"); - expect(result.commandsFile.getText()).toMatchSnapshot("commands"); - expect(result.commandsValidatedFile.getText()).toMatchSnapshot( - "commands-validated", - ); - expect(result.valibotFile.getText()).toMatchSnapshot("valibot"); - expect(result.honoFile.getText()).toMatchSnapshot("hono"); + await expectGenerated([ + result.typesFile, + result.commandsFile, + result.commandsValidatedFile, + result.valibotFile, + result.honoFile, + ]); }); test("input-only mode omits wire schemas", async () => { @@ -412,14 +378,5 @@ test("input-only mode omits wire schemas", async () => { { inputOnly: true }, ); - const valibotText = result.valibotFile.getText(); - - expect(valibotText).toContain("inputNameSchema"); - expect(valibotText).toContain("inputAmountSchema"); - expect(valibotText).not.toMatch(/export const nameSchema\b/); - expect(valibotText).not.toMatch(/export const amountSchema\b/); - expect(valibotText).not.toContain("v.trim()"); - expect(valibotText).not.toContain("v.toNumber()"); - expect(valibotText).not.toContain("v.toBigint()"); - expect(valibotText).not.toMatch(/export const listItemsCommandQuerySchema\b/); + await expectGenerated([result.valibotFile]); }); diff --git a/__tests__/tsconfig.json b/__tests__/tsconfig.json index 042c135..f1dc771 100644 --- a/__tests__/tsconfig.json +++ b/__tests__/tsconfig.json @@ -1,5 +1,6 @@ { "extends": "../tsconfig.json", - "include": ["../bin", "../lib", "."] + "include": ["../bin", "../lib", "."], + "exclude": ["__snapshots__", "node_modules"] } diff --git a/__tests__/typescript-hint.test.ts b/__tests__/typescript-hint.test.ts index 3aa055a..9033bc6 100644 --- a/__tests__/typescript-hint.test.ts +++ b/__tests__/typescript-hint.test.ts @@ -1,6 +1,7 @@ import type { oas31 } from "openapi3-ts"; -import { expect, test } from "vitest"; +import { test } from "vitest"; import { processOpenApiDocument } from "../lib/process-document.ts"; +import { expectGenerated } from "./generated-snapshot.ts"; const baseDoc = { openapi: "3.1.0" as const, @@ -26,12 +27,7 @@ test("x-typescript-hint on top-level string schema", async () => { schema, ); - const typesText = result.typesFile.getText(); - - expect(typesText).toContain( - "export type EmbedUrl = `https://embed.example.com/${string}`;", - ); - expect(typesText).not.toMatch(/export type EmbedUrl = string;/); + await expectGenerated([result.typesFile]); }); test("x-typescript-hint honored inside oneOf branches", async () => { @@ -55,12 +51,7 @@ test("x-typescript-hint honored inside oneOf branches", async () => { schema, ); - const typesText = result.typesFile.getText(); - - expect(typesText).toMatch( - /export type EventSource =\s*"native" \| EmbedUrl \| SyndicatedUrl;/, - ); - expect(typesText).not.toMatch(/EventSource = "native" \| string/); + await expectGenerated([result.typesFile]); }); test("x-typescript-hint honored inside anyOf branches", async () => { @@ -83,7 +74,5 @@ test("x-typescript-hint honored inside anyOf branches", async () => { schema, ); - expect(result.typesFile.getText()).toMatch( - /export type MaybeUrl =\s*AbsoluteUrl \| RelativeUrl;/, - ); + await expectGenerated([result.typesFile]); }); diff --git a/oxlint.config.ts b/oxlint.config.ts index dea004e..a2cc905 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from "@block65/shared-config/oxlint"; export default defineConfig({ + // generated code under test, compared byte for byte + ignorePatterns: ["__tests__/__snapshots__/**"], + // a consumer's config enables the valibot group, so the fixtures lint under it groups: { vitest: "on", valibot: "on" },