Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions kits/delete-user-data/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
},
Expand Down Expand Up @@ -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.",
Comment thread
CorieW marked this conversation as resolved.
},
},
}),
searchFields: defineString("AUTO_DISCOVERY_SEARCH_FIELDS", {
label: "Auto discovery search fields",
Expand Down
46 changes: 42 additions & 4 deletions kits/delete-user-data/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) => ({
select: {
Expand Down Expand Up @@ -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);
Comment thread
CorieW marked this conversation as resolved.
});
});
5 changes: 4 additions & 1 deletion kits/firestore-bigquery-export/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions kits/firestore-bigquery-export/tests/config-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
const param = declaredParams.find((candidate) => candidate.name === name) as
| { options?: { input?: { text?: Record<string, unknown> } } }
| 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);
}
);
});
11 changes: 11 additions & 0 deletions kits/firestore-bundle-builder/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
};
Expand Down
65 changes: 65 additions & 0 deletions kits/firestore-bundle-builder/tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
const param = declaredParams.find((candidate) => candidate.name === name) as
| { options?: { input?: { text?: Record<string, unknown> } } }
| 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();
});
});
16 changes: 14 additions & 2 deletions kits/firestore-genai-chatbot/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions kits/firestore-genai-chatbot/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return (declaration(name).input as { text?: Record<string, unknown> })
.text as Record<string, unknown>;
}

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);
});
});
40 changes: 35 additions & 5 deletions kits/firestore-incremental-capture/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading