diff --git a/.changeset/loud-socks-doubt.md b/.changeset/loud-socks-doubt.md new file mode 100644 index 000000000000..4af3b4607703 --- /dev/null +++ b/.changeset/loud-socks-doubt.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a case where Astro's config `experimental.env.schema` keys did not allow numbers. Numbers are still not allowed as the first character to be able to generate valid JavaScript identifiers diff --git a/.changeset/nasty-poems-juggle.md b/.changeset/nasty-poems-juggle.md new file mode 100644 index 000000000000..74e1b176d036 --- /dev/null +++ b/.changeset/nasty-poems-juggle.md @@ -0,0 +1,18 @@ +--- +'astro': patch +--- + +Expands the `isInputError()` utility from `astro:actions` to accept errors of any type. This should now allow type narrowing from a try / catch block. + +```ts +// example.ts +import { actions, isInputError } from 'astro:actions'; + +try { + await actions.like(new FormData()); +} catch (error) { + if (isInputError(error)) { + console.log(error.fields); + } +} +``` diff --git a/.changeset/plenty-socks-talk.md b/.changeset/plenty-socks-talk.md new file mode 100644 index 000000000000..2749228dd952 --- /dev/null +++ b/.changeset/plenty-socks-talk.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Exposes utility types from `astro:actions` for the `defineAction` handler (`ActionHandler`) and the `ActionError` code (`ActionErrorCode`). diff --git a/.changeset/slow-roses-call.md b/.changeset/slow-roses-call.md new file mode 100644 index 000000000000..9217f96fe965 --- /dev/null +++ b/.changeset/slow-roses-call.md @@ -0,0 +1,23 @@ +--- +'astro': patch +--- + +Adds a new property `experimental.env.validateSecrets` to allow validating private variables on the server. + +By default, this is set to `false` and only public variables are checked on start. If enabled, secrets will also be checked on start (dev/build modes). This is useful for example in some CIs to make sure all your secrets are correctly set before deploying. + +```js +// astro.config.mjs +import { defineConfig, envField } from "astro/config" + +export default defineConfig({ + experimental: { + env: { + schema: { + // ... + }, + validateSecrets: true + } + } +}) +``` diff --git a/.changeset/small-vans-own.md b/.changeset/small-vans-own.md new file mode 100644 index 000000000000..06352e256af0 --- /dev/null +++ b/.changeset/small-vans-own.md @@ -0,0 +1,29 @@ +--- +'astro': patch +--- + +Expose new `ActionReturnType` utility from `astro:actions`. This infers the return type of an action by passing `typeof actions.name` as a type argument. This example defines a `like` action that returns `likes` as an object: + +```ts +// actions/index.ts +import { defineAction } from 'astro:actions'; + +export const server = { + like: defineAction({ + handler: () => { + /* ... */ + return { likes: 42 } + } + }) +} +``` + +In your client code, you can infer this handler return value with `ActionReturnType`: + +```ts +// client.ts +import { actions, ActionReturnType } from 'astro:actions'; + +type LikesResult = ActionReturnType; +// -> { likes: number } +``` diff --git a/.changeset/swift-cows-walk.md b/.changeset/swift-cows-walk.md new file mode 100644 index 000000000000..212d0417e699 --- /dev/null +++ b/.changeset/swift-cows-walk.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fixes `astro:actions` autocompletion for the `defineAction` `accept` property diff --git a/package.json b/package.json index 4d477912b2ba..e99e740da097 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:citgm": "pnpm -r --filter=astro test", "test:match": "cd packages/astro && pnpm run test:match", "test:unit": "cd packages/astro && pnpm run test:unit", + "test:types": "cd packages/astro && pnpm run test:types", "test:unit:match": "cd packages/astro && pnpm run test:unit:match", "test:smoke": "pnpm test:smoke:example && pnpm test:smoke:docs", "test:smoke:example": "turbo run build --concurrency=100% --filter=\"@example/*\"", diff --git a/packages/astro/package.json b/packages/astro/package.json index 21fd52eb980f..78d7d017244e 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -115,12 +115,13 @@ "build:ci": "pnpm run prebuild && astro-scripts build \"src/**/*.{ts,js}\" && pnpm run postbuild", "dev": "astro-scripts dev --copy-wasm --prebuild \"src/runtime/server/astro-island.ts\" --prebuild \"src/runtime/client/{idle,load,media,only,visible}.ts\" \"src/**/*.{ts,js}\"", "postbuild": "astro-scripts copy \"src/**/*.astro\" && astro-scripts copy \"src/**/*.wasm\"", - "test": "pnpm run test:node", + "test": "pnpm run test:node && pnpm run test:types", "test:match": "pnpm run test:node --match", "test:e2e": "pnpm test:e2e:chrome && pnpm test:e2e:firefox", "test:e2e:match": "playwright test -g", "test:e2e:chrome": "playwright test", "test:e2e:firefox": "playwright test --config playwright.firefox.config.js", + "test:types": "tsc --project tsconfig.tests.json", "test:node": "astro-scripts test \"test/**/*.test.js\"" }, "dependencies": { @@ -220,6 +221,7 @@ "astro-scripts": "workspace:*", "cheerio": "1.0.0-rc.12", "eol": "^0.9.1", + "expect-type": "^0.19.0", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.2", "memfs": "^4.9.3", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index d8ba590297ee..e624b69c8d37 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -11,7 +11,11 @@ import type { import type * as babel from '@babel/core'; import type * as rollup from 'rollup'; import type * as vite from 'vite'; -import type { Accept, ActionClient, InputSchema } from '../actions/runtime/virtual/server.js'; +import type { + ActionAccept, + ActionClient, + ActionInputSchema, +} from '../actions/runtime/virtual/server.js'; import type { RemotePattern } from '../assets/utils/remotePattern.js'; import type { AssetsPrefix, SerializedSSRManifest } from '../core/app/types.js'; import type { PageBuildData } from '../core/build/types.js'; @@ -2166,6 +2170,37 @@ export interface AstroUserConfig { * ``` */ schema?: EnvSchema; + + /** + * @docs + * @name experimental.env.validateSecrets + * @kind h4 + * @type {boolean} + * @default `false` + * @version 4.11.6 + * @description + * + * Whether or not to validate secrets on the server when starting the dev server or running a build. + * + * By default, only public variables are validated on the server when starting the dev server or a build, and private variables are validated at runtime only. If enabled, private variables will also be checked on start. This is useful in some continuous integration (CI) pipelines to make sure all your secrets are correctly set before deploying. + * + * ```js + * // astro.config.mjs + * import { defineConfig, envField } from "astro/config" + * + * export default defineConfig({ + * experimental: { + * env: { + * schema: { + * // ... + * }, + * validateSecrets: true + * } + * } + * }) + * ``` + */ + validateSecrets?: boolean; }; }; } @@ -2733,8 +2768,8 @@ interface AstroSharedContext< * Get action result on the server when using a form POST. */ getActionResult: < - TAccept extends Accept, - TInputSchema extends InputSchema, + TAccept extends ActionAccept, + TInputSchema extends ActionInputSchema, TAction extends ActionClient, >( action: TAction diff --git a/packages/astro/src/actions/runtime/utils.ts b/packages/astro/src/actions/runtime/utils.ts index eac9b92cf99e..02961144b4e2 100644 --- a/packages/astro/src/actions/runtime/utils.ts +++ b/packages/astro/src/actions/runtime/utils.ts @@ -31,3 +31,14 @@ export async function getAction( } return actionLookup; } + +/** + * Used to preserve the input schema type in the error object. + * This allows for type inference on the `fields` property + * when type narrowed to an `ActionInputError`. + * + * Example: Action has an input schema of `{ name: z.string() }`. + * When calling the action and checking `isInputError(result.error)`, + * `result.error.fields` will be typed with the `name` field. + */ +export type ErrorInferenceObject = Record; diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts index ce4d5f69662b..326bbf4f9be6 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -1,13 +1,7 @@ import { z } from 'zod'; import { type ActionAPIContext, getApiContext as _getApiContext } from '../store.js'; -import { type MaybePromise } from '../utils.js'; -import { - ActionError, - ActionInputError, - type ErrorInferenceObject, - type SafeResult, - callSafely, -} from './shared.js'; +import type { ErrorInferenceObject, MaybePromise } from '../utils.js'; +import { ActionError, ActionInputError, type SafeResult, callSafely } from './shared.js'; export * from './shared.js'; @@ -16,19 +10,21 @@ export { z } from 'zod'; /** @deprecated Access context from the second `handler()` parameter. */ export const getApiContext = _getApiContext; -export type Accept = 'form' | 'json'; -export type InputSchema = T extends 'form' +export type ActionAccept = 'form' | 'json'; +export type ActionInputSchema = T extends 'form' ? z.AnyZodObject | z.ZodType : z.ZodType; -type Handler = TInputSchema extends z.ZodType +export type ActionHandler = TInputSchema extends z.ZodType ? (input: z.infer, context: ActionAPIContext) => MaybePromise : (input: any, context: ActionAPIContext) => MaybePromise; +export type ActionReturnType> = Awaited>; + export type ActionClient< TOutput, - TAccept extends Accept, - TInputSchema extends InputSchema | undefined, + TAccept extends ActionAccept | undefined, + TInputSchema extends ActionInputSchema | undefined, > = TInputSchema extends z.ZodType ? (( input: TAccept extends 'form' ? FormData : z.input @@ -50,8 +46,8 @@ export type ActionClient< export function defineAction< TOutput, - TAccept extends Accept = 'json', - TInputSchema extends InputSchema | undefined = TAccept extends 'form' + TAccept extends ActionAccept | undefined = undefined, + TInputSchema extends ActionInputSchema | undefined = TAccept extends 'form' ? // If `input` is omitted, default to `FormData` for forms and `any` for JSON. z.ZodType : undefined, @@ -62,7 +58,7 @@ export function defineAction< }: { input?: TInputSchema; accept?: TAccept; - handler: Handler; + handler: ActionHandler; }): ActionClient { const serverHandler = accept === 'form' @@ -77,8 +73,8 @@ export function defineAction< return serverHandler as ActionClient; } -function getFormServerHandler>( - handler: Handler, +function getFormServerHandler>( + handler: ActionHandler, inputSchema?: TInputSchema ) { return async (unparsedInput: unknown): Promise> => { @@ -99,8 +95,8 @@ function getFormServerHandler> }; } -function getJsonServerHandler>( - handler: Handler, +function getJsonServerHandler>( + handler: ActionHandler, inputSchema?: TInputSchema ) { return async (unparsedInput: unknown): Promise> => { diff --git a/packages/astro/src/actions/runtime/virtual/shared.ts b/packages/astro/src/actions/runtime/virtual/shared.ts index 420682aad4fc..10ed75aa5174 100644 --- a/packages/astro/src/actions/runtime/virtual/shared.ts +++ b/packages/astro/src/actions/runtime/virtual/shared.ts @@ -1,20 +1,23 @@ import type { z } from 'zod'; -import type { MaybePromise } from '../utils.js'; - -type ActionErrorCode = - | 'BAD_REQUEST' - | 'UNAUTHORIZED' - | 'FORBIDDEN' - | 'NOT_FOUND' - | 'TIMEOUT' - | 'CONFLICT' - | 'PRECONDITION_FAILED' - | 'PAYLOAD_TOO_LARGE' - | 'UNSUPPORTED_MEDIA_TYPE' - | 'UNPROCESSABLE_CONTENT' - | 'TOO_MANY_REQUESTS' - | 'CLIENT_CLOSED_REQUEST' - | 'INTERNAL_SERVER_ERROR'; +import type { ErrorInferenceObject, MaybePromise } from '../utils.js'; + +export const ACTION_ERROR_CODES = [ + 'BAD_REQUEST', + 'UNAUTHORIZED', + 'FORBIDDEN', + 'NOT_FOUND', + 'TIMEOUT', + 'CONFLICT', + 'PRECONDITION_FAILED', + 'PAYLOAD_TOO_LARGE', + 'UNSUPPORTED_MEDIA_TYPE', + 'UNPROCESSABLE_CONTENT', + 'TOO_MANY_REQUESTS', + 'CLIENT_CLOSED_REQUEST', + 'INTERNAL_SERVER_ERROR', +] as const; + +export type ActionErrorCode = (typeof ACTION_ERROR_CODES)[number]; const codeToStatusMap: Record = { // Implemented from tRPC error code table @@ -40,8 +43,6 @@ const statusToCodeMap: Record = Object.entries(codeToSt {} ); -export type ErrorInferenceObject = Record; - export class ActionError extends Error { type = 'AstroActionError'; code: ActionErrorCode = 'INTERNAL_SERVER_ERROR'; @@ -85,6 +86,10 @@ export class ActionError export function isInputError( error?: ActionError +): error is ActionInputError; +export function isInputError(error?: unknown): error is ActionInputError; +export function isInputError( + error?: unknown | ActionError ): error is ActionInputError { return error instanceof ActionInputError; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 15025a765fd8..bb3130137eeb 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -89,6 +89,9 @@ export const ASTRO_CONFIG_DEFAULTS = { clientPrerender: false, globalRoutePriority: false, rewriting: false, + env: { + validateSecrets: false, + }, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -526,6 +529,10 @@ export const AstroConfigSchema = z.object({ env: z .object({ schema: EnvSchema.optional(), + validateSecrets: z + .boolean() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.experimental.env.validateSecrets), }) .strict() .optional(), diff --git a/packages/astro/src/env/schema.ts b/packages/astro/src/env/schema.ts index ec2e12827955..4052a1c8e181 100644 --- a/packages/astro/src/env/schema.ts +++ b/packages/astro/src/env/schema.ts @@ -81,14 +81,17 @@ const EnvFieldMetadata = z.union([ SecretServerEnvFieldMetadata, ]); -const KEY_REGEX = /^[A-Z_]+$/; +const EnvSchemaKey = z + .string() + .min(1) + .refine(([firstChar]) => isNaN(Number.parseInt(firstChar)), { + message: 'A valid variable name cannot start with a number.', + }) + .refine((str) => /^[A-Z0-9_]+$/.test(str), { + message: 'A valid variable name can only contain uppercase letters, numbers and underscores.', + }); -export const EnvSchema = z.record( - z.string().regex(KEY_REGEX, { - message: 'A valid variable name can only contain uppercase letters and underscores.', - }), - z.intersection(EnvFieldMetadata, EnvFieldType) -); +export const EnvSchema = z.record(EnvSchemaKey, z.intersection(EnvFieldMetadata, EnvFieldType)); // https://www.totaltypescript.com/concepts/the-prettify-helper type Prettify = { diff --git a/packages/astro/src/env/vite-plugin-env.ts b/packages/astro/src/env/vite-plugin-env.ts index 3b08d8c2ed1d..1bcb021e0f51 100644 --- a/packages/astro/src/env/vite-plugin-env.ts +++ b/packages/astro/src/env/vite-plugin-env.ts @@ -52,7 +52,11 @@ export function astroEnv({ } } - const validatedVariables = validatePublicVariables({ schema, loadedEnv }); + const validatedVariables = validatePublicVariables({ + schema, + loadedEnv, + validateSecrets: settings.config.experimental.env?.validateSecrets ?? false, + }); templates = { ...getTemplates(schema, fs, validatedVariables), @@ -94,23 +98,28 @@ function resolveVirtualModuleId(id: T): `\0${T}` { function validatePublicVariables({ schema, loadedEnv, + validateSecrets, }: { schema: EnvSchema; loadedEnv: Record; + validateSecrets: boolean; }) { const valid: Array<{ key: string; value: any; type: string; context: 'server' | 'client' }> = []; const invalid: Array<{ key: string; type: string }> = []; for (const [key, options] of Object.entries(schema)) { - if (options.access !== 'public') { + const variable = loadedEnv[key] === '' ? undefined : loadedEnv[key]; + + if (options.access === 'secret' && !validateSecrets) { continue; } - const variable = loadedEnv[key]; - const result = validateEnvVariable(variable === '' ? undefined : variable, options); - if (result.ok) { - valid.push({ key, value: result.value, type: result.type, context: options.context }); - } else { + + const result = validateEnvVariable(variable, options); + if (!result.ok) { invalid.push({ key, type: result.type }); + // We don't do anything with validated secrets so we don't store them + } else if (options.access === 'public') { + valid.push({ key, value: result.value, type: result.type, context: options.context }); } } diff --git a/packages/astro/test/env-secret.test.js b/packages/astro/test/env-secret.test.js index 562f011410aa..4505254a6b62 100644 --- a/packages/astro/test/env-secret.test.js +++ b/packages/astro/test/env-secret.test.js @@ -1,77 +1,90 @@ import assert from 'node:assert/strict'; -import { writeFileSync } from 'node:fs'; -import { after, describe, it } from 'node:test'; +import { afterEach, describe, it } from 'node:test'; import * as cheerio from 'cheerio'; import testAdapter from './test-adapter.js'; import { loadFixture } from './test-utils.js'; -describe('astro:env public variables', () => { +describe('astro:env secret variables', () => { /** @type {Awaited>} */ let fixture; - /** @type {Awaited>} */ - let app; - /** @type {Awaited>} */ + /** @type {Awaited> | undefined} */ let devServer = undefined; - describe('Server variables', () => { - after(async () => { - await devServer?.stop(); + afterEach(async () => { + await devServer?.stop(); + if (process.env.KNOWN_SECRET) { + delete process.env.KNOWN_SECRET; + } + }); + + it('works in dev', async () => { + process.env.KNOWN_SECRET = '5'; + fixture = await loadFixture({ + root: './fixtures/astro-env-server-secret/', }); + devServer = await fixture.startDevServer(); + const response = await fixture.fetch('/'); + assert.equal(response.status, 200); + }); - it('works in dev', async () => { - writeFileSync( - new URL('./fixtures/astro-env-server-secret/.env', import.meta.url), - 'KNOWN_SECRET=5', - 'utf-8' - ); - fixture = await loadFixture({ - root: './fixtures/astro-env-server-secret/', - }); - devServer = await fixture.startDevServer(); - const response = await fixture.fetch('/'); - assert.equal(response.status, 200); + it('builds without throwing', async () => { + fixture = await loadFixture({ + root: './fixtures/astro-env-server-secret/', + output: 'server', + adapter: testAdapter({ + env: { + KNOWN_SECRET: '123456', + UNKNOWN_SECRET: 'abc', + }, + }), }); + await fixture.build(); + assert.equal(true, true); + }); - it('builds without throwing', async () => { - fixture = await loadFixture({ - root: './fixtures/astro-env-server-secret/', - output: 'server', - adapter: testAdapter({ - env: { - KNOWN_SECRET: '123456', - UNKNOWN_SECRET: 'abc', - }, - }), - }); - await fixture.build(); - app = await fixture.loadTestAdapterApp(); - assert.equal(true, true); + it('adapter can set how env is retrieved', async () => { + fixture = await loadFixture({ + root: './fixtures/astro-env-server-secret/', + output: 'server', + adapter: testAdapter({ + env: { + KNOWN_SECRET: '123456', + UNKNOWN_SECRET: 'abc', + }, + }), }); + await fixture.build(); + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + assert.equal(response.status, 200); - it('adapter can set how env is retrieved', async () => { - fixture = await loadFixture({ - root: './fixtures/astro-env-server-secret/', - output: 'server', - adapter: testAdapter({ - env: { - KNOWN_SECRET: '123456', - UNKNOWN_SECRET: 'abc', - }, - }), - }); - await fixture.build(); - app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/'); - const response = await app.render(request); - assert.equal(response.status, 200); + const html = await response.text(); + const $ = cheerio.load(html); - const html = await response.text(); - const $ = cheerio.load(html); + const data = JSON.parse($('#data').text()); - const data = JSON.parse($('#data').text()); + assert.equal(data.KNOWN_SECRET, 123456); + assert.equal(data.UNKNOWN_SECRET, 'abc'); + }); - assert.equal(data.KNOWN_SECRET, 123456); - assert.equal(data.UNKNOWN_SECRET, 'abc'); + it('fails if validateSecrets is enabled and secret is not set', async () => { + fixture = await loadFixture({ + root: './fixtures/astro-env-server-secret/', + experimental: { + env: { + validateSecrets: true, + }, + }, }); + + try { + await fixture.build(); + assert.fail(); + } catch (error) { + assert.equal(error instanceof Error, true); + assert.equal(error.title, 'Invalid Environment Variables'); + assert.equal(error.message.includes('Variable KNOWN_SECRET is not of type: number.'), true); + } }); }); diff --git a/packages/astro/test/types/action-return-type.ts b/packages/astro/test/types/action-return-type.ts new file mode 100644 index 000000000000..4ca66eed25d4 --- /dev/null +++ b/packages/astro/test/types/action-return-type.ts @@ -0,0 +1,18 @@ +import { describe, it } from 'node:test'; +import { expectTypeOf } from 'expect-type'; +import { type ActionReturnType, defineAction } from '../../dist/actions/runtime/virtual/server.js'; +import { z } from '../../zod.mjs'; + +describe('ActionReturnType', () => { + it('Infers action return type', async () => { + const action = defineAction({ + input: z.object({ + name: z.string(), + }), + handler: async ({ name }) => { + return { name }; + }, + }); + expectTypeOf>().toEqualTypeOf<{ name: string }>(); + }); +}); diff --git a/packages/astro/test/types/define-action-accept.ts b/packages/astro/test/types/define-action-accept.ts new file mode 100644 index 000000000000..c9a9ca315a34 --- /dev/null +++ b/packages/astro/test/types/define-action-accept.ts @@ -0,0 +1,45 @@ +import { describe, it } from 'node:test'; +import { expectTypeOf } from 'expect-type'; +import { defineAction } from '../../dist/actions/runtime/virtual/server.js'; +import { z } from '../../zod.mjs'; + +describe('defineAction accept', () => { + it('accepts type `any` when input is omitted with accept json', async () => { + const action = defineAction({ + handler: () => {}, + }); + expectTypeOf(action).parameter(0).toBeAny(); + expectTypeOf(action).parameter(0).not.toEqualTypeOf(); + + const jsonAction = defineAction({ + accept: 'json', + handler: () => {}, + }); + expectTypeOf(jsonAction).parameter(0).toBeAny(); + expectTypeOf(jsonAction).parameter(0).not.toEqualTypeOf(); + }); + it('accepts type `FormData` when input is omitted with accept form', async () => { + const action = defineAction({ + accept: 'form', + handler: () => {}, + }); + expectTypeOf(action).parameter(0).toEqualTypeOf(); + }); + + it('accept type safe values for input with accept json', async () => { + const action = defineAction({ + input: z.object({ name: z.string() }), + handler: () => {}, + }); + expectTypeOf(action).parameter(0).toEqualTypeOf<{ name: string }>(); + }); + + it('accepts type `FormData` for all inputs with accept form', async () => { + const action = defineAction({ + accept: 'form', + input: z.object({ name: z.string() }), + handler: () => {}, + }); + expectTypeOf(action).parameter(0).toEqualTypeOf(); + }); +}); diff --git a/packages/astro/test/types/is-input-error.ts b/packages/astro/test/types/is-input-error.ts new file mode 100644 index 000000000000..2ab65e3d9e9f --- /dev/null +++ b/packages/astro/test/types/is-input-error.ts @@ -0,0 +1,31 @@ +import { describe, it } from 'node:test'; +import { expectTypeOf } from 'expect-type'; +import { defineAction, isInputError } from '../../dist/actions/runtime/virtual/server.js'; +import { z } from '../../zod.mjs'; + +const exampleAction = defineAction({ + input: z.object({ + name: z.string(), + }), + handler: () => {}, +}); + +const result = await exampleAction.safe({ name: 'Alice' }); + +describe('isInputError', () => { + it('isInputError narrows unknown error types', async () => { + try { + await exampleAction({ name: 'Alice' }); + } catch (e) { + if (isInputError(e)) { + expectTypeOf(e.fields).toEqualTypeOf>(); + } + } + }); + + it('`isInputError` preserves `fields` object type for ActionError objects', async () => { + if (isInputError(result.error)) { + expectTypeOf(result.error.fields).toEqualTypeOf<{ name?: string[] }>(); + } + }); +}); diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js index 13574982939c..4e4ba1e7a202 100644 --- a/packages/astro/test/units/config/config-validate.test.js +++ b/packages/astro/test/units/config/config-validate.test.js @@ -4,6 +4,7 @@ import stripAnsi from 'strip-ansi'; import { z } from 'zod'; import { validateConfig } from '../../../dist/core/config/validate.js'; import { formatConfigErrorMessage } from '../../../dist/core/messages.js'; +import { envField } from '../../../dist/env/config.js'; describe('Config Validation', () => { it('empty user config is valid', async () => { @@ -367,5 +368,42 @@ describe('Config Validation', () => { ).catch((err) => err) ); }); + + it('Should allow schema variables with numbers', () => { + assert.doesNotThrow(() => + validateConfig( + { + experimental: { + env: { + schema: { + ABC123: envField.string({ access: 'public', context: 'server' }), + }, + }, + }, + }, + process.cwd() + ).catch((err) => err) + ); + }); + + it('Should not allow schema variables starting with a number', async () => { + const configError = await validateConfig( + { + experimental: { + env: { + schema: { + '123ABC': envField.string({ access: 'public', context: 'server' }), + }, + }, + }, + }, + process.cwd() + ).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.errors[0].message, + 'A valid variable name cannot start with a number.' + ); + }); }); }); diff --git a/packages/astro/tsconfig.tests.json b/packages/astro/tsconfig.tests.json new file mode 100644 index 000000000000..1984bc4fe66a --- /dev/null +++ b/packages/astro/tsconfig.tests.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test/types"], + "compilerOptions": { + "allowJs": true, + "emitDeclarationOnly": false, + "noEmit": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bc02855e9cc..4d06fa8df6d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -806,6 +806,9 @@ importers: eol: specifier: ^0.9.1 version: 0.9.1 + expect-type: + specifier: ^0.19.0 + version: 0.19.0 mdast-util-mdx: specifier: ^3.0.0 version: 3.0.0 @@ -8712,6 +8715,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expect-type@0.19.0: + resolution: {integrity: sha512-piv9wz3IrAG4Wnk2A+n2VRCHieAyOSxrRLU872Xo6nyn39kYXKDALk4OcqnvLRnFvkz659CnWC8MWZLuuQnoqg==} + engines: {node: '>=12.0.0'} + express@4.19.2: resolution: {integrity: sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==} engines: {node: '>= 0.10.0'} @@ -9370,6 +9377,7 @@ packages: libsql@0.3.12: resolution: {integrity: sha512-to30hj8O3DjS97wpbKN6ERZ8k66MN1IaOfFLR6oHqd25GMiPJ/ZX0VaZ7w+TsPmxcFS3p71qArj/hiedCyvXCg==} + cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] lilconfig@2.1.0: @@ -14838,6 +14846,8 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expect-type@0.19.0: {} + express@4.19.2: dependencies: accepts: 1.3.8