From 2dcbb64bd07cbc964933587e381ae8a738de401b Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Tue, 15 Sep 2026 14:57:41 +0100 Subject: [PATCH] fix(kits): restore the extensions' param validation The kits dropped `required: true` and several `validationRegex` values from the predecessor extensions, so `firebase deploy` accepted an empty answer for 21 params the extension installer refuses, and skipped validation the extension applied. Add `nonEmpty` to the 21 params their extension marks required, and restore the five dropped regexes with their error messages. Fixes #3192 --- kits/delete-user-data/src/config.ts | 15 +++++ kits/delete-user-data/tests/config.test.ts | 46 +++++++++++-- kits/firestore-bigquery-export/src/config.ts | 5 +- .../tests/config-runtime.test.ts | 43 ++++++++++++ kits/firestore-bundle-builder/src/config.ts | 11 ++++ .../tests/config.test.ts | 65 +++++++++++++++++++ kits/firestore-genai-chatbot/src/config.ts | 16 ++++- .../tests/config.test.ts | 32 +++++++++ .../src/config.ts | 40 ++++++++++-- .../tests/config.test.ts | 54 +++++++++++++++ kits/firestore-send-email/src/config.ts | 3 +- .../firestore-send-email/tests/config.test.ts | 10 ++- kits/firestore-translate-text/src/config.ts | 6 +- .../tests/config.test.ts | 15 +++++ kits/firestore-vector-search/src/config.ts | 9 ++- .../tests/config.test.ts | 20 ++++++ .../tests/config.test.ts | 15 +++++ kits/speech-to-text/src/config.ts | 3 +- kits/speech-to-text/tests/config.test.ts | 13 ++++ 19 files changed, 401 insertions(+), 20 deletions(-) create mode 100644 kits/firestore-bigquery-export/tests/config-runtime.test.ts create mode 100644 kits/firestore-bundle-builder/tests/config.test.ts diff --git a/kits/delete-user-data/src/config.ts b/kits/delete-user-data/src/config.ts index c1ba33c3a7..04e3ea818a 100644 --- a/kits/delete-user-data/src/config.ts +++ b/kits/delete-user-data/src/config.ts @@ -44,6 +44,8 @@ const params = { 'The ID of the Firestore database to use. Use "(default)" for the default database. You can view your available Firestore databases at https://console.cloud.google.com/firestore/databases.', default: "(default)", + // `required: true` in the extension, which refuses an empty answer. + input: { text: { example: "(default)", nonEmpty: true } }, }), firestoreDeleteMode: defineString("FIRESTORE_DELETE_MODE", { label: "Cloud Firestore delete mode", @@ -99,6 +101,9 @@ const params = { text: { example: "my-project-12345.appspot.com", + // `required: true` in the extension. The regex alone accepts the empty + // string, so nonEmpty is what reproduces the extension's refusal. + nonEmpty: true, validationRegex: /^([0-9a-z_.-]*)$/, validationErrorMessage: "Invalid storage bucket", }, @@ -128,6 +133,16 @@ const params = { description: "If auto discovery is enabled, how deep should auto discovery find collections and documents. For example, setting to `1` would only discover root collections and documents, whereas setting to `9` would search sub-collections 9 levels deep. Defaults to `3`.", default: 3, + // `required: true` in the extension. `nonEmpty` is typed for string params + // only, so use the regex it is sugar for: an empty answer would otherwise + // resolve to 0 rather than the declared default of 3. + input: { + text: { + example: "3", + validationRegex: /.+/, + validationErrorMessage: "A non-empty value is required.", + }, + }, }), searchFields: defineString("AUTO_DISCOVERY_SEARCH_FIELDS", { label: "Auto discovery search fields", diff --git a/kits/delete-user-data/tests/config.test.ts b/kits/delete-user-data/tests/config.test.ts index a41c7b72ab..b2d693ebdb 100644 --- a/kits/delete-user-data/tests/config.test.ts +++ b/kits/delete-user-data/tests/config.test.ts @@ -52,10 +52,12 @@ const defineString = vi.fn( ); // Carries name so configFromEnv can look the variable up, as the real one does. -const defineInt = vi.fn((name: string, opts?: { default?: number }) => ({ - name, - value: () => opts?.default ?? 0, -})); +const defineInt = vi.fn( + (name: string, opts?: { default?: number; input?: unknown }) => ({ + name, + value: () => opts?.default ?? 0, + }) +); const select = vi.fn((options: Record) => ({ select: { @@ -220,3 +222,39 @@ describe("configFromEnv", () => { expect(configFromEnv().storageBucket).toBe("demo-test.appspot.com"); }); }); + +// Compatibility requirement: these are `required: true` in extension.yaml, so +// the extension's installer refuses an empty answer and re-prompts. Without +// the declarations below the CLI accepts an empty value and deploys it. +describe("params the extension marks required", () => { + test("refuse an empty value at the prompt", async () => { + await importConfig(); + + const options = new Map( + defineString.mock.calls.map(([name, opts]) => [name, opts]) + ); + for (const name of ["FIRESTORE_DATABASE_ID", "CLOUD_STORAGE_BUCKET"]) { + expect(options.get(name)).toMatchObject({ + input: { text: { nonEmpty: true } }, + }); + } + }); + + // `nonEmpty` is typed for string params only, so the int param uses the + // regex it is sugar for. An empty answer would otherwise resolve to 0. + test("refuse an empty AUTO_DISCOVERY_SEARCH_DEPTH", async () => { + await importConfig(); + + const options = new Map( + defineInt.mock.calls.map(([name, opts]) => [name, opts]) + ); + const regex = ( + options.get("AUTO_DISCOVERY_SEARCH_DEPTH") as { + input?: { text?: { validationRegex?: RegExp } }; + } + )?.input?.text?.validationRegex; + + expect(regex?.test("")).toBe(false); + expect(regex?.test("3")).toBe(true); + }); +}); diff --git a/kits/firestore-bigquery-export/src/config.ts b/kits/firestore-bigquery-export/src/config.ts index 81a913e2e8..9785aadbdb 100644 --- a/kits/firestore-bigquery-export/src/config.ts +++ b/kits/firestore-bigquery-export/src/config.ts @@ -120,13 +120,16 @@ const params = { "Override the default project for BigQuery instance. This can allow updates to be directed to to a BigQuery instance on another GCP project.", default: projectID, + // `required: true` in the extension, which refuses an empty answer. + input: { text: { nonEmpty: true } }, }), database: defineString("DATABASE", { label: "Firestore Instance ID", description: 'The Firestore database to use. Use "(default)" for the default database. You can view your available Firestore databases at https://console.cloud.google.com/firestore/databases.', default: "(default)", - input: { text: { example: "(default)" } }, + // `required: true` in the extension, which refuses an empty answer. + input: { text: { example: "(default)", nonEmpty: true } }, }), // Declared so the CLI prompts for the value and persists it to `.env`; the // function region option cannot be a param expression, so the entry point diff --git a/kits/firestore-bigquery-export/tests/config-runtime.test.ts b/kits/firestore-bigquery-export/tests/config-runtime.test.ts new file mode 100644 index 0000000000..d09634a046 --- /dev/null +++ b/kits/firestore-bigquery-export/tests/config-runtime.test.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { declaredParams } from "firebase-functions/params"; +import { describe, expect, test } from "vitest"; +import "../src/config"; + +// config.test.ts fakes firebase-functions/params and drops the declaration +// options, so these cases read the real declarations instead. +function text(name: string): Record { + const param = declaredParams.find((candidate) => candidate.name === name) as + | { options?: { input?: { text?: Record } } } + | undefined; + + return param?.options?.input?.text ?? {}; +} + +/** + * Compatibility requirement: extension.yaml marks both params `required: true`, + * so the extension's installer refuses an empty answer and re-prompts. The CLI + * enforces that for a kit only when the declaration says `nonEmpty`. + */ +describe("params the extension marks required", () => { + test.each(["BIGQUERY_PROJECT_ID", "DATABASE"])( + "%s refuses an empty value at the prompt", + (name) => { + expect(text(name).nonEmpty).toBe(true); + } + ); +}); diff --git a/kits/firestore-bundle-builder/src/config.ts b/kits/firestore-bundle-builder/src/config.ts index 5bfb3bc08e..a6347fb5b0 100644 --- a/kits/firestore-bundle-builder/src/config.ts +++ b/kits/firestore-bundle-builder/src/config.ts @@ -27,9 +27,20 @@ import type { BundleBuilderConfig } from "./export-config"; const params = { bundleSpecCollection: defineString("BUNDLESPEC_COLLECTION", { default: "bundles", + // `required: true` in the extension, which refuses an empty answer. + input: { text: { example: "bundles", nonEmpty: true } }, }), bundleStorageBucket: defineString("BUNDLE_STORAGE_BUCKET", { default: storageBucket, + // Extension regex, kept verbatim. The param is optional there and the + // regex already matches the empty string, so no empty branch is needed. + input: { + text: { + example: "my-project-12345.appspot.com", + validationRegex: /^([0-9a-z_.-]*)$/, + validationErrorMessage: "Invalid storage bucket", + }, + }, }), storagePrefix: defineString("STORAGE_PREFIX", { default: "bundles" }), }; diff --git a/kits/firestore-bundle-builder/tests/config.test.ts b/kits/firestore-bundle-builder/tests/config.test.ts new file mode 100644 index 0000000000..3ff9f0b34d --- /dev/null +++ b/kits/firestore-bundle-builder/tests/config.test.ts @@ -0,0 +1,65 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { declaredParams } from "firebase-functions/params"; +import { describe, expect, test } from "vitest"; +import "../src/config"; + +function text(name: string): Record { + const param = declaredParams.find((candidate) => candidate.name === name) as + | { options?: { input?: { text?: Record } } } + | undefined; + + return param?.options?.input?.text ?? {}; +} + +// `Param.toSpec()` rewrites a declared RegExp to its source string in place, +// so a declaration read after discovery can hold either form. +function validationRegex(name: string): RegExp { + const declared = text(name).validationRegex as RegExp | string; + + return typeof declared === "string" ? new RegExp(declared) : declared; +} + +/** + * Compatibility requirement: the extension marks `BUNDLESPEC_COLLECTION` + * `required: true`, so its installer refuses an empty answer and re-prompts, + * and it validates `BUNDLE_STORAGE_BUCKET` against a regex the kit had + * dropped. The CLI enforces either one for a kit only when the declaration + * carries it. + */ +describe("validation inherited from the extension", () => { + test("BUNDLESPEC_COLLECTION refuses an empty value at the prompt", () => { + expect(text("BUNDLESPEC_COLLECTION").nonEmpty).toBe(true); + }); + + test("BUNDLE_STORAGE_BUCKET keeps the extension's bucket validation", () => { + const regex = validationRegex("BUNDLE_STORAGE_BUCKET"); + + expect(regex.source).toBe(/^([0-9a-z_.-]*)$/.source); + expect(regex.test("my-project-12345.appspot.com")).toBe(true); + expect(regex.test("My Bucket")).toBe(false); + }); + + // The extension leaves the bucket optional and its regex matches "", so the + // kit must not tighten it: an existing .env may carry an empty value. + test("BUNDLE_STORAGE_BUCKET still accepts an empty value", () => { + const regex = validationRegex("BUNDLE_STORAGE_BUCKET"); + + expect(regex.test("")).toBe(true); + expect(text("BUNDLE_STORAGE_BUCKET").nonEmpty).toBeUndefined(); + }); +}); diff --git a/kits/firestore-genai-chatbot/src/config.ts b/kits/firestore-genai-chatbot/src/config.ts index d6b0bd7514..3cd6acba42 100644 --- a/kits/firestore-genai-chatbot/src/config.ts +++ b/kits/firestore-genai-chatbot/src/config.ts @@ -115,6 +115,16 @@ const params = { description: "Input the name of the Gemini model you would like to use. To view available models for each provider, see: [Vertex AI Gemini models](https://cloud.google.com/vertex-ai/docs/generative-ai/learn/models), [Google AI Gemini models](https://ai.google.dev/models/gemini). Note: Any models in preview on Vertex AI will require Vertex AI Model Location to be set to 'global'.", default: "gemini-2.5-flash", + // `required: true` in the extension, plus its validation, kept verbatim. + input: { + text: { + example: "gemini-2.5-flash", + nonEmpty: true, + validationRegex: /^[a-zA-Z0-9][a-zA-Z0-9.\-_/]*$/, + validationErrorMessage: + "Please specify a model id with no spaces, for example 'gemini-3.6-flash'. Model ids are not validated against the provider at install time - an id the provider does not serve will fail at request time.", + }, + }, }), vertexModelLocation: defineString("VERTEX_AI_MODEL_LOCATION", { label: "Vertex AI Model Location", @@ -173,14 +183,16 @@ const params = { label: "Prompt Field", description: "The field in the message document that contains the prompt.", default: "prompt", - input: { text: { example: "prompt" } }, + // `required: true` in the extension, which refuses an empty answer. + input: { text: { example: "prompt", nonEmpty: true } }, }), responseField: defineString("RESPONSE_FIELD", { label: "Response Field", description: "The field in the message document into which to put the response.", default: "response", - input: { text: { example: "response" } }, + // `required: true` in the extension, which refuses an empty answer. + input: { text: { example: "response", nonEmpty: true } }, }), orderField: defineString("ORDER_FIELD", { label: "Order Field", diff --git a/kits/firestore-genai-chatbot/tests/config.test.ts b/kits/firestore-genai-chatbot/tests/config.test.ts index 49a36c5807..45518c7316 100644 --- a/kits/firestore-genai-chatbot/tests/config.test.ts +++ b/kits/firestore-genai-chatbot/tests/config.test.ts @@ -133,3 +133,35 @@ describe("select values inherited from the extension", () => { expect(resolveConfig(configFromEnv()).vertex.modelLocation).toBe("global"); }); }); + +/** + * Compatibility requirement: extension.yaml marks these `required: true`, so + * the extension's installer refuses an empty answer and re-prompts, and it + * validates `MODEL` against a regex the kit had dropped. The CLI enforces + * either one for a kit only when the declaration carries it. + */ +describe("params the extension marks required", () => { + function text(name: string): Record { + return (declaration(name).input as { text?: Record }) + .text as Record; + } + + test.each(["MODEL", "PROMPT_FIELD", "RESPONSE_FIELD"])( + "%s refuses an empty value at the prompt", + (name) => { + expect(text(name).nonEmpty).toBe(true); + } + ); + + test("MODEL keeps the extension's model-id validation", () => { + // `Param.toSpec()` rewrites a declared RegExp to its source string in + // place, so a declaration read after discovery can hold either form. + const declared = text("MODEL").validationRegex as RegExp | string; + const regex = + typeof declared === "string" ? new RegExp(declared) : declared; + + expect(regex.source).toBe(/^[a-zA-Z0-9][a-zA-Z0-9.\-_/]*$/.source); + expect(regex.test("gemini-2.5-flash")).toBe(true); + expect(regex.test("gemini 2.5 flash")).toBe(false); + }); +}); diff --git a/kits/firestore-incremental-capture/src/config.ts b/kits/firestore-incremental-capture/src/config.ts index 5683dd63da..8c7046d379 100644 --- a/kits/firestore-incremental-capture/src/config.ts +++ b/kits/firestore-incremental-capture/src/config.ts @@ -96,15 +96,45 @@ const params = { default: "us-central1", input: select([...LOCATION_OPTIONS]), }), + // Every param below is `required: true` in the extension, which refuses an + // empty answer, and three carry validation the kit had dropped. Both are + // reproduced verbatim. syncCollectionPath: defineString("SYNC_COLLECTION_PATH", { default: "posts", + input: { + text: { + example: "posts", + nonEmpty: true, + validationRegex: /^[^\/]+(\/[^\/]+\/[^\/]+)*$/, + validationErrorMessage: "Must be a valid Cloud Firestore Collection", + }, + }, + }), + syncDataset: defineString("SYNC_DATASET", { + default: "backup_dataset", + input: { + text: { + example: "backup_dataset", + nonEmpty: true, + validationRegex: /^[a-zA-Z0-9_]+$/, + validationErrorMessage: + "BigQuery dataset IDs must be alphanumeric (plus underscores) and must be no more than 1024 characters.", + }, + }, + }), + syncTable: defineString("SYNC_TABLE", { + default: "backup_table", + input: { text: { example: "backup_table", nonEmpty: true } }, }), - syncDataset: defineString("SYNC_DATASET", { default: "backup_dataset" }), - syncTable: defineString("SYNC_TABLE", { default: "backup_table" }), backupInstanceId: defineString("BACKUP_INSTANCE_ID", { - // Required with no default, so the prompt has to reject an empty answer: - // whatever it resolves to is written straight into .env. - input: { text: { nonEmpty: true } }, + input: { + text: { + example: "my-backup-instance", + nonEmpty: true, + validationRegex: /^[a-zA-Z][a-zA-Z0-9-]{2,61}[a-zA-Z0-9]$/, + validationErrorMessage: "Enter a valid instance id", + }, + }, }), datasetLocation: defineString("DATASET_LOCATION", { default: "us", diff --git a/kits/firestore-incremental-capture/tests/config.test.ts b/kits/firestore-incremental-capture/tests/config.test.ts index a1e576380f..b68e284809 100644 --- a/kits/firestore-incremental-capture/tests/config.test.ts +++ b/kits/firestore-incremental-capture/tests/config.test.ts @@ -56,3 +56,57 @@ describe("configFromEnv", () => { ); }); }); + +/** + * Compatibility requirement: extension.yaml marks every param below + * `required: true`, so the extension's installer refuses an empty answer and + * re-prompts, and it validates three of them against regexes the kit had + * dropped. The CLI enforces either one for a kit only when the declaration + * carries it. + */ +describe("params the extension marks required", () => { + function text(name: string): Record { + const param = declaredParams.find( + (candidate) => candidate.name === name + ) as { options?: { input?: { text?: Record } } }; + + return param?.options?.input?.text ?? {}; + } + + // `Param.toSpec()` rewrites a declared RegExp to its source string in place, + // so a declaration read after discovery can hold either form. + function validationRegex(name: string): RegExp { + const declared = text(name).validationRegex as RegExp | string; + + return typeof declared === "string" ? new RegExp(declared) : declared; + } + + test.each([ + "SYNC_COLLECTION_PATH", + "SYNC_DATASET", + "SYNC_TABLE", + "BACKUP_INSTANCE_ID", + ])("%s refuses an empty value at the prompt", (name) => { + expect(text(name).nonEmpty).toBe(true); + }); + + test.each([ + ["SYNC_COLLECTION_PATH", /^[^\/]+(\/[^\/]+\/[^\/]+)*$/, "posts", "posts/"], + ["SYNC_DATASET", /^[a-zA-Z0-9_]+$/, "backup_dataset", "backup dataset"], + [ + "BACKUP_INSTANCE_ID", + /^[a-zA-Z][a-zA-Z0-9-]{2,61}[a-zA-Z0-9]$/, + "backup-db", + "-backup", + ], + ])( + "%s keeps the extension's validation", + (name, expected, valid, invalid) => { + const regex = validationRegex(name as string); + + expect(regex.source).toBe((expected as RegExp).source); + expect(regex.test(valid as string)).toBe(true); + expect(regex.test(invalid as string)).toBe(false); + } + ); +}); diff --git a/kits/firestore-send-email/src/config.ts b/kits/firestore-send-email/src/config.ts index 0e0d82e4bf..0a76004ae7 100644 --- a/kits/firestore-send-email/src/config.ts +++ b/kits/firestore-send-email/src/config.ts @@ -92,7 +92,8 @@ const params = { description: 'The Firestore database to use. Use "(default)" for the default database. You can find your available Firestore databases at [https://console.cloud.google.com/firestore/databases](https://console.cloud.google.com/firestore/databases).', default: "(default)", - input: { text: { example: "(default)" } }, + // `required: true` in the extension, which refuses an empty answer. + input: { text: { example: "(default)", nonEmpty: true } }, }), databaseRegion: defineString("DATABASE_REGION", { label: "Firestore Instance Location", diff --git a/kits/firestore-send-email/tests/config.test.ts b/kits/firestore-send-email/tests/config.test.ts index 2b18ceb5be..2bdf72c7ea 100644 --- a/kits/firestore-send-email/tests/config.test.ts +++ b/kits/firestore-send-email/tests/config.test.ts @@ -18,7 +18,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; interface StringParamOpts { default?: string; - input?: { text?: { validationRegex?: RegExp } }; + input?: { text?: { validationRegex?: RegExp; nonEmpty?: boolean } }; } const { stringParamOpts, paramEnv } = vi.hoisted(() => ({ @@ -186,6 +186,14 @@ describe("SMTP_CONNECTION_URI validationRegex", () => { }); }); +// Compatibility requirement: DATABASE is `required: true` in extension.yaml, +// so the extension's installer refuses an empty answer and re-prompts. +describe("DATABASE", () => { + test("refuses an empty value at the prompt", () => { + expect(stringParamOpts.get("DATABASE")?.input?.text?.nonEmpty).toBe(true); + }); +}); + describe("resolveConfig", () => { test("normalizes optional strings and resolves secrets", () => { const resolved = resolveConfig({ diff --git a/kits/firestore-translate-text/src/config.ts b/kits/firestore-translate-text/src/config.ts index 4e3f8fcd41..160b95696d 100644 --- a/kits/firestore-translate-text/src/config.ts +++ b/kits/firestore-translate-text/src/config.ts @@ -67,14 +67,16 @@ const params = { description: "What is the name of the field that contains the string that you want to translate?", default: "input", - input: { text: { example: "input" } }, + // `required: true` in the extension, which refuses an empty answer. + input: { text: { example: "input", nonEmpty: true } }, }), outputFieldName: defineString("OUTPUT_FIELD_NAME", { label: "Translations output field name", description: "What is the name of the field where you want to store your translations?", default: "translated", - input: { text: { example: "translated" } }, + // `required: true` in the extension, which refuses an empty answer. + input: { text: { example: "translated", nonEmpty: true } }, }), languages: defineString("LANGUAGES", { label: "Target languages for translations, as a comma-separated list", diff --git a/kits/firestore-translate-text/tests/config.test.ts b/kits/firestore-translate-text/tests/config.test.ts index 82c2406ddc..5eded671b0 100644 --- a/kits/firestore-translate-text/tests/config.test.ts +++ b/kits/firestore-translate-text/tests/config.test.ts @@ -202,6 +202,21 @@ describe("configFromEnv", () => { }); }); + // Compatibility requirement: both are `required: true` in extension.yaml, so + // the extension's installer refuses an empty answer and re-prompts. + test("refuses an empty value for the required params", async () => { + await importConfig(); + + const options = new Map( + defineString.mock.calls.map(([name, opts]) => [name, opts]) + ); + for (const name of ["INPUT_FIELD_NAME", "OUTPUT_FIELD_NAME"]) { + expect(options.get(name)).toMatchObject({ + input: { text: { nonEmpty: true } }, + }); + } + }); + test("offers the supported providers and gemini models as a select", async () => { await importConfig(); diff --git a/kits/firestore-vector-search/src/config.ts b/kits/firestore-vector-search/src/config.ts index a60f7b9694..5d18527093 100644 --- a/kits/firestore-vector-search/src/config.ts +++ b/kits/firestore-vector-search/src/config.ts @@ -160,21 +160,24 @@ const params = { description: "What is the name of the field that contains the string that you want to embed?", default: "input", - input: { text: { example: "input" } }, + // `required: true` in the extension, which refuses an empty answer. + input: { text: { example: "input", nonEmpty: true } }, }), outputFieldName: defineString("OUTPUT_FIELD_NAME", { label: "Output field name", description: "What is the name of the field where you want to store your embeddings?", default: "embedding", - input: { text: { example: "embedding" } }, + // `required: true` in the extension, which refuses an empty answer. + input: { text: { example: "embedding", nonEmpty: true } }, }), statusFieldName: defineString("STATUS_FIELD_NAME", { label: "Status field name", description: "What is the name of the field where you want to track the state of a document being embedded?", default: "status", - input: { text: { example: "status" } }, + // `required: true` in the extension, which refuses an empty answer. + input: { text: { example: "status", nonEmpty: true } }, }), doBackfill: defineBoolean("DO_BACKFILL", { label: "Embed existing documents?", diff --git a/kits/firestore-vector-search/tests/config.test.ts b/kits/firestore-vector-search/tests/config.test.ts index 07bc1dfe4b..4ec2a166b3 100644 --- a/kits/firestore-vector-search/tests/config.test.ts +++ b/kits/firestore-vector-search/tests/config.test.ts @@ -130,3 +130,23 @@ describe("select values inherited from the extension", () => { expect(config.updateOnConfigure).toBe(false); }); }); + +/** + * Compatibility requirement: extension.yaml marks these `required: true`, so + * the extension's installer refuses an empty answer and re-prompts. The CLI + * enforces that for a kit only when the declaration says `nonEmpty`. + */ +describe("params the extension marks required", () => { + test.each(["INPUT_FIELD_NAME", "OUTPUT_FIELD_NAME", "STATUS_FIELD_NAME"])( + "%s refuses an empty value at the prompt", + (name) => { + const param = declaredParams.find( + (candidate) => candidate.name === name + ) as { options?: { input?: unknown } } | undefined; + + expect(param?.options?.input).toMatchObject({ + text: { nonEmpty: true }, + }); + } + ); +}); diff --git a/kits/rtdb-limit-child-nodes/tests/config.test.ts b/kits/rtdb-limit-child-nodes/tests/config.test.ts index 1595589483..ac49db7b43 100644 --- a/kits/rtdb-limit-child-nodes/tests/config.test.ts +++ b/kits/rtdb-limit-child-nodes/tests/config.test.ts @@ -101,6 +101,21 @@ describe("configFromEnv", () => { }); }); + // Compatibility requirement: the param is `required: true` in extension.yaml + // and its regex matches the empty string, so only nonEmpty reproduces the + // extension installer's refusal to accept a blank instance. + test("refuses an empty SELECTED_DATABASE_INSTANCE", async () => { + await importConfig(); + + const instanceOptions = defineString.mock.calls.find( + ([name]) => name === "SELECTED_DATABASE_INSTANCE" + )?.[1]; + + expect(instanceOptions).toMatchObject({ + input: { text: { nonEmpty: true } }, + }); + }); + // The extension declared NODE_PATH and MAX_COUNT as required with no default, // so the CLI prompted for both at install. Declaring a default here would let // a deploy that omits MAX_COUNT silently prune every node down to that value. diff --git a/kits/speech-to-text/src/config.ts b/kits/speech-to-text/src/config.ts index bebb084e9c..f764c5d91c 100644 --- a/kits/speech-to-text/src/config.ts +++ b/kits/speech-to-text/src/config.ts @@ -63,7 +63,8 @@ const params = { description: "Which kind of use-case should the speech-to-text transcription algorithm be honed for? For details, see [the model field in the documentation](https://cloud.google.com/speech-to-text/docs/reference/rest/v1/RecognitionConfig)\nIf you're not sure, just use the default.", default: "default", - input: { text: { example: "default" } }, + // `required: true` in the extension, which refuses an empty answer. + input: { text: { example: "default", nonEmpty: true } }, }), outputStoragePath: defineString("OUTPUT_STORAGE_PATH", { label: "Storage path for transcriptions", diff --git a/kits/speech-to-text/tests/config.test.ts b/kits/speech-to-text/tests/config.test.ts index 394b1b94d3..43614873c1 100644 --- a/kits/speech-to-text/tests/config.test.ts +++ b/kits/speech-to-text/tests/config.test.ts @@ -85,3 +85,16 @@ describe("ENABLE_AUTOMATIC_PUNCTUATION values inherited from the extension", () expect(configFromEnv().enableAutomaticPunctuation).toBe(false); }); }); + +/** + * Compatibility requirement: `MODEL` is `required: true` in extension.yaml, so + * the extension's installer refuses an empty answer and re-prompts. The CLI + * enforces that for a kit only when the declaration says `nonEmpty`. + */ +describe("MODEL", () => { + test("refuses an empty value at the prompt", () => { + expect(declaration("MODEL").input).toMatchObject({ + text: { nonEmpty: true }, + }); + }); +});