diff --git a/.github/workflows/postmerge.yaml b/.github/workflows/postmerge.yaml index e82d70d65..df63a53cf 100644 --- a/.github/workflows/postmerge.yaml +++ b/.github/workflows/postmerge.yaml @@ -45,6 +45,8 @@ jobs: - name: "Run integration test" run: npm run test:postmerge + env: + PROJECT_ID: ${{ secrets.PROJECT_ID }} - name: Print debug logs if: failure() diff --git a/.gitignore b/.gitignore index 017bc9f40..00b9713c9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ firebase-functions-*.tgz integration_test/.firebaserc integration_test/*.log integration_test/functions/firebase-functions.tgz -integration_test/functions/package.json lib node_modules npm-debug.log diff --git a/eslint.config.js b/eslint.config.js index 2b77805fd..d0f8e2e8f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,110 +3,124 @@ const js = require("@eslint/js"); const path = require("path"); const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, }); module.exports = [ - { - ignores: [ - "lib/", - "dev/", - "node_modules/", - "coverage/", - "docgen/", - "v1/", - "v2/", - "logger/", - "dist/", - "spec/fixtures/", - "scripts/**/*.js", - "scripts/**/*.mjs", - "protos/", - ".prettierrc.js", - "eslint.config.*", - "tsdown.config.*", - "scripts/bin-test/sources/esm-ext/index.mjs", - ], + { + ignores: [ + "lib/", + "dev/", + "node_modules/", + "coverage/", + "docgen/", + "v1/", + "v2/", + "logger/", + "dist/", + "spec/fixtures/", + "scripts/**/*.js", + "scripts/**/*.mjs", + "protos/", + ".prettierrc.js", + "eslint.config.*", + "tsdown.config.*", + "scripts/bin-test/sources/esm-ext/index.mjs", + "integration_test/functions/lib/", + ], + }, + ...compat.extends( + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking", + "plugin:jsdoc/recommended", + "google", + "prettier" + ), + { + languageOptions: { + parser: require("@typescript-eslint/parser"), + parserOptions: { + project: "tsconfig.json", + tsconfigRootDir: __dirname, + }, + ecmaVersion: 2022, }, - ...compat.extends( - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking", - "plugin:jsdoc/recommended", - "google", - "prettier" - ), - { - languageOptions: { - parser: require("@typescript-eslint/parser"), - parserOptions: { - project: "tsconfig.json", - tsconfigRootDir: __dirname, - }, - ecmaVersion: 2022 - }, - plugins: { - "prettier": require("eslint-plugin-prettier"), - }, - rules: { - "jsdoc/newline-after-description": "off", - "jsdoc/require-jsdoc": ["warn", { publicOnly: true }], - "jsdoc/check-tag-names": ["warn", { definedTags: ["alpha", "remarks", "typeParam", "packageDocumentation", "hidden"] }], - "no-restricted-globals": ["error", "name", "length"], - "prefer-arrow-callback": "error", - "prettier/prettier": "error", - "require-atomic-updates": "off", // This rule is so noisy and isn't useful: https://github.com/eslint/eslint/issues/11899 - "require-jsdoc": "off", // This rule is deprecated and superseded by jsdoc/require-jsdoc. - "valid-jsdoc": "off", // This is deprecated but included in recommended configs. - "no-prototype-builtins": "warn", - "no-useless-escape": "warn", - "prefer-promise-reject-errors": "warn", - }, + plugins: { + prettier: require("eslint-plugin-prettier"), }, - { - files: ["**/*.ts"], - rules: { - "jsdoc/require-param-type": "off", - "jsdoc/require-returns-type": "off", - // Google style guide allows us to omit trivial parameters and returns - "jsdoc/require-param": "off", - "jsdoc/require-returns": "off", + rules: { + "jsdoc/newline-after-description": "off", + "jsdoc/require-jsdoc": ["warn", { publicOnly: true }], + "jsdoc/check-tag-names": [ + "warn", + { definedTags: ["alpha", "remarks", "typeParam", "packageDocumentation", "hidden"] }, + ], + "no-restricted-globals": ["error", "name", "length"], + "prefer-arrow-callback": "error", + "prettier/prettier": "error", + "require-atomic-updates": "off", // This rule is so noisy and isn't useful: https://github.com/eslint/eslint/issues/11899 + "require-jsdoc": "off", // This rule is deprecated and superseded by jsdoc/require-jsdoc. + "valid-jsdoc": "off", // This is deprecated but included in recommended configs. + "no-prototype-builtins": "warn", + "no-useless-escape": "warn", + "prefer-promise-reject-errors": "warn", + }, + }, + { + files: ["**/*.ts"], + rules: { + "jsdoc/require-param-type": "off", + "jsdoc/require-returns-type": "off", + // Google style guide allows us to omit trivial parameters and returns + "jsdoc/require-param": "off", + "jsdoc/require-returns": "off", - "@typescript-eslint/no-invalid-this": "error", - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }], // Unused vars should not exist. - "@typescript-eslint/no-misused-promises": "warn", // rule does not work with async handlers for express. - "no-invalid-this": "off", // Turned off in favor of @typescript-eslint/no-invalid-this. - "no-unused-vars": "off", // Off in favor of @typescript-eslint/no-unused-vars. - eqeqeq: ["error", "always", { null: "ignore" }], - camelcase: ["error", { properties: "never" }], // snake_case allowed in properties iif to satisfy an external contract / style + "@typescript-eslint/no-invalid-this": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, + ], // Unused vars should not exist. + "@typescript-eslint/no-misused-promises": "warn", // rule does not work with async handlers for express. + "no-invalid-this": "off", // Turned off in favor of @typescript-eslint/no-invalid-this. + "no-unused-vars": "off", // Off in favor of @typescript-eslint/no-unused-vars. + eqeqeq: ["error", "always", { null: "ignore" }], + camelcase: ["error", { properties: "never" }], // snake_case allowed in properties iif to satisfy an external contract / style - // Ideally, all these warning should be error - let's fix them in the future. - "@typescript-eslint/no-unsafe-argument": "warn", - "@typescript-eslint/no-unsafe-assignment": "warn", - "@typescript-eslint/no-unsafe-call": "warn", - "@typescript-eslint/no-unsafe-member-access": "warn", - "@typescript-eslint/no-unsafe-return": "warn", - "@typescript-eslint/restrict-template-expressions": "warn", - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-redundant-type-constituents": "warn", - "@typescript-eslint/no-base-to-string": "warn", - "@typescript-eslint/no-duplicate-type-constituents": "warn", - "@typescript-eslint/no-require-imports": "warn", - "@typescript-eslint/no-empty-object-type": "warn", - "@typescript-eslint/prefer-promise-reject-errors": "warn", - }, + // Ideally, all these warning should be error - let's fix them in the future. + "@typescript-eslint/no-unsafe-argument": "warn", + "@typescript-eslint/no-unsafe-assignment": "warn", + "@typescript-eslint/no-unsafe-call": "warn", + "@typescript-eslint/no-unsafe-member-access": "warn", + "@typescript-eslint/no-unsafe-return": "warn", + "@typescript-eslint/restrict-template-expressions": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-redundant-type-constituents": "warn", + "@typescript-eslint/no-base-to-string": "warn", + "@typescript-eslint/no-duplicate-type-constituents": "warn", + "@typescript-eslint/no-require-imports": "warn", + "@typescript-eslint/no-empty-object-type": "warn", + "@typescript-eslint/prefer-promise-reject-errors": "warn", + }, + }, + { + files: [ + "**/*.spec.ts", + "**/*.spec.js", + "spec/helper.ts", + "scripts/bin-test/**/*.ts", + "integration_test/**/*.ts", + ], + languageOptions: { + globals: { + mocha: true, + }, }, - { - files: ["**/*.spec.ts", "**/*.spec.js", "spec/helper.ts", "scripts/bin-test/**/*.ts", "integration_test/**/*.ts"], - languageOptions: { - globals: { - mocha: true, - }, - }, - rules: { - "@typescript-eslint/no-unused-expressions": "off", - } + rules: { + "@typescript-eslint/no-unused-expressions": "off", + "@typescript-eslint/no-unnecessary-type-assertion": "off", }, + }, ]; diff --git a/integration_test/.gitignore b/integration_test/.gitignore new file mode 100644 index 000000000..08558a9b8 --- /dev/null +++ b/integration_test/.gitignore @@ -0,0 +1,72 @@ +# Ignored as the test runner will generate this file +firebase.json + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# dataconnect generated files +.dataconnect diff --git a/integration_test/README.md b/integration_test/README.md index 3b0f5413f..8d0e9f053 100644 --- a/integration_test/README.md +++ b/integration_test/README.md @@ -1,22 +1,122 @@ -## How to Use +# Integration testing -**_ATTENTION_**: Running this test will wipe the contents of the Firebase project(s) you run it against. Make sure you use disposable Firebase project(s)! +This directory contains end-to-end integration tests for the Firebase Functions SDK. These tests deploy real Cloud Functions to a Firebase project and verify their behavior by triggering events and validating responses. -Run the integration test as follows: +## Prerequisites + +- Node.js 18+ installed +- Firebase CLI installed and authenticated (`npm install -g firebase-tools`) +- Access to a Firebase test project + +## Setup + +Add your firebase config to `/src/config`. + +```js +export const config = { + // Add Firebase config here +}; +``` + +Set your local project to match the configuration. ```bash -./run_tests.sh [] + firebase use +``` + +## Usage + +```bash +npm i +npm run test +``` + +## Overview + +The integration test suite: + +- Builds and packages the local Firebase Functions SDK +- Deploys both v1 and v2 Cloud Functions to a test project +- Runs comprehensive tests against deployed functions +- Validates function triggers, event handling, and data flow +- Cleans up resources after test completion + +Tests cover all major function types including HTTPS, Firestore, Realtime Database, Storage, PubSub, Authentication, Scheduler, Tasks, and more. + +## Running Tests + +Using `npm run test`. This will: + +1. Build the SDK from source +2. Deploy all functions to the configured Firebase project +3. Wait for functions to provision +4. Execute the test suite +5. Clean up deployed functions + +## Known issues + +### Updating multiple versions functions for storage causes a pre-condition error + +For this test suite v1 and v2 storage functions have been separated to mitigate against function deployment errors. When deploying all functions simultaneously, the following error may occur: + +```js +{ + "protoPayload": { + "methodName": "storage.buckets.update", + "status": { + "code": 9, + "message": "At least one of the pre-conditions you specified did not hold." + } + }, + "principalEmail": "..." +} ``` -Test runs cycles of testing, once for Node.js 14 and another for Node.js 16. +To mitigate this, the test suite includes a delay between deploying v1 and v2 storage functions. + +### Storage onObjectDeleted + +These tests are skipped as the timeDeleted field is not included in the Storage event data sent by Google Cloud. + +### onObjectMetadataUpdated + +Custom metadata is not being sent in the metadata update event payload. + +### onObjectFinalized + +The metadata and timeCreated fields are not included in the Storage finalize event data sent by Google Cloud. + +### Auth Identity v2 + +Any `beforeUser` functions cannot be tested without manual setup. + +#### beforeUserCreated & beforeUserSignedIn + +It is currently not possible to create a function in advance and assign via the API. -Test uses locally installed firebase to invoke commands for deploying function. The test also requires that you have -gcloud CLI installed and authenticated (`gcloud auth login`). +The Identity Toolkit REST API endpoint exhibits a false positive behaviour: + +API Returns Success: When setting blocking function triggers with correct event types (beforeCreate, beforeSignIn), the API returns 200 OK. + +Configuration Appears Set: The API response shows the triggers are configured with timestamps. GCP, however is not updated despite the successful API response, the blocking functions are not actually configured in Google Cloud Platform. + +[API source](https://identitytoolkit.googleapis.com/$discovery/rest?version=v2) + +### Multiple storage function deployments + +An intermittent error occurs when deploying functions related to storage, causing deployments to fail. A delay has been added to the test framework to allow functions to propagate before deploying additional functions and running the test suite. + +### Service identity via Function deployment + +There is an intermittent issue on deploying functions. Running the test suite will occasionally result in one of the following" errors: + +```bash +functions: generating the service identity for pubsub.googleapis.com... +functions: generating the service identity for eventarc.googleapis.com... +``` -Integration test is triggered by invoking HTTP function integrationTest which in turns invokes each function trigger -by issuing actions necessary to trigger it (e.g. write to storage bucket). +Retrying the suite will result in a successful deployment following a time delay >5 minutes. -### Debugging +### Delay on Firestore initial propogation -The status and result of each test is stored in RTDB of the project used for testing. You can also inspect Cloud Logging -for more clues. +Following the deployment of a Firestore function, events do not always fire. A delay is required (30 seconds) to ensure the function has completed installation steps before firing an event. diff --git a/integration_test/cli.ts b/integration_test/cli.ts new file mode 100644 index 000000000..1cb9beb76 --- /dev/null +++ b/integration_test/cli.ts @@ -0,0 +1,198 @@ +#!/usr/bin/env node + +import { spawn } from "child_process"; +import { promises as fs } from "fs"; +import { join } from "path"; + +const runId = `ff${Math.random().toString(36).substring(2, 15)}`; + +console.log(`Running tests for run ID: ${runId}`); + +const integrationTestDir = __dirname; +const functionsDir = join(integrationTestDir, "functions"); +const rootDir = join(integrationTestDir, ".."); +const firebaseJsonPath = join(integrationTestDir, "firebase.json"); + +async function execCommand( + command: string, + args: string[], + env: Record = {}, + cwd?: string +): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { + stdio: "inherit", + env: { ...process.env, ...env }, + cwd: cwd || process.cwd(), + shell: true, + }); + + proc.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Command failed with exit code ${code}`)); + } + }); + + proc.on("error", (error) => { + reject(error); + }); + }); +} + +async function buildAndPackSDK(): Promise { + console.log("Building root SDK..."); + await execCommand("npm", ["run", "build"], {}, rootDir); + console.log("Root SDK built successfully"); + + console.log("Packing SDK for functions..."); + const tarballPath = join(functionsDir, "firebase-functions-local.tgz"); + // Remove old tarball if it exists + try { + await fs.unlink(tarballPath); + } catch { + // Ignore if it doesn't exist + } + + // Pack the SDK + await execCommand("npm", ["pack", "--pack-destination", functionsDir], {}, rootDir); + + // Rename the tarball + const files = await fs.readdir(functionsDir); + const tarballFile = files.find((f) => f.startsWith("firebase-functions-") && f.endsWith(".tgz")); + if (tarballFile) { + await fs.rename(join(functionsDir, tarballFile), tarballPath); + console.log("SDK packed successfully"); + } else { + throw new Error("Failed to find packed tarball"); + } + + // Note: We don't regenerate package-lock.json here because Firebase deploy + // will run npm install and regenerate it with the correct checksum for the new tarball +} + +async function writeFirebaseJson(codebase: string): Promise { + console.log(`Writing firebase.json with codebase: ${codebase}`); + const firebaseJson = { + functions: [ + { + source: "functions", + disallowLegacyRuntimeConfig: true, + ignore: [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log", + "*.local", + "**/*.test.ts", + ], + predeploy: ['npm --prefix "$RESOURCE_DIR" run build'], + }, + ], + }; + + await fs.writeFile(firebaseJsonPath, JSON.stringify(firebaseJson, null, 2), "utf-8"); + console.log("firebase.json written successfully"); +} + +async function deployFunctions(runId: string): Promise { + console.log(`Deploying functions with RUN_ID: ${runId}...`); + // Delete package-lock.json before deploy so Firebase's npm install regenerates it + // with the correct checksum for the newly created tarball + const packageLockPath = join(functionsDir, "package-lock.json"); + try { + await fs.unlink(packageLockPath); + console.log("Deleted package-lock.json before deploy (Firebase will regenerate it)"); + } catch { + // Ignore if it doesn't exist + } + + // Deploy v1 Storage functions one at a time to avoid bucket race condition + const storageFunctions = [ + "test-storageV1OnObjectFinalizedTrigger", + "test-storageV1OnObjectDeletedTrigger", + "test-storageV1OnObjectMetadataUpdatedTrigger", + ]; + + for (const funcName of storageFunctions) { + console.log(`Deploying ${funcName}...`); + await execCommand( + "firebase", + ["deploy", "--only", `functions:${funcName}`], + { RUN_ID: runId }, + integrationTestDir + ); + console.log(`Waiting 10 seconds before next deployment...`); + await new Promise((resolve) => setTimeout(resolve, 10_000)); + } + + // Wait 10 seconds for bucket configuration to propogate. + console.log( + "All Storage functions deployed, waiting 10 seconds for bucket configuration to stabilize..." + ); + + await new Promise((resolve) => setTimeout(resolve, 10_000)); + + // Deploy remaining functions + console.log("Deploying remaining functions..."); + await execCommand( + "firebase", + ["deploy", "--only", "functions"], + { RUN_ID: runId }, + integrationTestDir + ); + // Wait 30 seconds to allow functions to propogate. + console.log("Functions deployed successfully, waiting 30 seconds for functions to propogate..."); + await new Promise((resolve) => setTimeout(resolve, 30_000)); +} + +async function writeEnvFile(runId: string): Promise { + console.log(`Writing .env with RUN_ID: ${runId}...`); + await fs.writeFile(join(functionsDir, ".env"), `RUN_ID=${runId}`, "utf-8"); + console.log(".env.test written successfully"); +} + +async function runTests(runId: string): Promise { + console.log(`Running tests with RUN_ID: ${runId}...`); + await execCommand("vitest", ["run"], { RUN_ID: runId }, integrationTestDir); + console.log("Tests completed successfully"); +} + +async function cleanupFunctions(runId: string): Promise { + const moduleId = "test"; + console.log(`Cleaning up functions with RUN_ID: ${runId}...`); + await execCommand("firebase", ["functions:delete", moduleId, "--force"], {}, integrationTestDir); + console.log("Functions cleaned up successfully"); +} + +async function main(): Promise { + let success = false; + try { + await buildAndPackSDK(); + await writeFirebaseJson(runId); + await writeEnvFile(runId); + await deployFunctions(runId); + console.log("Waiting 20 seconds for deployments fully provision before running tests..."); + await new Promise((resolve) => setTimeout(resolve, 20_000)); + await runTests(runId); + + success = true; + } catch (error) { + console.error("Error during test execution:", error); + throw error; + } finally { + // Step 7: Clean up codebase on success or error + await cleanupFunctions(runId); + } + + if (success) { + console.log("All tests passed!"); + process.exit(0); + } +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/integration_test/database.rules.json b/integration_test/database.rules.json deleted file mode 100644 index 2ad59a69c..000000000 --- a/integration_test/database.rules.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "rules": { - "dbTests": { - "$testId": { - "adminOnly": { - ".validate": false - } - } - }, - ".read": "auth != null", - ".write": true - } -} diff --git a/integration_test/firebase.json b/integration_test/firebase.json deleted file mode 100644 index 9662aef03..000000000 --- a/integration_test/firebase.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "database": { - "rules": "database.rules.json" - }, - "firestore": { - "rules": "firestore.rules", - "indexes": "firestore.indexes.json" - }, - "functions": { - "source": "functions", - "codebase": "integration-tests", - "predeploy": ["npm --prefix \"$RESOURCE_DIR\" run build"] - } -} diff --git a/integration_test/firestore.indexes.json b/integration_test/firestore.indexes.json deleted file mode 100644 index 0e3f2d6b6..000000000 --- a/integration_test/firestore.indexes.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "indexes": [] -} diff --git a/integration_test/firestore.rules b/integration_test/firestore.rules deleted file mode 100644 index d9df6d5d1..000000000 --- a/integration_test/firestore.rules +++ /dev/null @@ -1,9 +0,0 @@ -rules_version = "2"; - -service cloud.firestore { - match /databases/{database}/documents { - match /{document=**} { - allow read, write: if request.auth != null; - } - } -} diff --git a/integration_test/functions/.gitignore b/integration_test/functions/.gitignore new file mode 100644 index 000000000..db1a9d12e --- /dev/null +++ b/integration_test/functions/.gitignore @@ -0,0 +1,12 @@ +# Compiled JavaScript files +lib/**/*.js +lib/**/*.js.map + +# TypeScript v1 declaration files +typings/ + +# Node.js dependency directory +node_modules/ +*.local + +.env \ No newline at end of file diff --git a/integration_test/functions/.npmrc b/integration_test/functions/.npmrc deleted file mode 100644 index 43c97e719..000000000 --- a/integration_test/functions/.npmrc +++ /dev/null @@ -1 +0,0 @@ -package-lock=false diff --git a/integration_test/functions/package.json b/integration_test/functions/package.json new file mode 100644 index 000000000..646bb66b7 --- /dev/null +++ b/integration_test/functions/package.json @@ -0,0 +1,30 @@ +{ + "name": "functions", + "scripts": { + "build": "tsc", + "build:watch": "tsc --watch", + "serve": "npm run build && firebase emulators:start --only functions", + "shell": "npm run build && firebase functions:shell", + "start": "npm run shell", + "deploy": "firebase deploy --only functions", + "logs": "firebase functions:log" + }, + "engines": { + "node": "22" + }, + "main": "lib/index.js", + "dependencies": { + "@google-cloud/pubsub": "^5.2.0", + "@google-cloud/scheduler": "^5.3.1", + "@google-cloud/tasks": "^6.2.1", + "firebase": "^12.6.0", + "firebase-admin": "^12.6.0", + "firebase-functions": "file:firebase-functions-local.tgz", + "undici": "^7.16.0" + }, + "devDependencies": { + "firebase-functions-test": "^3.1.0", + "typescript": "^5.7.3" + }, + "private": true +} diff --git a/integration_test/functions/src/assertions/database.ts b/integration_test/functions/src/assertions/database.ts new file mode 100644 index 000000000..8a3b4b113 --- /dev/null +++ b/integration_test/functions/src/assertions/database.ts @@ -0,0 +1,37 @@ +import { assertType, expect } from "vitest"; +import { RUN_ID } from "../utils"; + +export * from "./index"; + +export function expectDatabaseEvent(data: any, eventName: string, refPath: string) { + expect(data.location).toBeDefined(); + assertType(data.location); + expect(data.location.length).toBeGreaterThan(0); + expect(data.firebaseDatabaseHost).toBeDefined(); + assertType(data.firebaseDatabaseHost); + expect(data.firebaseDatabaseHost.length).toBeGreaterThan(0); + expect(data.instance).toBeDefined(); + assertType(data.instance); + expect(data.instance.length).toBeGreaterThan(0); + expect(data.ref).toBeDefined(); + assertType(data.ref); + expect(data.ref).toBe(refPath); + expect(data.params).toBeDefined(); + expect(data.params.runId).toBe(RUN_ID); +} + +export function expectDataSnapshot(snapshot: any) { + expect(snapshot.ref).toBeDefined(); + expect(snapshot.ref.__type).toBe("reference"); + expect(snapshot.ref.key).toBeDefined(); + expect(snapshot.key).toBeDefined(); + expect(snapshot.exists).toBe(true); + expect(snapshot.hasChildren).toBeDefined(); + expect(typeof snapshot.hasChildren).toBe("boolean"); + expect(snapshot.hasChild).toBeDefined(); + expect(typeof snapshot.hasChild).toBe("boolean"); + expect(snapshot.numChildren).toBeDefined(); + expect(typeof snapshot.numChildren).toBe("number"); + expect(snapshot.json).toBeDefined(); + expect(typeof snapshot.json).toBe("object"); +} diff --git a/integration_test/functions/src/assertions/firestore.ts b/integration_test/functions/src/assertions/firestore.ts new file mode 100644 index 000000000..56e0699ac --- /dev/null +++ b/integration_test/functions/src/assertions/firestore.ts @@ -0,0 +1,63 @@ +import { expect, assertType } from "vitest"; +import { RUN_ID } from "../utils"; + +export * from "./index"; + +export function expectFirestoreAuthEvent(data: any, collection: string, document: string) { + expect(data.authId).toBeDefined(); + assertType(data.authId); + expect(data.authId.length).toBeGreaterThan(0); + expect(data.authType).toBeDefined(); + assertType(data.authType); + expect(data.authType.length).toBeGreaterThan(0); + expectFirestoreEvent(data, collection, document); +} + +export function expectFirestoreEvent(data: any, collection: string, document: string) { + expect(data.location).toBeDefined(); + assertType(data.location); + expect(data.location.length).toBeGreaterThan(0); + expect(data.project).toBeDefined(); + assertType(data.project); + expect(data.project.length).toBeGreaterThan(0); + expect(data.database).toBeDefined(); + assertType(data.database); + expect(data.database.length).toBeGreaterThan(0); + expect(data.namespace).toBeDefined(); + assertType(data.namespace); + expect(data.namespace.length).toBeGreaterThan(0); + expect(data.document).toBeDefined(); + assertType(data.document); + expect(data.document.length).toBeGreaterThan(0); + expect(data.document).toBe(`integration_test/${RUN_ID}/${collection}/${document}`); + expect(data.params).toBeDefined(); + expect(data.params.runId).toBe(RUN_ID); + expect(data.params.documentId).toBe(document); +} + +export function expectQueryDocumentSnapshot(snapshot: any, collection: string, document: string) { + expect(snapshot.exists).toBe(true); + expect(snapshot.id).toBe(document); + expectDocumentReference(snapshot.ref, collection, document); + expectTimestamp(snapshot.createTime); + expectTimestamp(snapshot.updateTime); +} + +export function expectDocumentReference(reference: any, collection: string, document: string) { + expect(reference._type).toBe("reference"); + expect(reference.id).toBe(document); + expect(reference.path).toBe(`integration_test/${RUN_ID}/${collection}/${document}`); +} + +export function expectTimestamp(timestamp: any) { + expect(timestamp._type).toBe("timestamp"); + expect(Date.parse(timestamp.iso)).toBeGreaterThan(0); + expect(Number(timestamp.seconds)).toBeGreaterThan(0); + expect(Number(timestamp.nanoseconds)).toBeGreaterThan(0); +} + +export function expectGeoPoint(geoPoint: any) { + expect(geoPoint._type).toBe("geopoint"); + expect(Number(geoPoint.latitude)).toBeGreaterThan(0); + expect(Number(geoPoint.longitude)).toBeGreaterThan(0); +} diff --git a/integration_test/functions/src/assertions/identity.ts b/integration_test/functions/src/assertions/identity.ts new file mode 100644 index 000000000..72449ff84 --- /dev/null +++ b/integration_test/functions/src/assertions/identity.ts @@ -0,0 +1,33 @@ +import { expect, assertType } from "vitest"; + +export * from "./index"; + +export function expectAuthBlockingEvent(data: any, userId: string) { + // expect(data.auth).toBeDefined(); // TOOD: Not provided? + expect(data.authType).toBeDefined(); + assertType(data.authType); + expect(data.eventId).toBeDefined(); + assertType(data.eventId); + expect(data.eventType).toBeDefined(); + assertType(data.eventType); + expect(data.timestamp).toBeDefined(); + assertType(data.timestamp); + expect(Date.parse(data.timestamp)).toBeGreaterThan(0); + + expect(data.locale).toBeDefined(); + expect(data.ipAddress).toBeDefined(); + assertType(data.ipAddress); + expect(data.ipAddress.length).toBeGreaterThan(0); + expect(data.userAgent).toBeDefined(); + assertType(data.userAgent); + expect(data.userAgent.length).toBeGreaterThan(0); + + expect(data.additionalUserInfo).toBeDefined(); + assertType(data.additionalUserInfo.isNewUser); + expect(data.additionalUserInfo.providerId).toBe("password"); + + // TODO: data.credential is null + + expect(data.data).toBeDefined(); + expect(data.data.uid).toBe(userId); +} diff --git a/integration_test/functions/src/assertions/index.ts b/integration_test/functions/src/assertions/index.ts new file mode 100644 index 000000000..e23393803 --- /dev/null +++ b/integration_test/functions/src/assertions/index.ts @@ -0,0 +1,47 @@ +import { Resource } from "firebase-functions/v1"; +import { expect, assertType } from "vitest"; + +export function expectCloudEvent(data: any) { + expect(data.specversion).toBe("1.0"); + expect(data.id).toBeDefined(); + assertType(data.id); + expect(data.id.length).toBeGreaterThan(0); + expect(data.source).toBeDefined(); + assertType(data.source); + expect(data.source.length).toBeGreaterThan(0); + + // Subject is optional (e.g. pubsub) + if ("subject" in data) { + expect(data.subject).toBeDefined(); + assertType(data.subject); + expect(data.subject.length).toBeGreaterThan(0); + } + + expect(data.type).toBeDefined(); + assertType(data.type); + expect(data.type.length).toBeGreaterThan(0); + expect(data.time).toBeDefined(); + assertType(data.time); + expect(data.time.length).toBeGreaterThan(0); + // iso string to unix - will be NaN if not a valid date + expect(Date.parse(data.time)).toBeGreaterThan(0); +} + +export function expectEventContext(data: any) { + expect(data.eventId).toBeDefined(); + assertType(data.eventId); + expect(data.eventId.length).toBeGreaterThan(0); + expect(data.eventType).toBeDefined(); + assertType(data.eventType); + expect(data.eventType.length).toBeGreaterThan(0); + expect(data.resource).toBeDefined(); + assertType(data.resource); + expect(data.resource.service).toBeDefined(); + expect(data.resource.name).toBeDefined(); + expect(data.timestamp).toBeDefined(); + assertType(data.timestamp); + expect(data.timestamp.length).toBeGreaterThan(0); + expect(Date.parse(data.timestamp)).toBeGreaterThan(0); + expect(data.params).toBeDefined(); + assertType>(data.params); +} diff --git a/integration_test/functions/src/assertions/storage.ts b/integration_test/functions/src/assertions/storage.ts new file mode 100644 index 000000000..060e20ee2 --- /dev/null +++ b/integration_test/functions/src/assertions/storage.ts @@ -0,0 +1,48 @@ +import { assertType, expect } from "vitest"; +import { config } from "../config"; + +export function expectStorageObjectData(data: any, filename: string) { + expect(data.bucket).toBe(config.storageBucket); + expect(data.contentType).toBe("text/plain"); + + expect(data.crc32c).toBeDefined(); + assertType(data.crc32c); + expect(data.crc32c.length).toBeGreaterThan(0); + + expect(data.md5Hash).toBeDefined(); + assertType(data.md5Hash); + expect(data.md5Hash.length).toBeGreaterThan(0); + + expect(data.etag).toBeDefined(); + assertType(data.etag); + expect(data.etag.length).toBeGreaterThan(0); + + expect(Number.parseInt(data.generation)).toBeGreaterThan(0); + + expect(data.id).toBeDefined(); + assertType(data.id); + expect(data.id).toContain(config.storageBucket); + expect(data.id).toContain(filename); + + expect(data.kind).toBe("storage#object"); + + expect(data.mediaLink).toContain( + `https://storage.googleapis.com/download/storage/v1/b/${config.storageBucket}/o/${filename}` + ); + + expect(Number.parseInt(data.metageneration)).toBeGreaterThan(0); + + expect(data.name).toBe(filename); + + expect(data.selfLink).toBe( + `https://www.googleapis.com/storage/v1/b/${config.storageBucket}/o/${filename}` + ); + + expect(Number.parseInt(data.size)).toBeGreaterThan(0); + + expect(data.storageClass).toBe("REGIONAL"); + + expect(Date.parse(data.timeCreated)).toBeGreaterThan(0); + expect(Date.parse(data.timeStorageClassUpdated)).toBeGreaterThan(0); + expect(Date.parse(data.updated)).toBeGreaterThan(0); +} diff --git a/integration_test/functions/src/config.ts b/integration_test/functions/src/config.ts new file mode 100644 index 000000000..04d863bf1 --- /dev/null +++ b/integration_test/functions/src/config.ts @@ -0,0 +1,3 @@ +export const config = { + // Add Firebase config here +}; diff --git a/integration_test/functions/src/firebase.client.ts b/integration_test/functions/src/firebase.client.ts new file mode 100644 index 000000000..a5403b2a6 --- /dev/null +++ b/integration_test/functions/src/firebase.client.ts @@ -0,0 +1,8 @@ +import { initializeApp } from "firebase/app"; +import { getAuth } from "firebase/auth"; +import { getFunctions } from "firebase/functions"; +import { config } from "./config"; + +export const app = initializeApp(config); +export const auth = getAuth(app); +export const functions = getFunctions(app); diff --git a/integration_test/functions/src/firebase.server.ts b/integration_test/functions/src/firebase.server.ts new file mode 100644 index 000000000..f7188cf3f --- /dev/null +++ b/integration_test/functions/src/firebase.server.ts @@ -0,0 +1,42 @@ +import admin from "firebase-admin"; +import { GoogleAuth } from "google-auth-library"; +import { applicationDefault } from "firebase-admin/app"; +import { getFunctions } from "firebase-admin/functions"; +import { getDatabase } from "firebase-admin/database"; +import { getAuth } from "firebase-admin/auth"; +import { getRemoteConfig } from "firebase-admin/remote-config"; +import { getFirestore } from "firebase-admin/firestore"; +import { config } from "./config"; +import { getStorage } from "firebase-admin/storage"; + +export const app = admin.initializeApp({ + credential: applicationDefault(), + projectId: config.projectId, + databaseURL: config.databaseURL, +}); + +export const firestore = getFirestore(app); +firestore.settings({ ignoreUndefinedProperties: true }); +export const database = getDatabase(app); +export const auth = getAuth(app); +export const remoteConfig = getRemoteConfig(app); +export const functions = getFunctions(app); +export const storage = getStorage(app); + +// See https://github.com/firebase/functions-samples/blob/a6ae4cbd3cf2fff3e2b97538081140ad9befd5d8/Node/taskqueues-backup-images/functions/index.js#L111-L128 +export async function getFunctionUrl(name: string) { + const auth = new GoogleAuth({ + projectId: config.projectId, + }); + + const url = `https://cloudfunctions.googleapis.com/v2beta/projects/${config.projectId}/locations/us-central1/functions/${name}`; + const client = await auth.getClient(); + const res: any = await client.request({ url }); + const uri = res.data?.serviceConfig?.uri; + + if (!uri) { + throw new Error(`Function ${name} not found`); + } + + return uri; +} diff --git a/integration_test/functions/src/index.ts b/integration_test/functions/src/index.ts index 79449cc7b..eefe7c055 100644 --- a/integration_test/functions/src/index.ts +++ b/integration_test/functions/src/index.ts @@ -1,230 +1,38 @@ -import { PubSub } from "@google-cloud/pubsub"; -import { GoogleAuth } from "google-auth-library"; -import { Request, Response } from "express"; -import * as admin from "firebase-admin"; -import * as functions from "firebase-functions"; -import fs from "fs"; -import fetch from "node-fetch"; - -import * as v1 from "./v1"; -import * as v2 from "./v2"; -const getNumTests = (m: object): number => { - return Object.keys(m).filter((k) => ({}.hasOwnProperty.call(m[k], "__endpoint"))).length; +import * as databaseV1 from "./v1/database.v1"; +import * as firestoreV1 from "./v1/firestore.v1"; +import * as httpsV1 from "./v1/https.v1"; +import * as pubsubV1 from "./v1/pubsub.v1"; +import * as remoteConfigV1 from "./v1/remoteConfig.v1"; +import * as storageV1 from "./v1/storage.v1"; +import * as tasksV1 from "./v1/tasks.v1"; + +import * as database from "./v2/database.v2"; +import * as eventarc from "./v2/eventarc.v2"; +import * as firestore from "./v2/firestore.v2"; +import * as https from "./v2/https.v2"; +import * as identity from "./v2/identity.v2"; +import * as pubsub from "./v2/pubsub.v2"; +import * as remoteConfig from "./v2/remoteConfig.v2"; +import * as scheduler from "./v2/scheduler.v2"; +import * as storage from "./v2/storage.v2"; +import * as tasks from "./v2/tasks.v2"; + +export const test = { + ...databaseV1, + ...firestoreV1, + ...httpsV1, + ...pubsubV1, + ...remoteConfigV1, + ...storageV1, + ...tasksV1, + ...database, + ...eventarc, + ...firestore, + ...https, + ...identity, + ...pubsub, + ...remoteConfig, + ...scheduler, + ...storage, + ...tasks, }; -const numTests = getNumTests(v1) + getNumTests(v2); -export { v1, v2 }; - -import { REGION } from "./region"; -import * as testLab from "./v1/testLab-utils"; - -const firebaseConfig = JSON.parse(process.env.FIREBASE_CONFIG); -admin.initializeApp(); - -// Re-enable no-unused-var check once callable functions are testable again. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function callHttpsTrigger(name: string, data: any) { - const url = `https://${REGION}-${firebaseConfig.projectId}.cloudfunctions.net/${name}`; - const client = await new GoogleAuth().getIdTokenClient("32555940559.apps.googleusercontent.com"); - const resp = await client.request({ - url, - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ data }), - }); - if (resp.status > 200) { - throw Error(resp.statusText); - } -} - -// Re-enable no-unused-var check once callable functions are testable again. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function callV2HttpsTrigger(name: string, data: any, accessToken: string) { - const getFnResp = await fetch( - `https://cloudfunctions.googleapis.com/v2beta/projects/${firebaseConfig.projectId}/locations/${REGION}/functions/${name}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); - if (!getFnResp.ok) { - throw new Error(getFnResp.statusText); - } - const fn = await getFnResp.json(); - const uri = fn.serviceConfig?.uri; - if (!uri) { - throw new Error(`Cannot call v2 https trigger ${name} - no uri found`); - } - - const client = await new GoogleAuth().getIdTokenClient("32555940559.apps.googleusercontent.com"); - const invokeFnREsp = await client.request({ - url: uri, - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ data }), - }); - if (invokeFnREsp.status > 200) { - throw Error(invokeFnREsp.statusText); - } -} - -async function callScheduleTrigger(functionName: string, region: string, accessToken: string) { - const response = await fetch( - `https://cloudscheduler.googleapis.com/v1/projects/${firebaseConfig.projectId}/locations/us-central1/jobs/firebase-schedule-${functionName}-${region}:run`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - } - ); - if (!response.ok) { - throw new Error(`Failed request with status ${response.status}!`); - } - const data = await response.text(); - functions.logger.log(`Successfully scheduled function ${functionName}`, data); - return; -} - -async function callV2ScheduleTrigger(functionName: string, region: string, accessToken: string) { - const response = await fetch( - `https://cloudscheduler.googleapis.com/v1/projects/${firebaseConfig.projectId}/locations/us-central1/jobs/firebase-schedule-${functionName}-${region}:run`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - } - ); - if (!response.ok) { - throw new Error(`Failed request with status ${response.status}!`); - } - const data = await response.text(); - functions.logger.log(`Successfully scheduled v2 function ${functionName}`, data); - return; -} - -async function updateRemoteConfig(testId: string, accessToken: string): Promise { - const resp = await fetch( - `https://firebaseremoteconfig.googleapis.com/v1/projects/${firebaseConfig.projectId}/remoteConfig`, - { - method: "PUT", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json; UTF-8", - "Accept-Encoding": "gzip", - "If-Match": "*", - }, - body: JSON.stringify({ version: { description: testId } }), - } - ); - if (!resp.ok) { - throw new Error(resp.statusText); - } -} - -function v1Tests(testId: string, accessToken: string): Array> { - return [ - // A database write to trigger the Firebase Realtime Database tests. - admin.database().ref(`dbTests/${testId}/start`).set({ ".sv": "timestamp" }), - // A Pub/Sub publish to trigger the Cloud Pub/Sub tests. - new PubSub().topic("pubsubTests").publish(Buffer.from(JSON.stringify({ testId }))), - // A user creation to trigger the Firebase Auth user creation tests. - admin - .auth() - .createUser({ - email: `${testId}@fake.com`, - password: "secret", - displayName: `${testId}`, - }) - .then(async (userRecord) => { - // A user deletion to trigger the Firebase Auth user deletion tests. - await admin.auth().deleteUser(userRecord.uid); - }), - // A firestore write to trigger the Cloud Firestore tests. - admin.firestore().collection("tests").doc(testId).set({ test: testId }), - // Invoke a callable HTTPS trigger. - // TODO: Temporarily disable - doesn't work unless running on projects w/ permission to create public functions. - // callHttpsTrigger("v1-callableTests", { foo: "bar", testId }), - // A Remote Config update to trigger the Remote Config tests. - updateRemoteConfig(testId, accessToken), - // A storage upload to trigger the Storage tests - admin - .storage() - .bucket() - .upload("/tmp/" + testId + ".txt"), - testLab.startTestRun(firebaseConfig.projectId, testId, accessToken), - // Invoke the schedule for our scheduled function to fire - callScheduleTrigger("v1-schedule", "us-central1", accessToken), - ]; -} - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function v2Tests(testId: string, accessToken: string): Array> { - return [ - // Invoke a callable HTTPS trigger. - // TODO: Temporarily disable - doesn't work unless running on projects w/ permission to create public functions. - // callV2HttpsTrigger("v2-callabletests", { foo: "bar", testId }, accessToken), - // Invoke a scheduled trigger. - callV2ScheduleTrigger("v2-schedule", "us-central1", accessToken), - ]; -} - -export const integrationTests: any = functions - .region(REGION) - .runWith({ - timeoutSeconds: 540, - invoker: "private", - }) - .https.onRequest(async (req: Request, resp: Response) => { - const testId = admin.database().ref().push().key; - await admin.database().ref(`testRuns/${testId}/timestamp`).set(Date.now()); - const testIdRef = admin.database().ref(`testRuns/${testId}`); - functions.logger.info("testId is: ", testId); - fs.writeFile(`/tmp/${testId}.txt`, "test", () => undefined); - try { - const accessToken = await admin.credential.applicationDefault().getAccessToken(); - await Promise.all([ - ...v1Tests(testId, accessToken.access_token), - ...v2Tests(testId, accessToken.access_token), - ]); - // On test completion, check that all tests pass and reply "PASS", or provide further details. - functions.logger.info("Waiting for all tests to report they pass..."); - await new Promise((resolve, reject) => { - setTimeout(() => reject(new Error("Timeout")), 5 * 60 * 1000); - let testsExecuted = 0; - testIdRef.on("child_added", (snapshot) => { - if (snapshot.key === "timestamp") { - return; - } - testsExecuted += 1; - if (!snapshot.val().passed) { - reject(new Error(`test ${snapshot.key} failed; see database for details.`)); - return; - } - functions.logger.info(`${snapshot.key} passed (${testsExecuted} of ${numTests})`); - if (testsExecuted < numTests) { - // Not all tests have completed. Wait longer. - return; - } - // All tests have passed! - resolve(); - }); - }); - functions.logger.info("All tests pass!"); - resp.status(200).send("PASS \n"); - } catch (err) { - functions.logger.info(`Some tests failed: ${err}`, err); - resp - .status(500) - .send(`FAIL - details at ${functions.firebaseConfig().databaseURL}/testRuns/${testId}`); - } finally { - testIdRef.off("child_added"); - } - }); diff --git a/integration_test/functions/src/region.ts b/integration_test/functions/src/region.ts deleted file mode 100644 index 4ce175234..000000000 --- a/integration_test/functions/src/region.ts +++ /dev/null @@ -1,2 +0,0 @@ -// TODO: Add back support for selecting region for integration test once params is ready. -export const REGION = "us-central1"; diff --git a/integration_test/functions/src/remoteConfig.test.ts b/integration_test/functions/src/remoteConfig.test.ts new file mode 100644 index 000000000..a23f7f6c1 --- /dev/null +++ b/integration_test/functions/src/remoteConfig.test.ts @@ -0,0 +1,71 @@ +import { describe, it, beforeAll, expect } from "vitest"; +import { RUN_ID, waitForEvent } from "./utils"; +import { expectEventContext, expectCloudEvent } from "./assertions"; +import { remoteConfig } from "./firebase.server"; + +describe("remoteConfig", () => { + describe("onConfigUpdated", () => { + let v1Data: any; + let v2Data: any; + + beforeAll(async () => { + // Create a shared trigger that only executes once + let triggerPromise: Promise | null = null; + const getTrigger = () => { + if (!triggerPromise) { + triggerPromise = (async () => { + const template = await remoteConfig.getTemplate(); + template.version.description = RUN_ID; + await remoteConfig.validateTemplate(template); + await remoteConfig.publishTemplate(template); + })(); + } + return triggerPromise; + }; + + // Wait for both events in parallel, sharing the same trigger + [v1Data, v2Data] = await Promise.all([ + waitForEvent("onConfigUpdatedV1", getTrigger), + waitForEvent("onConfigUpdated", getTrigger), + ]); + }, 60_000); + + describe("v1", () => { + it("should have EventContext", () => { + expectEventContext(v1Data); + }); + + it("should have the correct data", () => { + expect(v1Data.update.versionNumber).toBeDefined(); + expect(v1Data.update.updateTime).toBeDefined(); + expect(v1Data.update.updateUser).toBeDefined(); + expect(v1Data.update.description).toBeDefined(); + expect(v1Data.update.description).toBe(RUN_ID); + expect(v1Data.update.updateOrigin).toBeDefined(); + expect(v1Data.update.updateOrigin).toBe("ADMIN_SDK_NODE"); + expect(v1Data.update.updateType).toBeDefined(); + expect(v1Data.update.updateType).toBe("INCREMENTAL_UPDATE"); + // rollback source optional in v1 + }); + }); + + describe("v2", () => { + it("should be a CloudEvent", () => { + expectCloudEvent(v2Data); + }); + + it("should have the correct data", () => { + expect(v2Data.update.versionNumber).toBeDefined(); + expect(v2Data.update.updateTime).toBeDefined(); + expect(v2Data.update.updateUser).toBeDefined(); + expect(v2Data.update.description).toBeDefined(); + expect(v2Data.update.description).toBe(RUN_ID); + expect(v2Data.update.updateOrigin).toBeDefined(); + expect(v2Data.update.updateOrigin).toBe("ADMIN_SDK_NODE"); + expect(v2Data.update.updateType).toBeDefined(); + expect(v2Data.update.updateType).toBe("INCREMENTAL_UPDATE"); + expect(v2Data.update.rollbackSource).toBeDefined(); + }); + }); + }); +}); diff --git a/integration_test/functions/src/serializers/database.ts b/integration_test/functions/src/serializers/database.ts new file mode 100644 index 000000000..a99552c04 --- /dev/null +++ b/integration_test/functions/src/serializers/database.ts @@ -0,0 +1,43 @@ +import { DatabaseEvent, DataSnapshot } from "firebase-functions/database"; +import { Change } from "firebase-functions/v2"; +import { serializeCloudEvent } from "."; +import { Reference } from "firebase-admin/database"; + +export function serializeDatabaseEvent(event: DatabaseEvent, eventData: any) { + return { + ...serializeCloudEvent(event), + params: event.params, + firebaseDatabaseHost: event.firebaseDatabaseHost, + instance: event.instance, + ref: event.ref, + location: event.location, + eventData, + }; +} + +export function serializeDataSnapshot(snapshot: DataSnapshot) { + return { + ref: serializeReference(snapshot.ref), + key: snapshot.key, + priority: snapshot.getPriority(), + exists: snapshot.exists(), + hasChildren: snapshot.hasChildren(), + hasChild: snapshot.hasChild("noop"), + numChildren: snapshot.numChildren(), + json: snapshot.toJSON(), + }; +} + +export function serializeReference(reference: Reference) { + return { + __type: "reference", + key: reference.key, + }; +} + +export function serializeChangeEvent(event: Change): any { + return { + before: serializeDataSnapshot(event.before), + after: serializeDataSnapshot(event.after), + }; +} diff --git a/integration_test/functions/src/serializers/firestore.ts b/integration_test/functions/src/serializers/firestore.ts new file mode 100644 index 000000000..359951d43 --- /dev/null +++ b/integration_test/functions/src/serializers/firestore.ts @@ -0,0 +1,116 @@ +import { + DocumentData, + DocumentReference, + DocumentSnapshot, + GeoPoint, + QuerySnapshot, + Timestamp, +} from "firebase-admin/firestore"; +import { + Change, + FirestoreAuthEvent, + FirestoreEvent, + QueryDocumentSnapshot, +} from "firebase-functions/firestore"; +import { serializeCloudEvent } from "./index"; + +export function serializeFirestoreAuthEvent( + event: FirestoreAuthEvent, + eventData: any +): any { + return { + ...serializeFirestoreEvent(event, eventData), + authId: event.authId, + authType: event.authType, + }; +} + +export function serializeFirestoreEvent(event: FirestoreEvent, eventData: any): any { + return { + ...serializeCloudEvent(event), + location: event.location, + project: event.project, + database: event.database, + namespace: event.namespace, + document: event.document, + params: event.params, + eventData, + }; +} + +export function serializeQuerySnapshot(snapshot: QuerySnapshot): any { + return { + docs: snapshot.docs.map(serializeQueryDocumentSnapshot), + }; +} + +export function serializeChangeEvent(event: Change): any { + return { + before: serializeQueryDocumentSnapshot(event.before), + after: serializeQueryDocumentSnapshot(event.after), + }; +} + +export function serializeQueryDocumentSnapshot(snapshot: QueryDocumentSnapshot): any { + return serializeDocumentSnapshot(snapshot); +} + +export function serializeDocumentSnapshot(snapshot: DocumentSnapshot): any { + return { + exists: snapshot.exists, + ref: serializeDocumentReference(snapshot.ref), + id: snapshot.id, + createTime: serializeTimestamp(snapshot.createTime), + updateTime: serializeTimestamp(snapshot.updateTime), + data: serializeDocumentData(snapshot.data() ?? {}), + }; +} + +export function serializeGeoPoint(geoPoint: GeoPoint): any { + return { + _type: "geopoint", + latitude: geoPoint.latitude, + longitude: geoPoint.longitude, + }; +} + +export function serializeTimestamp(timestamp?: Timestamp): any { + if (!timestamp) { + return null; + } + + return { + _type: "timestamp", + seconds: timestamp.seconds, + nanoseconds: timestamp.nanoseconds, + iso: timestamp.toDate().toISOString(), + }; +} + +export function serializeDocumentReference(reference: DocumentReference): any { + return { + _type: "reference", + path: reference.path, + id: reference.id, + }; +} + +function serializeDocumentData(data: DocumentData): any { + const result: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (value instanceof Timestamp) { + result[key] = serializeTimestamp(value); + } else if (value instanceof GeoPoint) { + result[key] = serializeGeoPoint(value); + } else if (value instanceof DocumentReference) { + result[key] = serializeDocumentReference(value); + } else if (Array.isArray(value)) { + result[key] = value.map(serializeDocumentData); + } else if (typeof value === "object" && value !== null) { + result[key] = serializeDocumentData(value); + } else { + result[key] = value; + } + } + return result; +} diff --git a/integration_test/functions/src/serializers/identity.ts b/integration_test/functions/src/serializers/identity.ts new file mode 100644 index 000000000..69ff48fba --- /dev/null +++ b/integration_test/functions/src/serializers/identity.ts @@ -0,0 +1,29 @@ +import { AuthBlockingEvent } from "firebase-functions/identity"; +import { EventContext } from "firebase-functions/v1"; + +// v1? +function serializeEventContext(ctx: EventContext): any { + return { + auth: ctx.auth, + authType: ctx.authType, + eventId: ctx.eventId, + eventType: ctx.eventType, + params: ctx.params, + resource: ctx.resource, + timestamp: ctx.timestamp, + }; +} + +export function serializeAuthBlockingEvent(event: AuthBlockingEvent): any { + return { + ...serializeEventContext(event), + locale: event.locale, + ipAddress: event.ipAddress, + userAgent: event.userAgent, + additionalUserInfo: event.additionalUserInfo, + credential: event.credential, + emailType: event.emailType, + smsType: event.smsType, + data: event.data, + }; +} diff --git a/integration_test/functions/src/serializers/index.ts b/integration_test/functions/src/serializers/index.ts new file mode 100644 index 000000000..ae86e62c0 --- /dev/null +++ b/integration_test/functions/src/serializers/index.ts @@ -0,0 +1,26 @@ +import { CloudEvent } from "firebase-functions"; +import { EventContext } from "firebase-functions/v1"; + +export function serializeCloudEvent(event: CloudEvent): any { + return { + specversion: event.specversion, + id: event.id, + source: event.source, + subject: event.subject, + type: event.type, + time: event.time, + }; +} + +// v1 +export function serializeEventContext(ctx: EventContext): any { + return { + auth: ctx.auth, + authType: ctx.authType, + eventId: ctx.eventId, + eventType: ctx.eventType, + params: ctx.params, + resource: ctx.resource, + timestamp: ctx.timestamp, + }; +} diff --git a/integration_test/functions/src/serializers/storage.ts b/integration_test/functions/src/serializers/storage.ts new file mode 100644 index 000000000..c81415754 --- /dev/null +++ b/integration_test/functions/src/serializers/storage.ts @@ -0,0 +1,40 @@ +import { serializeCloudEvent } from "."; +import { StorageEvent, StorageObjectData } from "firebase-functions/v2/storage"; + +export function serializeStorageEvent(event: StorageEvent): any { + return { + ...serializeCloudEvent(event), + bucket: event.bucket, // Exposed at top-level and object level + object: serializeStorageObjectData(event.data), + }; +} + +function serializeStorageObjectData(data: StorageObjectData): any { + return { + bucket: data.bucket, + cacheControl: data.cacheControl, + componentCount: data.componentCount, + contentDisposition: data.contentDisposition, + contentEncoding: data.contentEncoding, + contentLanguage: data.contentLanguage, + contentType: data.contentType, + crc32c: data.crc32c, + customerEncryption: data.customerEncryption, + etag: data.etag, + generation: data.generation, + id: data.id, + kind: data.kind, + md5Hash: data.md5Hash, + mediaLink: data.mediaLink, + metadata: data.metadata, + metageneration: data.metageneration, + name: data.name, + selfLink: data.selfLink, + size: data.size, + storageClass: data.storageClass, + timeCreated: data.timeCreated, + timeDeleted: data.timeDeleted, + timeStorageClassUpdated: data.timeStorageClassUpdated, + updated: data.updated, + }; +} diff --git a/integration_test/functions/src/storage.test.ts b/integration_test/functions/src/storage.test.ts new file mode 100644 index 000000000..e54bb6bc4 --- /dev/null +++ b/integration_test/functions/src/storage.test.ts @@ -0,0 +1,223 @@ +import { describe, it, beforeAll, expect, afterAll } from "vitest"; +import { RUN_ID, waitForEvent } from "./utils"; +import { storage } from "./firebase.server"; +import { config } from "./config"; +import { expectStorageObjectData } from "./assertions/storage"; +import { expectEventContext, expectCloudEvent } from "./assertions"; + +const bucket = storage.bucket(config.storageBucket); +const filename = `dummy-file-${RUN_ID}.txt`; + +async function createDummyFile() { + const buffer = Buffer.from("Hello, world!"); + const file = bucket.file(filename); + await file.save(buffer); + const [metadata] = await file.getMetadata(); + return metadata; +} + +describe("storage", () => { + let createdFile: Awaited>; + let v1UploadedData: any; + let v2UploadedData: any; + let v1MetadataData: any; + let v2MetadataData: any; + let v1DeletedData: any; + let v2DeletedData: any; + + // Since storage triggers are bucket wide, we perform all events at the top-level + // in a specific order, then assert the values at the end. + beforeAll(async () => { + // Create file - triggers both v1 and v2 onObjectFinalized + let createFilePromise: Promise | null = null; + const getCreateFileTrigger = () => { + if (!createFilePromise) { + createFilePromise = (async () => { + createdFile = await createDummyFile(); + })(); + } + return createFilePromise; + }; + + [v1UploadedData, v2UploadedData] = await Promise.all([ + waitForEvent("onObjectFinalizedV1", getCreateFileTrigger), + waitForEvent("onObjectFinalized", getCreateFileTrigger), + ]); + + // Update metadata - triggers both v1 and v2 onObjectMetadataUpdated + let updateMetadataPromise: Promise | null = null; + const getUpdateMetadataTrigger = () => { + if (!updateMetadataPromise) { + updateMetadataPromise = (async () => { + await bucket.file(createdFile.name).setMetadata({ + runId: RUN_ID, + }); + })(); + } + return updateMetadataPromise; + }; + + [v1MetadataData, v2MetadataData] = await Promise.all([ + waitForEvent("onObjectMetadataUpdatedV1", getUpdateMetadataTrigger), + waitForEvent("onObjectMetadataUpdated", getUpdateMetadataTrigger), + ]); + + // Delete file - triggers both v1 and v2 onObjectDeleted + let deleteFilePromise: Promise | null = null; + const getDeleteFileTrigger = () => { + if (!deleteFilePromise) { + deleteFilePromise = (async () => { + await bucket.file(createdFile.name).delete(); + })(); + } + return deleteFilePromise; + }; + + [v1DeletedData, v2DeletedData] = await Promise.all([ + waitForEvent("onObjectDeletedV1", getDeleteFileTrigger), + waitForEvent("onObjectDeleted", getDeleteFileTrigger), + ]); + }, 60_000); + + afterAll(async () => { + // Just in case the file wasn't deleted by the trigger if it failed. + await bucket.file(createdFile.name).delete({ + ignoreNotFound: true, + }); + }); + + describe("onObjectDeleted", () => { + describe("v1", () => { + it("should have event context", () => { + expectEventContext(v1DeletedData); + }); + + it("should have the correct data", () => { + expect(v1DeletedData.object.bucket).toBe(config.storageBucket); + // Use the actual filename from the object data + const actualFilename = v1DeletedData.object.name || filename; + expectStorageObjectData(v1DeletedData.object, actualFilename); + }); + + // TODO: Doesn't seem to be sent by Google Cloud? + it.skip("should contain a timeDeleted timestamp", () => { + expect(v1DeletedData.object.timeDeleted).toBeDefined(); + expect(Date.parse(v1DeletedData.object.timeDeleted)).toBeGreaterThan(0); + }); + }); + + describe("v2", () => { + it("should be a CloudEvent", () => { + expectCloudEvent(v2DeletedData); + }); + + it("should have the correct data", () => { + expect(v2DeletedData.bucket).toBe(config.storageBucket); + expectStorageObjectData(v2DeletedData.object, filename); + }); + + // TODO: Doesn't seem to be sent by Google Cloud? + it.skip("should contain a timeDeleted timestamp", () => { + expect(v2DeletedData.object.timeDeleted).toBeDefined(); + expect(Date.parse(v2DeletedData.object.timeDeleted)).toBeGreaterThan(0); + }); + }); + }); + + describe("onObjectMetadataUpdated", () => { + describe("v1", () => { + it("should have event context", () => { + // Note: onObjectMetadataUpdated may not always have event context in v1 + if (v1MetadataData.eventId !== undefined) { + expect(v1MetadataData.eventId).toBeDefined(); + expect(v1MetadataData.eventType).toBeDefined(); + expect(v1MetadataData.timestamp).toBeDefined(); + expect(v1MetadataData.resource).toBeDefined(); + } + }); + + it("should have the correct data", () => { + expect(v1MetadataData.object.bucket).toBe(config.storageBucket); + // Use the actual filename from the object data + const actualFilename = v1MetadataData.object.name || filename; + expectStorageObjectData(v1MetadataData.object, actualFilename); + }); + + // TODO: Doesn't seem to be sent by Google Cloud? + it.skip("should have metadata", () => { + expect(v1MetadataData.object.metadata).toBeDefined(); + expect(v1MetadataData.object.metadata.runId).toBe(RUN_ID); + }); + }); + + describe("v2", () => { + it("should be a CloudEvent", () => { + expectCloudEvent(v2MetadataData); + }); + + it("should have the correct data", () => { + expect(v2MetadataData.bucket).toBe(config.storageBucket); + expectStorageObjectData(v2MetadataData.object, filename); + }); + + // TODO: Doesn't seem to be sent by Google Cloud? + it.skip("should have metadata", () => { + expect(v2MetadataData.metadata).toBeDefined(); + expect(v2MetadataData.metadata.runId).toBe(RUN_ID); + }); + }); + }); + + describe("onObjectFinalized", () => { + describe("v1", () => { + it("should have event context", () => { + expect(v1UploadedData.eventId).toBeDefined(); + expect(v1UploadedData.eventType).toBeDefined(); + expect(v1UploadedData.timestamp).toBeDefined(); + expect(v1UploadedData.resource).toBeDefined(); + }); + + it("should have the correct data", () => { + expect(v1UploadedData.object.bucket).toBe(config.storageBucket); + // Use the actual filename from the object data + const actualFilename = v1UploadedData.object.name || filename; + expectStorageObjectData(v1UploadedData.object, actualFilename); + }); + + // TODO: Doesn't seem to be sent by Google Cloud? + it.skip("should not have initial metadata", () => { + expect(v1UploadedData.object.metadata).toBeDefined(); + expect(v1UploadedData.object.metadata.runId).not.toBeUndefined(); + }); + + // TODO: Doesn't seem to be sent by Google Cloud? + it.skip("should contain a timeCreated timestamp", () => { + expect(v1UploadedData.object.timeCreated).toBeDefined(); + expect(Date.parse(v1UploadedData.object.timeCreated)).toBeGreaterThan(0); + }); + }); + + describe("v2", () => { + it("should be a CloudEvent", () => { + expectCloudEvent(v2UploadedData); + }); + + it("should have the correct data", () => { + expect(v2UploadedData.bucket).toBe(config.storageBucket); + expectStorageObjectData(v2UploadedData.object, filename); + }); + + // TODO: Doesn't seem to be sent by Google Cloud? + it.skip("should not have initial metadata", () => { + expect(v2UploadedData.object.metadata).toBeDefined(); + expect(v2UploadedData.object.metadata.runId).not.toBeUndefined(); + }); + + // TODO: Doesn't seem to be sent by Google Cloud? + it.skip("should contain a timeCreated timestamp", () => { + expect(v2UploadedData.object.timeCreated).toBeDefined(); + expect(Date.parse(v2UploadedData.object.timeCreated)).toBeGreaterThan(0); + }); + }); + }); +}); diff --git a/integration_test/functions/src/testing.ts b/integration_test/functions/src/testing.ts deleted file mode 100644 index 156e94242..000000000 --- a/integration_test/functions/src/testing.ts +++ /dev/null @@ -1,134 +0,0 @@ -import * as firebase from "firebase-admin"; -import * as functions from "firebase-functions"; - -export type TestCase = (data: T, context?: functions.EventContext) => any; -export interface TestCaseMap { - [key: string]: TestCase; -} - -export class TestSuite { - private name: string; - private tests: TestCaseMap; - - constructor(name: string, tests: TestCaseMap = {}) { - this.name = name; - this.tests = tests; - } - - it(name: string, testCase: TestCase): TestSuite { - this.tests[name] = testCase; - return this; - } - - run(testId: string, data: T, context?: functions.EventContext): Promise { - const running: Array> = []; - for (const testName in this.tests) { - if (!this.tests.hasOwnProperty(testName)) { - continue; - } - const run = Promise.resolve() - .then(() => this.tests[testName](data, context)) - .then( - (result) => { - functions.logger.info( - `${result ? "Passed" : "Failed with successful op"}: ${testName}` - ); - return { name: testName, passed: !!result }; - }, - (error) => { - console.error(`Failed: ${testName}`, error); - return { name: testName, passed: 0, error }; - } - ); - running.push(run); - } - return Promise.all(running).then((results) => { - let sum = 0; - // eslint-disable-next-line @typescript-eslint/restrict-plus-operands - results.forEach((val) => (sum = sum + val.passed)); - const summary = `passed ${sum} of ${running.length}`; - const passed = sum === running.length; - functions.logger.info(summary); - const result = { passed, summary, tests: results }; - return firebase.database().ref(`testRuns/${testId}/${this.name}`).set(result); - }); - } -} - -export function success() { - return Promise.resolve().then(() => true); -} - -function failure(reason: string) { - return Promise.reject(reason); -} - -export function evaluate(value: boolean, errMsg: string) { - if (value) { - return success(); - } - return failure(errMsg); -} - -export function expectEq(left: any, right: any) { - return evaluate( - left === right, - JSON.stringify(left) + " does not equal " + JSON.stringify(right) - ); -} - -function deepEq(left: any, right: any) { - if (left === right) { - return true; - } - - if (!(left instanceof Object && right instanceof Object)) { - return false; - } - - if (Object.keys(left).length !== Object.keys(right).length) { - return false; - } - - for (const key in left) { - if (Object.prototype.hasOwnProperty.call(left, key)) { - if (!Object.prototype.hasOwnProperty.call(right, key)) { - return false; - } - if (!deepEq(left[key], right[key])) { - return false; - } - } - } - - return true; -} - -export function expectDeepEq(left: any, right: any) { - return evaluate( - deepEq(left, right), - `${JSON.stringify(left)} does not deep equal ${JSON.stringify(right)}` - ); -} - -export function expectMatches(input: string, regexp: RegExp) { - return evaluate( - input.match(regexp) !== null, - `Input '${input}' did not match regexp '${regexp}'` - ); -} - -export function expectReject(f: (e: EventType) => Promise) { - return async (event: EventType) => { - let rejected = false; - try { - await f(event); - } catch { - rejected = true; - } - - if (!rejected) { - throw new Error("Test should have returned a rejected promise"); - } - }; -} diff --git a/integration_test/functions/src/utils.ts b/integration_test/functions/src/utils.ts new file mode 100644 index 000000000..1dc639196 --- /dev/null +++ b/integration_test/functions/src/utils.ts @@ -0,0 +1,50 @@ +import { firestore } from "./firebase.server"; + +export const RUN_ID = String(process.env.RUN_ID); + +export async function sendEvent(event: string, data: any): Promise { + await firestore.collection(RUN_ID).doc(event).set(data); +} + +export function waitForEvent( + event: string, + trigger: () => Promise, + timeoutMs: number = 60_000 +): Promise { + return new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | null = null; + let triggerCompleted = false; + let snapshotData: T | null = null; + let unsubscribe: (() => void) | null = null; + + const checkAndResolve = () => { + if (triggerCompleted && snapshotData !== null) { + if (timer) clearTimeout(timer); + if (unsubscribe) unsubscribe(); + resolve(snapshotData); + } + }; + + unsubscribe = firestore + .collection(RUN_ID) + .doc(event) + .onSnapshot((snapshot) => { + if (snapshot.exists) { + snapshotData = snapshot.data() as T; + checkAndResolve(); + } + }); + + timer = setTimeout(() => { + if (unsubscribe) unsubscribe(); + reject(new Error(`Timeout waiting for event "${event}" after ${timeoutMs}ms`)); + }, timeoutMs); + + trigger() + .then(() => { + triggerCompleted = true; + checkAndResolve(); + }) + .catch(reject); + }); +} diff --git a/integration_test/functions/src/v1/auth-tests.ts b/integration_test/functions/src/v1/auth-tests.ts deleted file mode 100644 index 5d1b6188a..000000000 --- a/integration_test/functions/src/v1/auth-tests.ts +++ /dev/null @@ -1,65 +0,0 @@ -import * as admin from "firebase-admin"; -import * as functions from "firebase-functions"; -import { REGION } from "../region"; -import { expectEq, TestSuite } from "../testing"; -import UserMetadata = admin.auth.UserRecord; - -export const createUserTests: any = functions - .region(REGION) - .auth.user() - .onCreate((u, c) => { - const testId: string = u.displayName; - functions.logger.info(`testId is ${testId}`); - - return new TestSuite("auth user onCreate") - .it("should have a project as resource", (user, context) => - expectEq(context.resource.name, `projects/${process.env.GCLOUD_PROJECT}`) - ) - - .it("should not have a path", (user, context) => expectEq((context as any).path, undefined)) - - .it("should have the correct eventType", (user, context) => - expectEq(context.eventType, "google.firebase.auth.user.create") - ) - - .it("should have an eventId", (user, context) => context.eventId) - - .it("should have a timestamp", (user, context) => context.timestamp) - - .it("should not have auth", (user, context) => expectEq((context as any).auth, undefined)) - - .it("should not have action", (user, context) => expectEq((context as any).action, undefined)) - - .it("should have properly defined meta", (user) => user.metadata) - - .run(testId, u, c); - }); - -export const deleteUserTests: any = functions - .region(REGION) - .auth.user() - .onDelete((u, c) => { - const testId: string = u.displayName; - functions.logger.info(`testId is ${testId}`); - - return new TestSuite("auth user onDelete") - .it("should have a project as resource", (user, context) => - expectEq(context.resource.name, `projects/${process.env.GCLOUD_PROJECT}`) - ) - - .it("should not have a path", (user, context) => expectEq((context as any).path, undefined)) - - .it("should have the correct eventType", (user, context) => - expectEq(context.eventType, "google.firebase.auth.user.delete") - ) - - .it("should have an eventId", (user, context) => context.eventId) - - .it("should have a timestamp", (user, context) => context.timestamp) - - .it("should not have auth", (user, context) => expectEq((context as any).auth, undefined)) - - .it("should not have action", (user, context) => expectEq((context as any).action, undefined)) - - .run(testId, u, c); - }); diff --git a/integration_test/functions/src/v1/database-tests.ts b/integration_test/functions/src/v1/database-tests.ts deleted file mode 100644 index df9d3cdd2..000000000 --- a/integration_test/functions/src/v1/database-tests.ts +++ /dev/null @@ -1,75 +0,0 @@ -import * as admin from "firebase-admin"; -import * as functions from "firebase-functions"; -import { REGION } from "../region"; -import { expectEq, expectMatches, TestSuite } from "../testing"; -import DataSnapshot = admin.database.DataSnapshot; - -const testIdFieldName = "testId"; - -export const databaseTests: any = functions - .region(REGION) - .database.ref("dbTests/{testId}/start") - .onWrite((ch, ctx) => { - if (ch.after.val() === null) { - functions.logger.info( - `Event for ${ctx.params[testIdFieldName]} is null; presuming data cleanup, so skipping.` - ); - return; - } - - return new TestSuite>("database ref onWrite") - - .it("should not have event.app", (change, context) => !(context as any).app) - - .it("should give refs access to admin data", (change) => - change.after.ref.parent - .child("adminOnly") - .update({ allowed: 1 }) - .then(() => true) - ) - - .it("should have a correct ref url", (change) => { - const url = change.after.ref.toString(); - return Promise.resolve() - .then(() => { - return expectMatches( - url, - new RegExp( - `^https://${process.env.GCLOUD_PROJECT}(-default-rtdb)*.firebaseio.com/dbTests` - ) - ); - }) - .then(() => { - return expectMatches(url, /\/start$/); - }); - }) - - .it("should have refs resources", (change, context) => - expectMatches( - context.resource.name, - new RegExp( - `^projects/_/instances/${process.env.GCLOUD_PROJECT}(-default-rtdb)*/refs/dbTests/${context.params.testId}/start$` - ) - ) - ) - - .it("should not include path", (change, context) => - expectEq((context as any).path, undefined) - ) - - .it("should have the right eventType", (change, context) => - expectEq(context.eventType, "google.firebase.database.ref.write") - ) - - .it("should have eventId", (change, context) => context.eventId) - - .it("should have timestamp", (change, context) => context.timestamp) - - .it("should not have action", (change, context) => - expectEq((context as any).action, undefined) - ) - - .it("should have admin authType", (change, context) => expectEq(context.authType, "ADMIN")) - - .run(ctx.params[testIdFieldName], ch, ctx); - }); diff --git a/integration_test/functions/src/v1/database.v1.test.ts b/integration_test/functions/src/v1/database.v1.test.ts new file mode 100644 index 000000000..8fd276897 --- /dev/null +++ b/integration_test/functions/src/v1/database.v1.test.ts @@ -0,0 +1,117 @@ +import { describe, it, beforeAll, expect } from "vitest"; +import { RUN_ID, waitForEvent } from "../utils"; +import { database } from "../firebase.server"; +import { expectDataSnapshot } from "../assertions/database"; + +describe("database.v1", () => { + describe("onValueCreated", () => { + let data: any; + let refPath: string; + + beforeAll(async () => { + data = await waitForEvent("onValueCreatedV1", async () => { + const testData = { + foo: "bar", + number: 42, + nested: { + key: "value", + }, + }; + refPath = `integration_test/${RUN_ID}/onValueCreatedV1/${Date.now()}`; + await database.ref(refPath).set(testData); + }); + }, 60_000); + + it("should have a DataSnapshot", () => { + expectDataSnapshot(data, refPath); + }); + + it("should have the correct data", () => { + const value = data.json; + expect(value.foo).toBe("bar"); + expect(value.number).toBe(42); + expect(value.nested).toBeDefined(); + expect(value.nested.key).toBe("value"); + }); + }); + + describe("onValueUpdated", () => { + let data: any; + let refPath: string; + + beforeAll(async () => { + data = await waitForEvent("onValueUpdatedV1", async () => { + const initialData = { + foo: "bar", + number: 42, + nested: { + key: "value", + }, + }; + refPath = `integration_test/${RUN_ID}/onValueUpdatedV1/${Date.now()}`; + await database.ref(refPath).set(initialData); + await new Promise((resolve) => setTimeout(resolve, 3000)); + await database.ref(refPath).update({ + foo: "baz", + number: 100, + }); + }); + }, 60_000); + + it("should be a Change event with snapshots", () => { + const before = data.before; + const after = data.after; + expectDataSnapshot(before, refPath); + expectDataSnapshot(after, refPath); + }); + + it("before event should have the correct data", () => { + const value = data.before.json; + expect(value.foo).toBe("bar"); + expect(value.number).toBe(42); + expect(value.nested).toBeDefined(); + expect(value.nested.key).toBe("value"); + }); + + it("after event should have the correct data", () => { + const value = data.after.json; + expect(value.foo).toBe("baz"); + expect(value.number).toBe(100); + expect(value.nested).toBeDefined(); + expect(value.nested.key).toBe("value"); + }); + }); + + describe("onValueDeleted", () => { + let data: any; + let refPath: string; + + beforeAll(async () => { + data = await waitForEvent("onValueDeletedV1", async () => { + const testData = { + foo: "bar", + number: 42, + nested: { + key: "value", + }, + }; + refPath = `integration_test/${RUN_ID}/onValueDeletedV1/${Date.now()}`; + await database.ref(refPath).set(testData); + await new Promise((resolve) => setTimeout(resolve, 3000)); + await database.ref(refPath).remove(); + }); + }, 60_000); + + it("should have a DataSnapshot", () => { + expectDataSnapshot(data, refPath); + }); + + it("should have the correct data", () => { + const value = data.json; + expect(value.foo).toBe("bar"); + expect(value.number).toBe(42); + expect(value.nested).toBeDefined(); + expect(value.nested.key).toBe("value"); + }); + }); +}); diff --git a/integration_test/functions/src/v1/database.v1.ts b/integration_test/functions/src/v1/database.v1.ts new file mode 100644 index 000000000..8b9bcf93d --- /dev/null +++ b/integration_test/functions/src/v1/database.v1.ts @@ -0,0 +1,27 @@ +import * as functions from "firebase-functions/v1"; +import { serializeChangeEvent, serializeDataSnapshot } from "../serializers/database"; +import { sendEvent } from "../utils"; + +export const databaseV1OnValueCreated = functions.database + .ref(`integration_test/{runId}/onValueCreatedV1/{timestamp}`) + .onCreate(async (snapshot) => { + await sendEvent("onValueCreatedV1", serializeDataSnapshot(snapshot)); + }); + +export const databaseV1OnValueUpdated = functions.database + .ref(`integration_test/{runId}/onValueUpdatedV1/{timestamp}`) + .onUpdate(async (change) => { + await sendEvent("onValueUpdatedV1", serializeChangeEvent(change)); + }); + +export const databaseV1OnValueDeleted = functions.database + .ref(`integration_test/{runId}/onValueDeletedV1/{timestamp}`) + .onDelete(async (snapshot) => { + await sendEvent("onValueDeletedV1", serializeDataSnapshot(snapshot)); + }); + +export const test = { + databaseV1OnValueCreated, + databaseV1OnValueUpdated, + databaseV1OnValueDeleted, +}; diff --git a/integration_test/functions/src/v1/firestore-tests.ts b/integration_test/functions/src/v1/firestore-tests.ts deleted file mode 100644 index b986ca06a..000000000 --- a/integration_test/functions/src/v1/firestore-tests.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as admin from "firebase-admin"; -import * as functions from "firebase-functions"; -import { REGION } from "../region"; -import { expectDeepEq, expectEq, TestSuite } from "../testing"; -import DocumentSnapshot = admin.firestore.DocumentSnapshot; - -const testIdFieldName = "documentId"; - -export const firestoreTests: any = functions - .runWith({ - timeoutSeconds: 540, - }) - .region(REGION) - .firestore.document("tests/{documentId}") - .onCreate((s, c) => { - return new TestSuite("firestore document onWrite") - - .it("should not have event.app", (snap, context) => !(context as any).app) - - .it("should give refs write access", (snap) => - snap.ref.set({ allowed: 1 }, { merge: true }).then(() => true) - ) - - .it("should have well-formatted resource", (snap, context) => - expectEq( - context.resource.name, - `projects/${process.env.GCLOUD_PROJECT}/databases/(default)/documents/tests/${context.params.documentId}` - ) - ) - - .it("should have the right eventType", (snap, context) => - expectEq(context.eventType, "google.firestore.document.create") - ) - - .it("should have eventId", (snap, context) => context.eventId) - - .it("should have timestamp", (snap, context) => context.timestamp) - - .it("should have the correct data", (snap, context) => - expectDeepEq(snap.data(), { test: context.params.documentId }) - ) - - .run(c.params[testIdFieldName], s, c); - }); diff --git a/integration_test/functions/src/v1/firestore.v1.test.ts b/integration_test/functions/src/v1/firestore.v1.test.ts new file mode 100644 index 000000000..0f27c1158 --- /dev/null +++ b/integration_test/functions/src/v1/firestore.v1.test.ts @@ -0,0 +1,121 @@ +import { describe, it, beforeAll, expect } from "vitest"; +import { waitForEvent, RUN_ID } from "../utils"; +import { firestore } from "../firebase.server"; +import { GeoPoint } from "firebase-admin/firestore"; +import { + expectGeoPoint, + expectQueryDocumentSnapshot, + expectTimestamp, +} from "../assertions/firestore"; + +describe("firestore.v1", () => { + describe("onDocumentCreated", () => { + let data: any; + let documentId: string; + + beforeAll(async () => { + data = await waitForEvent("onDocumentCreatedV1", async () => { + await firestore + .collection(`integration_test/${RUN_ID}/oDocumentCreatedV1`) + .add({ + foo: "bar", + timestamp: new Date(), + geopoint: new GeoPoint(10, 20), + }) + .then((doc) => { + documentId = doc.id; + }); + }); + }, 60_000); + + it("should be a QueryDocumentSnapshot", () => { + expectQueryDocumentSnapshot(data, "oDocumentCreatedV1", documentId); + }); + + it("should have the correct data", () => { + const value = data.data; + expect(value.foo).toBe("bar"); + expectTimestamp(value.timestamp); + expectGeoPoint(value.geopoint); + }); + }); + + describe("onDocumentUpdated", () => { + let data: any; + let documentId: string; + + beforeAll(async () => { + data = await waitForEvent("onDocumentUpdatedV1", async () => { + await firestore + .collection(`integration_test/${RUN_ID}/oDocumentUpdatedV1`) + .add({ + foo: "bar", + timestamp: new Date(), + geopoint: new GeoPoint(10, 20), + }) + .then(async (doc) => { + await new Promise((resolve) => setTimeout(resolve, 3000)); + await doc.update({ + foo: "baz", + }); + return doc; + }) + .then((doc) => { + documentId = doc.id; + }); + }); + }, 60_000); + + it("should be a Change event with snapshots", () => { + const before = data.before; + const after = data.after; + expectQueryDocumentSnapshot(before, "oDocumentUpdatedV1", documentId); + expectQueryDocumentSnapshot(after, "oDocumentUpdatedV1", documentId); + }); + + it("before event should have the correct data", () => { + const value = data.before.data; + expect(value.foo).toBe("bar"); + expectTimestamp(value.timestamp); + expectGeoPoint(value.geopoint); + }); + + it("after event should have the correct data", () => { + const value = data.after.data; + expect(value.foo).toBe("baz"); + expectTimestamp(value.timestamp); + expectGeoPoint(value.geopoint); + }); + }); + + describe("onDocumentDeleted", () => { + let data: any; + let documentId: string; + + beforeAll(async () => { + data = await waitForEvent("onDocumentDeletedV1", async () => { + const docRef = await firestore + .collection(`integration_test/${RUN_ID}/oDocumentDeletedV1`) + .add({ + foo: "bar", + timestamp: new Date(), + geopoint: new GeoPoint(10, 20), + }); + documentId = docRef.id; + await new Promise((resolve) => setTimeout(resolve, 3000)); + await docRef.delete(); + }); + }, 60_000); + + it("should be a QueryDocumentSnapshot", () => { + expectQueryDocumentSnapshot(data, "oDocumentDeletedV1", documentId); + }); + + it("should have the correct data", () => { + const value = data.data; + expect(value.foo).toBe("bar"); + expectTimestamp(value.timestamp); + expectGeoPoint(value.geopoint); + }); + }); +}); diff --git a/integration_test/functions/src/v1/firestore.v1.ts b/integration_test/functions/src/v1/firestore.v1.ts new file mode 100644 index 000000000..9eec5c36d --- /dev/null +++ b/integration_test/functions/src/v1/firestore.v1.ts @@ -0,0 +1,27 @@ +import * as functions from "firebase-functions/v1"; +import { serializeChangeEvent, serializeQueryDocumentSnapshot } from "../serializers/firestore"; +import { sendEvent } from "../utils"; + +export const firestoreV1OnDocumentCreatedTrigger = functions.firestore + .document(`integration_test/{runId}/oDocumentCreatedV1/{documentId}`) + .onCreate(async (snapshot) => { + await sendEvent("onDocumentCreatedV1", serializeQueryDocumentSnapshot(snapshot)); + }); + +export const firestoreV1OnDocumentUpdatedTrigger = functions.firestore + .document(`integration_test/{runId}/oDocumentUpdatedV1/{documentId}`) + .onUpdate(async (change) => { + await sendEvent("onDocumentUpdatedV1", serializeChangeEvent(change)); + }); + +export const firestoreV1OnDocumentDeletedTrigger = functions.firestore + .document(`integration_test/{runId}/oDocumentDeletedV1/{documentId}`) + .onDelete(async (snapshot) => { + await sendEvent("onDocumentDeletedV1", serializeQueryDocumentSnapshot(snapshot)); + }); + +export const test = { + firestoreV1OnDocumentCreatedTrigger, + firestoreV1OnDocumentUpdatedTrigger, + firestoreV1OnDocumentDeletedTrigger, +}; diff --git a/integration_test/functions/src/v1/https-tests.ts b/integration_test/functions/src/v1/https-tests.ts deleted file mode 100644 index 5a74a1903..000000000 --- a/integration_test/functions/src/v1/https-tests.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as functions from "firebase-functions"; -import { REGION } from "../region"; -import { expectEq, TestSuite } from "../testing"; - -export const callableTests: any = functions - .runWith({ invoker: "private" }) - .region(REGION) - .https.onCall((d) => { - return new TestSuite("https onCall") - .it("should have the correct data", (data: any) => expectEq(data?.foo, "bar")) - .run(d.testId, d); - }); diff --git a/integration_test/functions/src/v1/https.v1.test.ts b/integration_test/functions/src/v1/https.v1.test.ts new file mode 100644 index 000000000..a7cb86e40 --- /dev/null +++ b/integration_test/functions/src/v1/https.v1.test.ts @@ -0,0 +1,68 @@ +import { describe, it, beforeAll, expect } from "vitest"; +import { fetch } from "undici"; +import { waitForEvent } from "../utils"; +import { httpsCallable } from "firebase/functions"; +import { functions } from "../firebase.client"; +import { getFunctionUrl } from "../firebase.server"; + +describe("https.v1", () => { + describe("httpsOnCallTrigger", () => { + let data: any; + let callData: any; + + beforeAll(async () => { + data = await waitForEvent("httpsOnCallV1", async () => { + const callable = httpsCallable(functions, "test-httpsV1OnCallTrigger"); + + // v1 doesn't support streaming, so just call normally + callData = await callable({ + foo: "bar", + }); + }); + }, 60_000); + + it("should accept the correct data", () => { + expect(data.data).toEqual({ foo: "bar" }); + }); + + it("should return the correct data", () => { + // TODO(ehesp): Check if this is correct + // v1 returns the response body directly: https://firebase.google.com/docs/functions/callable-reference#response_body + expect(callData.data).toBe("onCallV1"); + }); + }); + + describe("httpsOnRequestTrigger", () => { + let data: any; + let status: number; + let body: any; + + beforeAll(async () => { + data = await waitForEvent("httpsOnRequestV1", async () => { + const functionUrl = await getFunctionUrl("test-httpsV1OnRequestTrigger"); + const response = await fetch(functionUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ foo: "bar" }), + }); + + status = response.status; + body = await response.text(); + }); + }, 60_000); + + it("should accept the correct data", () => { + expect(data).toEqual({ foo: "bar" }); + }); + + it("should return the correct status", () => { + expect(status).toBe(201); + }); + + it("should return the correct body", () => { + expect(body).toBe("onRequestV1"); + }); + }); +}); diff --git a/integration_test/functions/src/v1/https.v1.ts b/integration_test/functions/src/v1/https.v1.ts new file mode 100644 index 000000000..41405c626 --- /dev/null +++ b/integration_test/functions/src/v1/https.v1.ts @@ -0,0 +1,25 @@ +import * as functions from "firebase-functions/v1"; +import { sendEvent } from "../utils"; + +export const httpsV1OnCallTrigger = functions + .runWith({ invoker: "public" }) + .https.onCall(async (data) => { + await sendEvent("httpsOnCallV1", { + data: data, + }); + + return "onCallV1"; + }); + +export const httpsV1OnRequestTrigger = functions + .runWith({ invoker: "public" }) + .https.onRequest(async (req, res) => { + await sendEvent("httpsOnRequestV1", req.body); + res.status(201).send("onRequestV1"); + return; + }); + +export const test = { + httpsV1OnCallTrigger, + httpsV1OnRequestTrigger, +}; diff --git a/integration_test/functions/src/v1/index.ts b/integration_test/functions/src/v1/index.ts deleted file mode 100644 index 0a1a2a35f..000000000 --- a/integration_test/functions/src/v1/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from "./pubsub-tests"; -export * from "./database-tests"; -export * from "./auth-tests"; -export * from "./firestore-tests"; -// Temporarily disable http test - will not work unless running on projects w/ permission to create public functions. -// export * from "./https-tests"; -export * from "./remoteConfig-tests"; -export * from "./storage-tests"; -export * from "./testLab-tests"; diff --git a/integration_test/functions/src/v1/pubsub-tests.ts b/integration_test/functions/src/v1/pubsub-tests.ts deleted file mode 100644 index 866e3218d..000000000 --- a/integration_test/functions/src/v1/pubsub-tests.ts +++ /dev/null @@ -1,67 +0,0 @@ -import * as admin from "firebase-admin"; -import * as functions from "firebase-functions"; -import { REGION } from "../region"; -import { evaluate, expectEq, success, TestSuite } from "../testing"; -import PubsubMessage = functions.pubsub.Message; - -// TODO(inlined) use multiple queues to run inline. -// Expected message data: {"hello": "world"} -export const pubsubTests: any = functions - .region(REGION) - .pubsub.topic("pubsubTests") - .onPublish((m, c) => { - let testId: string; - try { - testId = m.json.testId; - } catch (_e) { - // Ignored. Covered in another test case that `event.data.json` works. - } - - return new TestSuite("pubsub onPublish") - .it("should have a topic as resource", (message, context) => - expectEq(context.resource.name, `projects/${process.env.GCLOUD_PROJECT}/topics/pubsubTests`) - ) - - .it("should not have a path", (message, context) => - expectEq((context as any).path, undefined) - ) - - .it("should have the correct eventType", (message, context) => - expectEq(context.eventType, "google.pubsub.topic.publish") - ) - - .it("should have an eventId", (message, context) => context.eventId) - - .it("should have a timestamp", (message, context) => context.timestamp) - - .it("should not have auth", (message, context) => expectEq((context as any).auth, undefined)) - - .it("should not have action", (message, context) => - expectEq((context as any).action, undefined) - ) - - .it("should have pubsub data", (message) => { - const decoded = new Buffer(message.data, "base64").toString(); - const parsed = JSON.parse(decoded); - return evaluate(parsed.hasOwnProperty("testId"), `Raw data was + ${message.data}`); - }) - - .it("should decode JSON payloads with the json helper", (message) => - evaluate(message.json.hasOwnProperty("testId"), message.json) - ) - - .run(testId, m, c); - }); - -export const schedule: any = functions - .region(REGION) - .pubsub.schedule("every 10 hours") // This is a dummy schedule, since we need to put a valid one in. - // For the test, the job is triggered by the jobs:run api - .onRun(async () => { - const db = admin.database(); - const snap = await db.ref("testRuns").orderByChild("timestamp").limitToLast(1).once("value"); - const testId = Object.keys(snap.val())[0]; - return new TestSuite("pubsub scheduleOnRun") - .it("should trigger when the scheduler fires", () => success()) - .run(testId, null); - }); diff --git a/integration_test/functions/src/v1/pubsub.v1.test.ts b/integration_test/functions/src/v1/pubsub.v1.test.ts new file mode 100644 index 000000000..e6ba5715b --- /dev/null +++ b/integration_test/functions/src/v1/pubsub.v1.test.ts @@ -0,0 +1,36 @@ +import { PubSub } from "@google-cloud/pubsub"; +import { beforeAll, describe, expect, it } from "vitest"; +import { expectEventContext } from "../assertions"; +import { config } from "../config"; +import { waitForEvent } from "../utils"; + +describe("pubsub.v1", () => { + describe("onMessagePublished", () => { + let data: any; + + beforeAll(async () => { + data = await waitForEvent("onMessagePublishedV1", async () => { + const pubsub = new PubSub({ + projectId: config.projectId, + }); + + const [topic] = await pubsub.topic("vitest_message_v1").get({ autoCreate: true }); + + await topic.publishMessage({ + data: Buffer.from("Hello, world!"), + }); + }); + }, 60_000); + + it("should have EventContext", () => { + expectEventContext(data); + }); + + it("should be a valid Message", () => { + expect(data.message).toBeDefined(); + expect(data.message.attributes).toBeDefined(); + // Sent as base64 string so need to decode it. + expect(Buffer.from(data.message.data, "base64").toString("utf-8")).toBe("Hello, world!"); + }); + }); +}); diff --git a/integration_test/functions/src/v1/pubsub.v1.ts b/integration_test/functions/src/v1/pubsub.v1.ts new file mode 100644 index 000000000..fac7d9970 --- /dev/null +++ b/integration_test/functions/src/v1/pubsub.v1.ts @@ -0,0 +1,15 @@ +import * as functions from "firebase-functions/v1"; +import { sendEvent } from "../utils"; + +export const pubsubV1OnMessagePublishedTrigger = functions.pubsub + .topic("vitest_message_v1") + .onPublish(async (message, event) => { + await sendEvent("onMessagePublishedV1", { + ...event, + message: message.toJSON(), + }); + }); + +export const test = { + pubsubV1OnMessagePublishedTrigger, +}; diff --git a/integration_test/functions/src/v1/remoteConfig-tests.ts b/integration_test/functions/src/v1/remoteConfig-tests.ts deleted file mode 100644 index 416621774..000000000 --- a/integration_test/functions/src/v1/remoteConfig-tests.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as functions from "firebase-functions"; -import { REGION } from "../region"; -import { expectEq, TestSuite } from "../testing"; -import TemplateVersion = functions.remoteConfig.TemplateVersion; - -export const remoteConfigTests: any = functions.region(REGION).remoteConfig.onUpdate((v, c) => { - return new TestSuite("remoteConfig onUpdate") - .it("should have a project as resource", (version, context) => - expectEq(context.resource.name, `projects/${process.env.GCLOUD_PROJECT}`) - ) - - .it("should have the correct eventType", (version, context) => - expectEq(context.eventType, "google.firebase.remoteconfig.update") - ) - - .it("should have an eventId", (version, context) => context.eventId) - - .it("should have a timestamp", (version, context) => context.timestamp) - - .it("should not have auth", (version, context) => expectEq((context as any).auth, undefined)) - - .run(v.description, v, c); -}); diff --git a/integration_test/functions/src/v1/remoteConfig.v1.ts b/integration_test/functions/src/v1/remoteConfig.v1.ts new file mode 100644 index 000000000..5664d35d9 --- /dev/null +++ b/integration_test/functions/src/v1/remoteConfig.v1.ts @@ -0,0 +1,15 @@ +import * as functions from "firebase-functions/v1"; +import { sendEvent } from "../utils"; + +export const remoteConfigV1OnConfigUpdatedTests = functions.remoteConfig.onUpdate( + async (update, event) => { + await sendEvent("onConfigUpdatedV1", { + ...event, + update, + }); + } +); + +export const test = { + remoteConfigV1OnConfigUpdatedTests, +}; diff --git a/integration_test/functions/src/v1/storage-tests.ts b/integration_test/functions/src/v1/storage-tests.ts deleted file mode 100644 index 6819c7a2a..000000000 --- a/integration_test/functions/src/v1/storage-tests.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as functions from "firebase-functions"; -import { REGION } from "../region"; -import { expectEq, TestSuite } from "../testing"; -import ObjectMetadata = functions.storage.ObjectMetadata; - -export const storageTests: any = functions - .runWith({ - timeoutSeconds: 540, - }) - .region(REGION) - .storage.bucket() - .object() - .onFinalize((s, c) => { - const testId = s.name.split(".")[0]; - return new TestSuite("storage object finalize") - - .it("should not have event.app", (data, context) => !(context as any).app) - - .it("should have the right eventType", (snap, context) => - expectEq(context.eventType, "google.storage.object.finalize") - ) - - .it("should have eventId", (snap, context) => context.eventId) - - .it("should have timestamp", (snap, context) => context.timestamp) - - .run(testId, s, c); - }); diff --git a/integration_test/functions/src/v1/storage.v1.ts b/integration_test/functions/src/v1/storage.v1.ts new file mode 100644 index 000000000..74e13686f --- /dev/null +++ b/integration_test/functions/src/v1/storage.v1.ts @@ -0,0 +1,36 @@ +import * as functions from "firebase-functions/v1"; +import { serializeEventContext } from "../serializers"; +import { sendEvent } from "../utils"; + +export const storageV1OnObjectDeletedTrigger = functions.storage + .object() + .onDelete(async (object, ctx) => { + await sendEvent("onObjectDeletedV1", { + ...serializeEventContext(ctx), + object, + }); + }); + +export const storageV1OnObjectFinalizedTrigger = functions.storage + .object() + .onFinalize(async (object, ctx) => { + await sendEvent("onObjectFinalizedV1", { + ...serializeEventContext(ctx), + object, + }); + }); + +export const storageV1OnObjectMetadataUpdatedTrigger = functions.storage + .object() + .onMetadataUpdate(async (object, ctx) => { + await sendEvent("onObjectMetadataUpdatedV1", { + ...serializeEventContext(ctx), + object, + }); + }); + +export const test = { + storageV1OnObjectDeletedTrigger, + storageV1OnObjectFinalizedTrigger, + storageV1OnObjectMetadataUpdatedTrigger, +}; diff --git a/integration_test/functions/src/v1/tasks.v1.test.ts b/integration_test/functions/src/v1/tasks.v1.test.ts new file mode 100644 index 000000000..3d35a321d --- /dev/null +++ b/integration_test/functions/src/v1/tasks.v1.test.ts @@ -0,0 +1,60 @@ +import { describe, it, beforeAll, expect, assertType } from "vitest"; +import { CloudTasksClient } from "@google-cloud/tasks"; +import { RUN_ID, waitForEvent } from "../utils"; +import { getFunctionUrl } from "../firebase.server"; +import { config } from "../config"; + +const QUEUE_NAME = "test-tasksV1OnTaskDispatchedTrigger"; + +describe("tasks.v1", () => { + describe("onTaskDispatched", () => { + let data: any; + + beforeAll(async () => { + data = await waitForEvent("onTaskDispatchedV1 ", async () => { + const client = new CloudTasksClient({ + projectId: config.projectId, + }); + + const serviceAccountEmail = `${config.projectId}@appspot.gserviceaccount.com`; + + await client.createTask({ + parent: client.queuePath(config.projectId, "us-central1", QUEUE_NAME), + task: { + httpRequest: { + httpMethod: "POST", + url: await getFunctionUrl(QUEUE_NAME), + headers: { + "Content-Type": "application/json", + }, + oidcToken: { + serviceAccountEmail, + }, + body: Buffer.from( + JSON.stringify({ + data: { + id: RUN_ID, + }, + }) + ).toString("base64"), + }, + }, + }); + }); + }, 60_000); + + it("should have the correct data", () => { + expect(data.data.id).toBe(RUN_ID); + expect(data.executionCount).toBe(0); + expect(data.id).toBeDefined(); + assertType(data.id); + expect(data.id.length).toBeGreaterThan(0); + expect(data.queueName).toBe(QUEUE_NAME); + expect(data.retryCount).toBe(0); + + // TODO(ehesp): This should be a valid datetime string, but it comes through as + // a precision unix timestamp - looks like a bug to be fixed. + expect(data.scheduledTime).toBeDefined(); + }); + }); +}); diff --git a/integration_test/functions/src/v1/tasks.v1.ts b/integration_test/functions/src/v1/tasks.v1.ts new file mode 100644 index 000000000..39231c602 --- /dev/null +++ b/integration_test/functions/src/v1/tasks.v1.ts @@ -0,0 +1,26 @@ +import * as functions from "firebase-functions/v1"; +import { sendEvent } from "../utils"; + +export const tasksV1OnTaskDispatchedTrigger = functions.tasks + .taskQueue({ + retryConfig: { + maxAttempts: 0, + }, + }) + .onDispatch(async (data, event) => { + await sendEvent("onTaskDispatchedV1 ", { + queueName: event.queueName, + id: event.id, + retryCount: event.retryCount, + executionCount: event.executionCount, + scheduledTime: event.scheduledTime, + previousResponse: event.previousResponse, + retryReason: event.retryReason, + // headers: event.headers, // Contains some sensitive information so exclude for now + data, + }); + }); + +export const test = { + tasksV1OnTaskDispatchedTrigger, +}; diff --git a/integration_test/functions/src/v1/testLab-tests.ts b/integration_test/functions/src/v1/testLab-tests.ts deleted file mode 100644 index 242cd21f6..000000000 --- a/integration_test/functions/src/v1/testLab-tests.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as functions from "firebase-functions"; -import { REGION } from "../region"; -import { expectEq, TestSuite } from "../testing"; -import TestMatrix = functions.testLab.TestMatrix; - -export const testLabTests: any = functions - .runWith({ - timeoutSeconds: 540, - }) - .region(REGION) - .testLab.testMatrix() - .onComplete((matrix, context) => { - return new TestSuite("test matrix complete") - .it("should have eventId", (snap, context) => context.eventId) - - .it("should have right eventType", (_, context) => - expectEq(context.eventType, "google.testing.testMatrix.complete") - ) - - .it("should be in state 'INVALID'", (matrix) => expectEq(matrix.state, "INVALID")) - - .run(matrix?.clientInfo?.details?.testId, matrix, context); - }); diff --git a/integration_test/functions/src/v1/testLab-utils.ts b/integration_test/functions/src/v1/testLab-utils.ts deleted file mode 100644 index 7ba32e112..000000000 --- a/integration_test/functions/src/v1/testLab-utils.ts +++ /dev/null @@ -1,112 +0,0 @@ -import * as admin from "firebase-admin"; -import fetch from "node-fetch"; - -interface AndroidDevice { - androidModelId: string; - androidVersionId: string; - locale: string; - orientation: string; -} - -const TESTING_API_SERVICE_NAME = "testing.googleapis.com"; - -/** - * Creates a new TestMatrix in Test Lab which is expected to be rejected as - * invalid. - * - * @param projectId Project for which the test run will be created - * @param testId Test id which will be encoded in client info details - * @param accessToken accessToken to attach to requested for authentication - */ -export async function startTestRun(projectId: string, testId: string, accessToken: string) { - const device = await fetchDefaultDevice(accessToken); - return await createTestMatrix(accessToken, projectId, testId, device); -} - -async function fetchDefaultDevice(accessToken: string): Promise { - const resp = await fetch( - `https://${TESTING_API_SERVICE_NAME}/v1/testEnvironmentCatalog/ANDROID`, - { - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - } - ); - if (!resp.ok) { - throw new Error(resp.statusText); - } - const data = await resp.json(); - const models = data?.androidDeviceCatalog?.models || []; - const defaultModels = models.filter( - (m) => - m.tags !== undefined && - m.tags.indexOf("default") > -1 && - m.supportedVersionIds !== undefined && - m.supportedVersionIds.length > 0 - ); - - if (defaultModels.length === 0) { - throw new Error("No default device found"); - } - - const model = defaultModels[0]; - const versions = model.supportedVersionIds; - - return { - androidModelId: model.id, - androidVersionId: versions[versions.length - 1], - locale: "en", - orientation: "portrait", - } as AndroidDevice; -} - -async function createTestMatrix( - accessToken: string, - projectId: string, - testId: string, - device: AndroidDevice -): Promise { - const body = { - projectId, - testSpecification: { - androidRoboTest: { - appApk: { - gcsPath: "gs://path/to/non-existing-app.apk", - }, - }, - }, - environmentMatrix: { - androidDeviceList: { - androidDevices: [device], - }, - }, - resultStorage: { - googleCloudStorage: { - gcsPath: "gs://" + admin.storage().bucket().name, - }, - }, - clientInfo: { - name: "CloudFunctionsSDKIntegrationTest", - clientInfoDetails: { - key: "testId", - value: testId, - }, - }, - }; - const resp = await fetch( - `https://${TESTING_API_SERVICE_NAME}/v1/projects/${projectId}/testMatrices`, - { - method: "POST", - headers: { - Authorization: "Bearer " + accessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - } - ); - if (!resp.ok) { - throw new Error(resp.statusText); - } - return; -} diff --git a/integration_test/functions/src/v2/database.v2.test.ts b/integration_test/functions/src/v2/database.v2.test.ts new file mode 100644 index 000000000..7bc1b3f63 --- /dev/null +++ b/integration_test/functions/src/v2/database.v2.test.ts @@ -0,0 +1,141 @@ +import { describe, it, beforeAll, expect } from "vitest"; +import { RUN_ID, waitForEvent } from "../utils"; +import { database } from "../firebase.server"; +import { expectCloudEvent, expectDatabaseEvent, expectDataSnapshot } from "../assertions/database"; + +describe("database.v2", () => { + describe("onValueCreated", () => { + let data: any; + let refPath: string; + + beforeAll(async () => { + data = await waitForEvent("onValueCreated", async () => { + const testData = { + foo: "bar", + number: 42, + nested: { + key: "value", + }, + }; + refPath = `integration_test/${RUN_ID}/onValueCreated/${Date.now()}`; + await database.ref(refPath).set(testData); + }); + }, 60_000); + + it("should be a CloudEvent", () => { + expectCloudEvent(data); + }); + + it("should be a DatabaseEvent", () => { + expectDatabaseEvent(data, "onValueCreated", refPath); + }); + + it("should have a DataSnapshot", () => { + expectDataSnapshot(data.eventData, refPath); + }); + + it("should have the correct data", () => { + const value = data.eventData.json; + expect(value.foo).toBe("bar"); + expect(value.number).toBe(42); + expect(value.nested).toBeDefined(); + expect(value.nested.key).toBe("value"); + }); + }); + + describe("onValueUpdated", () => { + let data: any; + let refPath: string; + + beforeAll(async () => { + data = await waitForEvent("onValueUpdated", async () => { + const initialData = { + foo: "bar", + number: 42, + nested: { + key: "value", + }, + }; + refPath = `integration_test/${RUN_ID}/onValueUpdated/${Date.now()}`; + await database.ref(refPath).set(initialData); + await new Promise((resolve) => setTimeout(resolve, 3000)); + await database.ref(refPath).update({ + foo: "baz", + number: 100, + }); + }); + }, 60_000); + + it("should be a CloudEvent", () => { + expectCloudEvent(data); + }); + + it("should be a DatabaseEvent", () => { + expectDatabaseEvent(data, "onValueUpdated", refPath); + }); + + it("should be a Change event with snapshots", () => { + const before = data.eventData.before; + const after = data.eventData.after; + expectDataSnapshot(before, refPath); + expectDataSnapshot(after, refPath); + }); + + it("before event should have the correct data", () => { + const value = data.eventData.before.json; + expect(value.foo).toBe("bar"); + expect(value.number).toBe(42); + expect(value.nested).toBeDefined(); + expect(value.nested.key).toBe("value"); + }); + + it("after event should have the correct data", () => { + const value = data.eventData.after.json; + expect(value.foo).toBe("baz"); + expect(value.number).toBe(100); + expect(value.nested).toBeDefined(); + expect(value.nested.key).toBe("value"); + }); + }); + + describe("onValueDeleted", () => { + let data: any; + let refPath: string; + + beforeAll(async () => { + data = await waitForEvent("onValueDeleted", async () => { + const testData = { + foo: "bar", + number: 42, + nested: { + key: "value", + }, + }; + refPath = `integration_test/${RUN_ID}/onValueDeleted/${Date.now()}`; + await database.ref(refPath).set(testData); + await new Promise((resolve) => setTimeout(resolve, 3000)); + await database.ref(refPath).remove(); + }); + }, 60_000); + + it("should be a CloudEvent", () => { + expectCloudEvent(data); + }); + + it("should be a DatabaseEvent", () => { + expectDatabaseEvent(data, "onValueDeleted", refPath); + }); + + it("should have a DataSnapshot", () => { + expectDataSnapshot(data.eventData, refPath); + }); + + it("should have the correct data", () => { + const value = data.eventData.json; + expect(value.foo).toBe("bar"); + expect(value.number).toBe(42); + expect(value.nested).toBeDefined(); + expect(value.nested.key).toBe("value"); + }); + }); +}); diff --git a/integration_test/functions/src/v2/database.v2.ts b/integration_test/functions/src/v2/database.v2.ts new file mode 100644 index 000000000..22cc917f7 --- /dev/null +++ b/integration_test/functions/src/v2/database.v2.ts @@ -0,0 +1,49 @@ +import { onValueCreated, onValueDeleted, onValueUpdated } from "firebase-functions/database"; +import { + serializeChangeEvent, + serializeDatabaseEvent, + serializeDataSnapshot, +} from "../serializers/database"; +import { sendEvent } from "../utils"; + +export const databaseOnValueCreated = onValueCreated( + { + ref: `integration_test/{runId}/onValueCreated/{timestamp}`, + }, + async (event) => { + await sendEvent( + "onValueCreated", + serializeDatabaseEvent(event, serializeDataSnapshot(event.data)) + ); + } +); + +export const databaseOnValueUpdated = onValueUpdated( + { + ref: `integration_test/{runId}/onValueUpdated/{timestamp}`, + }, + async (event) => { + await sendEvent( + "onValueUpdated", + serializeDatabaseEvent(event, serializeChangeEvent(event.data)) + ); + } +); + +export const databaseOnValueDeleted = onValueDeleted( + { + ref: `integration_test/{runId}/onValueDeleted/{timestamp}`, + }, + async (event) => { + await sendEvent( + "onValueDeleted", + serializeDatabaseEvent(event, serializeDataSnapshot(event.data)) + ); + } +); + +export const test = { + databaseOnValueCreated, + databaseOnValueUpdated, + databaseOnValueDeleted, +}; diff --git a/integration_test/functions/src/v2/eventarc.v2.test.ts b/integration_test/functions/src/v2/eventarc.v2.test.ts new file mode 100644 index 000000000..fdb8b6933 --- /dev/null +++ b/integration_test/functions/src/v2/eventarc.v2.test.ts @@ -0,0 +1,39 @@ +import { describe, it, beforeAll, expect } from "vitest"; +import { getEventarc } from "firebase-admin/eventarc"; +import { RUN_ID, waitForEvent } from "../utils"; +import { expectCloudEvent } from "../assertions"; + +const eventarc = getEventarc(); +const channel = eventarc.channel(); + +describe("eventarc.v2", () => { + describe("onCustomEventPublished", () => { + let data: any; + + beforeAll(async () => { + data = await waitForEvent("onCustomEventPublished", async () => { + await channel.publish({ + type: "vitest-test", + source: RUN_ID, + subject: "Foo", + data: { + foo: "bar", + }, + }); + }); + }, 60_000); + + it("should be a CloudEvent", () => { + expectCloudEvent(data); + }); + + it("should have the correct event type", () => { + expect(data.type).toBe("vitest-test"); + }); + + it("should have the correct data", () => { + const eventData = JSON.parse(data.eventData); + expect(eventData.foo).toBe("bar"); + }); + }); +}); diff --git a/integration_test/functions/src/v2/eventarc.v2.ts b/integration_test/functions/src/v2/eventarc.v2.ts new file mode 100644 index 000000000..297220dc0 --- /dev/null +++ b/integration_test/functions/src/v2/eventarc.v2.ts @@ -0,0 +1,19 @@ +import { onCustomEventPublished } from "firebase-functions/eventarc"; +import { serializeCloudEvent } from "../serializers"; +import { sendEvent } from "../utils"; + +export const eventarcOnCustomEventPublishedTrigger = onCustomEventPublished( + { + eventType: "vitest-test", + }, + async (event) => { + await sendEvent("onCustomEventPublished", { + ...serializeCloudEvent(event), + eventData: JSON.stringify(event.data), + }); + } +); + +export const test = { + eventarcOnCustomEventPublishedTrigger, +}; diff --git a/integration_test/functions/src/v2/firestore.v2.test.ts b/integration_test/functions/src/v2/firestore.v2.test.ts new file mode 100644 index 000000000..f657dbbcd --- /dev/null +++ b/integration_test/functions/src/v2/firestore.v2.test.ts @@ -0,0 +1,282 @@ +import { describe, it, beforeAll, expect } from "vitest"; +import { waitForEvent, RUN_ID } from "../utils"; +import { firestore } from "../firebase.server"; +import { GeoPoint } from "firebase-admin/firestore"; +import { + expectCloudEvent, + expectFirestoreAuthEvent, + expectFirestoreEvent, + expectGeoPoint, + expectQueryDocumentSnapshot, + expectTimestamp, +} from "../assertions/firestore"; + +describe("firestore.v2", () => { + describe("onDocumentCreated", () => { + let data: any; + let documentId: string; + + beforeAll(async () => { + data = await waitForEvent("onDocumentCreated", async () => { + await firestore + .collection(`integration_test/${RUN_ID}/onDocumentCreated`) + .add({ + foo: "bar", + timestamp: new Date(), + geopoint: new GeoPoint(10, 20), + }) + .then((doc) => { + documentId = doc.id; + }); + }); + }, 60_000); + + it("should be a CloudEvent", () => { + expectCloudEvent(data); + }); + + it("should be a FirestoreEvent", () => { + expectFirestoreEvent(data, "onDocumentCreated", documentId); + }); + + it("should be a QueryDocumentSnapshot", () => { + expectQueryDocumentSnapshot(data.eventData, "onDocumentCreated", documentId); + }); + + it("should have the correct data", () => { + const value = data.eventData.data; + expect(value.foo).toBe("bar"); + expectTimestamp(value.timestamp); + expectGeoPoint(value.geopoint); + }); + }); + + describe("onDocumentUpdated", () => { + let data: any; + let documentId: string; + + beforeAll(async () => { + data = await waitForEvent("onDocumentUpdated", async () => { + await firestore + .collection(`integration_test/${RUN_ID}/onDocumentUpdated`) + .add({ + foo: "bar", + timestamp: new Date(), + geopoint: new GeoPoint(10, 20), + }) + .then(async (doc) => { + await new Promise((resolve) => setTimeout(resolve, 3000)); + await doc.update({ + foo: "baz", + }); + return doc; + }) + .then((doc) => { + documentId = doc.id; + }); + }); + }, 60_000); + + it("should be a CloudEvent", () => { + expectCloudEvent(data); + }); + + it("should be a FirestoreEvent", () => { + expectFirestoreEvent(data, "onDocumentUpdated", documentId); + }); + + it("should be a Change event with snapshots", () => { + const before = data.eventData.before; + const after = data.eventData.after; + expectQueryDocumentSnapshot(before, "onDocumentUpdated", documentId); + expectQueryDocumentSnapshot(after, "onDocumentUpdated", documentId); + }); + + it("before event should have the correct data", () => { + const value = data.eventData.before.data; + expect(value.foo).toBe("bar"); + expectTimestamp(value.timestamp); + expectGeoPoint(value.geopoint); + }); + + it("after event should have the correct data", () => { + const value = data.eventData.after.data; + expect(value.foo).toBe("baz"); + expectTimestamp(value.timestamp); + expectGeoPoint(value.geopoint); + }); + }); + + describe("onDocumentDeleted", () => { + let data: any; + let documentId: string; + + beforeAll(async () => { + data = await waitForEvent("onDocumentDeleted", async () => { + const docRef = await firestore + .collection(`integration_test/${RUN_ID}/onDocumentDeleted`) + .add({ + foo: "bar", + timestamp: new Date(), + geopoint: new GeoPoint(10, 20), + }); + documentId = docRef.id; + await new Promise((resolve) => setTimeout(resolve, 3000)); + await docRef.delete(); + }); + }, 60_000); + + it("should be a CloudEvent", () => { + expectCloudEvent(data); + }); + + it("should be a FirestoreEvent", () => { + expectFirestoreEvent(data, "onDocumentDeleted", documentId); + }); + + it("should be a QueryDocumentSnapshot", () => { + expectQueryDocumentSnapshot(data.eventData, "onDocumentDeleted", documentId); + }); + + it("should have the correct data", () => { + const value = data.eventData.data; + expect(value.foo).toBe("bar"); + expectTimestamp(value.timestamp); + expectGeoPoint(value.geopoint); + }); + }); + + describe("onDocumentCreatedWithAuthContext", () => { + let data: any; + let documentId: string; + + beforeAll(async () => { + data = await waitForEvent("onDocumentCreatedWithAuthContext", async () => { + await firestore + .collection(`integration_test/${RUN_ID}/onDocumentCreatedWithAuthContext`) + .add({ + foo: "bar", + timestamp: new Date(), + geopoint: new GeoPoint(10, 20), + }) + .then((doc) => { + documentId = doc.id; + }); + }); + }, 60_000); + + it("should be a CloudEvent", () => { + expectCloudEvent(data); + }); + + it("should be a FirestoreAuthEvent", () => { + expectFirestoreAuthEvent(data, "onDocumentCreatedWithAuthContext", documentId); + }); + + it("should be a QueryDocumentSnapshot", () => { + expectQueryDocumentSnapshot(data.eventData, "onDocumentCreatedWithAuthContext", documentId); + }); + + it("should have the correct data", () => { + const value = data.eventData.data; + expect(value.foo).toBe("bar"); + expectTimestamp(value.timestamp); + expectGeoPoint(value.geopoint); + }); + }); + + describe("onDocumentUpdatedWithAuthContext", () => { + let data: any; + let documentId: string; + + beforeAll(async () => { + data = await waitForEvent("onDocumentUpdatedWithAuthContext", async () => { + await firestore + .collection(`integration_test/${RUN_ID}/onDocumentUpdatedWithAuthContext`) + .add({ + foo: "bar", + timestamp: new Date(), + geopoint: new GeoPoint(10, 20), + }) + .then(async (doc) => { + await new Promise((resolve) => setTimeout(resolve, 3000)); + await doc.update({ + foo: "baz", + }); + return doc; + }) + .then((doc) => { + documentId = doc.id; + }); + }); + }, 60_000); + + it("should be a CloudEvent", () => { + expectCloudEvent(data); + }); + + it("should be a FirestoreAuthEvent", () => { + expectFirestoreAuthEvent(data, "onDocumentUpdatedWithAuthContext", documentId); + }); + + it("should be a Change event with snapshots", () => { + const before = data.eventData.before; + const after = data.eventData.after; + expectQueryDocumentSnapshot(before, "onDocumentUpdatedWithAuthContext", documentId); + expectQueryDocumentSnapshot(after, "onDocumentUpdatedWithAuthContext", documentId); + }); + + it("before event should have the correct data", () => { + const value = data.eventData.before.data; + expect(value.foo).toBe("bar"); + expectTimestamp(value.timestamp); + expectGeoPoint(value.geopoint); + }); + + it("after event should have the correct data", () => { + const value = data.eventData.after.data; + expect(value.foo).toBe("baz"); + expectTimestamp(value.timestamp); + expectGeoPoint(value.geopoint); + }); + }); + + describe("onDocumentDeletedWithAuthContext", () => { + let data: any; + let documentId: string; + + beforeAll(async () => { + data = await waitForEvent("onDocumentDeletedWithAuthContext", async () => { + const docRef = await firestore + .collection(`integration_test/${RUN_ID}/onDocumentDeletedWithAuthContext`) + .add({ + foo: "bar", + timestamp: new Date(), + geopoint: new GeoPoint(10, 20), + }); + documentId = docRef.id; + await new Promise((resolve) => setTimeout(resolve, 3000)); + await docRef.delete(); + }); + }, 60_000); + + it("should be a CloudEvent", () => { + expectCloudEvent(data); + }); + + it("should be a FirestoreAuthEvent", () => { + expectFirestoreAuthEvent(data, "onDocumentDeletedWithAuthContext", documentId); + }); + + it("should be a QueryDocumentSnapshot", () => { + expectQueryDocumentSnapshot(data.eventData, "onDocumentDeletedWithAuthContext", documentId); + }); + + it("should have the correct data", () => { + const value = data.eventData.data; + expect(value.foo).toBe("bar"); + expectTimestamp(value.timestamp); + expectGeoPoint(value.geopoint); + }); + }); +}); diff --git a/integration_test/functions/src/v2/firestore.v2.ts b/integration_test/functions/src/v2/firestore.v2.ts new file mode 100644 index 000000000..2c68ad15a --- /dev/null +++ b/integration_test/functions/src/v2/firestore.v2.ts @@ -0,0 +1,95 @@ +import { + onDocumentCreated, + onDocumentCreatedWithAuthContext, + onDocumentDeleted, + onDocumentDeletedWithAuthContext, + onDocumentUpdated, + onDocumentUpdatedWithAuthContext, +} from "firebase-functions/v2/firestore"; +import { + serializeChangeEvent, + serializeFirestoreAuthEvent, + serializeFirestoreEvent, + serializeQueryDocumentSnapshot, +} from "../serializers/firestore"; +import { sendEvent } from "../utils"; + +export const firestoreOnDocumentCreatedTrigger = onDocumentCreated( + `integration_test/{runId}/onDocumentCreated/{documentId}`, + async (event) => { + await sendEvent( + "onDocumentCreated", + serializeFirestoreEvent(event, serializeQueryDocumentSnapshot(event.data!)) + ); + } +); + +export const firestoreOnDocumentUpdatedTrigger = onDocumentUpdated( + `integration_test/{runId}/onDocumentUpdated/{documentId}`, + async (event) => { + await sendEvent( + "onDocumentUpdated", + serializeFirestoreEvent(event, serializeChangeEvent(event.data!)) + ); + } +); + +export const firestoreOnDocumentDeletedTrigger = onDocumentDeleted( + `integration_test/{runId}/onDocumentDeleted/{documentId}`, + async (event) => { + await sendEvent( + "onDocumentDeleted", + serializeFirestoreEvent(event, serializeQueryDocumentSnapshot(event.data!)) + ); + } +); + +// TODO: Tests need to handle multiple changes to the same document +// export const firestoreOnDocumentWrittenTrigger = onDocumentWritten( +// `integration_test/{runId}/onDocumentWritten/{documentId}`, +// async (event) => { +// await sendEvent( +// "onDocumentWritten", +// serializeFirestoreEvent(event, serializeQueryDocumentSnapshot(event.data!)) +// ); +// } +// ); + +export const firestoreOnDocumentCreatedWithAuthContextTrigger = onDocumentCreatedWithAuthContext( + `integration_test/{runId}/onDocumentCreatedWithAuthContext/{documentId}`, + async (event) => { + await sendEvent( + "onDocumentCreatedWithAuthContext", + serializeFirestoreAuthEvent(event, serializeQueryDocumentSnapshot(event.data!)) + ); + } +); + +export const firestoreOnDocumentUpdatedWithAuthContextTrigger = onDocumentUpdatedWithAuthContext( + `integration_test/{runId}/onDocumentUpdatedWithAuthContext/{documentId}`, + async (event) => { + await sendEvent( + "onDocumentUpdatedWithAuthContext", + serializeFirestoreAuthEvent(event, serializeChangeEvent(event.data!)) + ); + } +); + +export const firestoreOnDocumentDeletedWithAuthContextTrigger = onDocumentDeletedWithAuthContext( + `integration_test/{runId}/onDocumentDeletedWithAuthContext/{documentId}`, + async (event) => { + await sendEvent( + "onDocumentDeletedWithAuthContext", + serializeFirestoreAuthEvent(event, serializeQueryDocumentSnapshot(event.data!)) + ); + } +); + +export const test = { + firestoreOnDocumentCreatedTrigger, + firestoreOnDocumentUpdatedTrigger, + firestoreOnDocumentDeletedTrigger, + firestoreOnDocumentCreatedWithAuthContextTrigger, + firestoreOnDocumentUpdatedWithAuthContextTrigger, + firestoreOnDocumentDeletedWithAuthContextTrigger, +}; diff --git a/integration_test/functions/src/v2/https-tests.ts b/integration_test/functions/src/v2/https-tests.ts deleted file mode 100644 index b787ac602..000000000 --- a/integration_test/functions/src/v2/https-tests.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { onCall } from "firebase-functions/v2/https"; -import { expectEq, TestSuite } from "../testing"; - -export const callabletests = onCall({ invoker: "private" }, (req) => { - return new TestSuite("v2 https onCall") - .it("should have the correct data", (data: any) => expectEq(data?.foo, "bar")) - .run(req.data.testId, req.data); -}); diff --git a/integration_test/functions/src/v2/https.v2.test.ts b/integration_test/functions/src/v2/https.v2.test.ts new file mode 100644 index 000000000..8dc63b0d9 --- /dev/null +++ b/integration_test/functions/src/v2/https.v2.test.ts @@ -0,0 +1,79 @@ +import { httpsCallable } from "firebase/functions"; +import { fetch } from "undici"; +import { beforeAll, describe, expect, it } from "vitest"; +import { functions } from "../firebase.client"; +import { waitForEvent } from "../utils"; + +describe("https.v2", () => { + describe("httpsOnCallTrigger", () => { + let data: any; + let callData: any; + const streamData: any[] = []; + + beforeAll(async () => { + data = await waitForEvent("httpsOnCall", async () => { + const callable = httpsCallable(functions, "test-httpsOnCallTrigger"); + + const { stream, data: result } = await callable.stream({ + foo: "bar", + }); + + for await (const chunk of stream) { + streamData.push(chunk); + } + + // Await the final result of the callable + callData = await result; + }); + }, 60_000); + + it("should accept the correct data", () => { + expect(data.acceptsStreaming).toBe(true); + expect(data.data).toEqual({ foo: "bar" }); + }); + + it("should return the correct data", () => { + expect(callData).toBe("onCall"); + }); + + it("should stream the correct data", () => { + expect(streamData).toEqual(["onCallStreamed"]); + }); + }); + + describe("httpsOnRequestTrigger", () => { + let data: any; + let status: number; + let body: any; + + beforeAll(async () => { + data = await waitForEvent("httpsOnRequest", async () => { + const response = await fetch( + "https://us-central1-cf3-integration-tests-v2-qa.cloudfunctions.net/test-httpsOnRequestTrigger", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ foo: "bar" }), + } + ); + + status = response.status; + body = await response.text(); + }); + }, 60_000); + + it("should accept the correct data", () => { + expect(data).toEqual({ foo: "bar" }); + }); + + it("should return the correct status", () => { + expect(status).toBe(201); + }); + + it("should return the correct body", () => { + expect(body).toBe("onRequest"); + }); + }); +}); diff --git a/integration_test/functions/src/v2/https.v2.ts b/integration_test/functions/src/v2/https.v2.ts new file mode 100644 index 000000000..cf24a1711 --- /dev/null +++ b/integration_test/functions/src/v2/https.v2.ts @@ -0,0 +1,36 @@ +import { onCall, onRequest } from "firebase-functions/v2/https"; +import { sendEvent } from "../utils"; + +export const httpsOnCallTrigger = onCall( + { + invoker: "public", + }, + async (request, response) => { + await sendEvent("httpsOnCall", { + acceptsStreaming: request.acceptsStreaming, + data: request.data, + }); + + if (request.acceptsStreaming) { + await response?.sendChunk("onCallStreamed"); + } + + return "onCall"; + } +); + +export const httpsOnRequestTrigger = onRequest( + { + invoker: "public", + }, + async (req, res) => { + await sendEvent("httpsOnRequest", req.body); + res.status(201).send("onRequest"); + return; + } +); + +export const test = { + httpsOnCallTrigger, + httpsOnRequestTrigger, +}; diff --git a/integration_test/functions/src/v2/identity.v2.test.ts b/integration_test/functions/src/v2/identity.v2.test.ts new file mode 100644 index 000000000..8753da112 --- /dev/null +++ b/integration_test/functions/src/v2/identity.v2.test.ts @@ -0,0 +1,88 @@ +import { createUserWithEmailAndPassword, signInWithEmailAndPassword, signOut } from "firebase/auth"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { expectAuthBlockingEvent } from "../assertions/identity"; +import { auth as authClient } from "../firebase.client"; +import { auth } from "../firebase.server"; +import { waitForEvent } from "../utils"; + +describe("identity.v2", () => { + describe.skip("beforeUserCreated", () => { + let data: any; + let userId: string; + let email: string; + + beforeAll(async () => { + data = await waitForEvent("beforeUserCreated", async () => { + email = `test-${Date.now()}@example.com`; + const password = "testPassword123!"; + userId = await createUserWithEmailAndPassword(authClient, email, password).then( + (credential) => credential.user.uid + ); + }); + }, 60_000); + + afterAll(async () => { + // Clean up: delete the test user + if (userId) { + try { + await auth.deleteUser(userId); + } catch (error) { + console.warn("Error deleting user:", error.message); + // Ignore errors if user was already deleted + } + } + + await signOut(authClient); + }); + + it("should be an AuthBlockingEvent", () => { + expectAuthBlockingEvent(data, userId); + }); + + it("should have the correct event type", () => { + expect(data.eventType).toBe("providers/cloud.auth/eventTypes/user.beforeCreate:password"); + }); + }); + + describe.skip("beforeUserSignedIn", () => { + let data: any; + let userId: string; + let email: string; + let password: string; + + beforeAll(async () => { + // First create a user (required before sign-in) + email = `signin-${Date.now()}@example.com`; + password = "testPassword123!"; + userId = await createUserWithEmailAndPassword(authClient, email, password).then( + (credential) => credential.user.uid + ); + + data = await waitForEvent("beforeUserSignedIn", async () => { + await signInWithEmailAndPassword(authClient, email, password); + }); + }, 60_000); + + afterAll(async () => { + // Clean up: delete the test user + if (userId) { + try { + await auth.deleteUser(userId); + } catch (error) { + console.warn("Error deleting user:", error.message); + // Ignore errors if user was already deleted + } + } + + await signOut(authClient); + }); + + it("should be an AuthBlockingEvent", () => { + expectAuthBlockingEvent(data, userId); + }); + + it("should have the correct event type", () => { + expect(data.eventType).toBe("providers/cloud.auth/eventTypes/user.beforeSignIn:password"); + }); + }); +}); diff --git a/integration_test/functions/src/v2/identity.v2.ts b/integration_test/functions/src/v2/identity.v2.ts new file mode 100644 index 000000000..0cb4e3707 --- /dev/null +++ b/integration_test/functions/src/v2/identity.v2.ts @@ -0,0 +1,16 @@ +import { beforeUserCreated, beforeUserSignedIn } from "firebase-functions/v2/identity"; +import { serializeAuthBlockingEvent } from "../serializers/identity"; +import { sendEvent } from "../utils"; + +export const authBeforeUserCreatedTrigger = beforeUserCreated(async (event) => { + await sendEvent("beforeUserCreated", serializeAuthBlockingEvent(event)); +}); + +export const authBeforeUserSignedInTrigger = beforeUserSignedIn(async (event) => { + await sendEvent("beforeUserSignedIn", serializeAuthBlockingEvent(event)); +}); + +export const test = { + authBeforeUserCreatedTrigger, + authBeforeUserSignedInTrigger, +}; diff --git a/integration_test/functions/src/v2/index.ts b/integration_test/functions/src/v2/index.ts deleted file mode 100644 index 38cde5f92..000000000 --- a/integration_test/functions/src/v2/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { setGlobalOptions } from "firebase-functions/v2"; -import { REGION } from "../region"; -setGlobalOptions({ region: REGION }); - -// TODO: Temporarily disable - doesn't work unless running on projects w/ permission to create public functions. -// export * from './https-tests'; -export * from "./scheduled-tests"; diff --git a/integration_test/functions/src/v2/pubsub.v2.test.ts b/integration_test/functions/src/v2/pubsub.v2.test.ts new file mode 100644 index 000000000..2878eb206 --- /dev/null +++ b/integration_test/functions/src/v2/pubsub.v2.test.ts @@ -0,0 +1,42 @@ +import { describe, it, beforeAll, expect, assertType } from "vitest"; +import { PubSub } from "@google-cloud/pubsub"; +import { waitForEvent } from "../utils"; +import { expectCloudEvent } from "../assertions/identity"; +import { config } from "../config"; + +describe("pubsub.v2", () => { + describe("onMessagePublished", () => { + let data: any; + + beforeAll(async () => { + data = await waitForEvent("onMessagePublished", async () => { + const pubsub = new PubSub({ + projectId: config.projectId, + }); + + const [topic] = await pubsub.topic("vitest_message").get({ autoCreate: true }); + + await topic.publishMessage({ + data: Buffer.from("Hello, world!"), + }); + }); + }, 60_000); + + it("should be a CloudEvent", () => { + expectCloudEvent(data); + }); + + it("should be a valid Message", () => { + expect(data.message).toBeDefined(); + expect(data.message.messageId).toBeDefined(); + assertType(data.message.messageId); + expect(data.message.messageId.length).toBeGreaterThan(0); + expect(data.message.publishTime).toBeDefined(); + expect(Date.parse(data.message.publishTime)).toBeGreaterThan(0); + expect(data.message.data).toBe("Hello, world!"); + expect(data.message.attributes).toBeDefined(); // Empty object + expect(data.message.orderingKey).toBeDefined(); + assertType(data.message.orderingKey); + }); + }); +}); diff --git a/integration_test/functions/src/v2/pubsub.v2.ts b/integration_test/functions/src/v2/pubsub.v2.ts new file mode 100644 index 000000000..57b54de20 --- /dev/null +++ b/integration_test/functions/src/v2/pubsub.v2.ts @@ -0,0 +1,25 @@ +import { onMessagePublished } from "firebase-functions/v2/pubsub"; +import { serializeCloudEvent } from "../serializers"; +import { sendEvent } from "../utils"; + +export const pubsubOnMessagePublishedTrigger = onMessagePublished( + { + topic: "vitest_message", + }, + async (event) => { + await sendEvent("onMessagePublished", { + ...serializeCloudEvent(event), + message: { + messageId: event.data.message.messageId, + publishTime: event.data.message.publishTime, + data: Buffer.from(event.data.message.data, "base64").toString("utf-8"), + attributes: event.data.message.attributes, + orderingKey: event.data.message.orderingKey, + }, + }); + } +); + +export const test = { + pubsubOnMessagePublishedTrigger, +}; diff --git a/integration_test/functions/src/v2/remoteConfig.v2.ts b/integration_test/functions/src/v2/remoteConfig.v2.ts new file mode 100644 index 000000000..85cdbbbcd --- /dev/null +++ b/integration_test/functions/src/v2/remoteConfig.v2.ts @@ -0,0 +1,14 @@ +import { onConfigUpdated } from "firebase-functions/v2/remoteConfig"; +import { serializeCloudEvent } from "../serializers"; +import { sendEvent } from "../utils"; + +export const remoteConfigOnConfigUpdatedTests = onConfigUpdated(async (event) => { + await sendEvent("onConfigUpdated", { + ...serializeCloudEvent(event), + update: event.data, + }); +}); + +export const test = { + remoteConfigOnConfigUpdatedTests, +}; diff --git a/integration_test/functions/src/v2/scheduled-tests.ts b/integration_test/functions/src/v2/scheduled-tests.ts deleted file mode 100644 index cc13bed62..000000000 --- a/integration_test/functions/src/v2/scheduled-tests.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as admin from "firebase-admin"; -import { onSchedule } from "firebase-functions/v2/scheduler"; -import { REGION } from "../region"; -import { success, TestSuite } from "../testing"; - -export const schedule: any = onSchedule( - { - schedule: "every 10 hours", - region: REGION, - }, - async () => { - const db = admin.database(); - const snap = await db.ref("testRuns").orderByChild("timestamp").limitToLast(1).once("value"); - const testId = Object.keys(snap.val())[0]; - return new TestSuite("scheduler scheduleOnRun") - .it("should trigger when the scheduler fires", () => success()) - .run(testId, null); - } -); diff --git a/integration_test/functions/src/v2/scheduler.v2.test.ts b/integration_test/functions/src/v2/scheduler.v2.test.ts new file mode 100644 index 000000000..42f8ef7e0 --- /dev/null +++ b/integration_test/functions/src/v2/scheduler.v2.test.ts @@ -0,0 +1,32 @@ +import { describe, it, beforeAll, expect } from "vitest"; +import { waitForEvent } from "../utils"; +import Scheduler from "@google-cloud/scheduler"; +import { config } from "../config"; + +const region = "us-central1"; +// See https://firebase.google.com/docs/functions/schedule-functions#deploy_a_scheduled_function +const scheduleName = `firebase-schedule-test-schedulerOnScheduleTrigger-${region}`; +const jobName = `projects/${config.projectId}/locations/${region}/jobs/${scheduleName}`; + +describe("scheduler.v2", () => { + describe("onSchedule", () => { + let data: any; + + beforeAll(async () => { + data = await waitForEvent("onSchedule", async () => { + const client = new Scheduler.v1beta1.CloudSchedulerClient({ + projectId: config.projectId, + }); + + await client.runJob({ + name: jobName, + }); + }); + }, 60_000); + + it("should have the correct data", () => { + expect(data.jobName).toBe(scheduleName); + expect(Date.parse(data.scheduleTime)).toBeGreaterThan(0); + }); + }); +}); diff --git a/integration_test/functions/src/v2/scheduler.v2.ts b/integration_test/functions/src/v2/scheduler.v2.ts new file mode 100644 index 000000000..c97fc8e06 --- /dev/null +++ b/integration_test/functions/src/v2/scheduler.v2.ts @@ -0,0 +1,10 @@ +import { onSchedule } from "firebase-functions/v2/scheduler"; +import { sendEvent } from "../utils"; + +export const schedulerOnScheduleTrigger = onSchedule("every day 00:00", async (event) => { + await sendEvent("onSchedule", event); +}); + +export const test = { + schedulerOnScheduleTrigger, +}; diff --git a/integration_test/functions/src/v2/storage.v2.ts b/integration_test/functions/src/v2/storage.v2.ts new file mode 100644 index 000000000..08c4fb6b5 --- /dev/null +++ b/integration_test/functions/src/v2/storage.v2.ts @@ -0,0 +1,25 @@ +import { + onObjectDeleted, + onObjectFinalized, + onObjectMetadataUpdated, +} from "firebase-functions/v2/storage"; +import { serializeStorageEvent } from "../serializers/storage"; +import { sendEvent } from "../utils"; + +export const storageOnObjectDeletedTrigger = onObjectDeleted(async (event) => { + await sendEvent("onObjectDeleted", serializeStorageEvent(event)); +}); + +export const storageOnObjectFinalizedTrigger = onObjectFinalized(async (event) => { + await sendEvent("onObjectFinalized", serializeStorageEvent(event)); +}); + +export const storageOnObjectMetadataUpdatedTrigger = onObjectMetadataUpdated(async (event) => { + await sendEvent("onObjectMetadataUpdated", serializeStorageEvent(event)); +}); + +export const test = { + storageOnObjectDeletedTrigger, + storageOnObjectFinalizedTrigger, + storageOnObjectMetadataUpdatedTrigger, +}; diff --git a/integration_test/functions/src/v2/tasks.v2.test.ts b/integration_test/functions/src/v2/tasks.v2.test.ts new file mode 100644 index 000000000..b569caae4 --- /dev/null +++ b/integration_test/functions/src/v2/tasks.v2.test.ts @@ -0,0 +1,60 @@ +import { describe, it, beforeAll, expect, assertType } from "vitest"; +import { CloudTasksClient } from "@google-cloud/tasks"; +import { RUN_ID, waitForEvent } from "../utils"; +import { getFunctionUrl } from "../firebase.server"; +import { config } from "../config"; + +const QUEUE_NAME = "test-tasksOnTaskDispatchedTrigger"; + +describe("tasks.v2", () => { + describe("onTaskDispatched", () => { + let data: any; + + beforeAll(async () => { + data = await waitForEvent("onTaskDispatched", async () => { + const client = new CloudTasksClient({ + projectId: config.projectId, + }); + + const serviceAccountEmail = `${config.projectId}@appspot.gserviceaccount.com`; + + await client.createTask({ + parent: client.queuePath(config.projectId, "us-central1", QUEUE_NAME), + task: { + httpRequest: { + httpMethod: "POST", + url: await getFunctionUrl(QUEUE_NAME), + headers: { + "Content-Type": "application/json", + }, + oidcToken: { + serviceAccountEmail, + }, + body: Buffer.from( + JSON.stringify({ + data: { + id: RUN_ID, + }, + }) + ).toString("base64"), + }, + }, + }); + }); + }, 60_000); + + it("should have the correct data", () => { + expect(data.data.id).toBe(RUN_ID); + expect(data.executionCount).toBe(0); + expect(data.id).toBeDefined(); + assertType(data.id); + expect(data.id.length).toBeGreaterThan(0); + expect(data.queueName).toBe(QUEUE_NAME); + expect(data.retryCount).toBe(0); + + // TODO(ehesp): This should be a valid datetime string, but it comes through as + // a precision unix timestamp - looks like a bug to be fixed. + expect(data.scheduledTime).toBeDefined(); + }); + }); +}); diff --git a/integration_test/functions/src/v2/tasks.v2.ts b/integration_test/functions/src/v2/tasks.v2.ts new file mode 100644 index 000000000..fbe042231 --- /dev/null +++ b/integration_test/functions/src/v2/tasks.v2.ts @@ -0,0 +1,27 @@ +import { onTaskDispatched } from "firebase-functions/v2/tasks"; +import { sendEvent } from "../utils"; + +export const tasksOnTaskDispatchedTrigger = onTaskDispatched( + { + retryConfig: { + maxAttempts: 0, + }, + }, + async (event) => { + await sendEvent("onTaskDispatched", { + queueName: event.queueName, + id: event.id, + retryCount: event.retryCount, + executionCount: event.executionCount, + scheduledTime: event.scheduledTime, + previousResponse: event.previousResponse, + retryReason: event.retryReason, + // headers: event.headers, // Contains some sensitive information so exclude for now + data: event.data, + }); + } +); + +export const test = { + tasksOnTaskDispatchedTrigger, +}; diff --git a/integration_test/functions/tsconfig.json b/integration_test/functions/tsconfig.json index 77fb279d5..c5a629340 100644 --- a/integration_test/functions/tsconfig.json +++ b/integration_test/functions/tsconfig.json @@ -1,12 +1,20 @@ { "compilerOptions": { - "lib": ["es6", "dom"], - "module": "commonjs", - "target": "es2020", - "noImplicitAny": false, + "module": "NodeNext", + "esModuleInterop": true, + "moduleResolution": "nodenext", + "noImplicitReturns": true, + "noUnusedLocals": true, "outDir": "lib", - "declaration": true, - "typeRoots": ["node_modules/@types"] + "sourceMap": true, + "strict": true, + "target": "es2017" }, - "files": ["src/index.ts"] + "compileOnSave": true, + "include": [ + "src" + ], + "exclude": [ + "**/*.test.ts" + ] } diff --git a/integration_test/package-lock.json b/integration_test/package-lock.json new file mode 100644 index 000000000..21ea9a650 --- /dev/null +++ b/integration_test/package-lock.json @@ -0,0 +1,1494 @@ +{ + "name": "integration_test", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "integration_test", + "devDependencies": { + "tsx": "^4.20.6", + "vitest": "^4.0.10" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", + "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", + "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", + "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", + "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", + "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", + "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", + "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", + "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", + "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", + "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", + "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", + "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", + "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", + "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", + "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", + "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", + "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", + "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", + "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", + "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", + "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", + "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.10.tgz", + "integrity": "sha512-3QkTX/lK39FBNwARCQRSQr0TP9+ywSdxSX+LgbJ2M1WmveXP72anTbnp2yl5fH+dU6SUmBzNMrDHs80G8G2DZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.10", + "@vitest/utils": "4.0.10", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.10.tgz", + "integrity": "sha512-e2OfdexYkjkg8Hh3L9NVEfbwGXq5IZbDovkf30qW2tOh7Rh9sVtmSr2ztEXOFbymNxS4qjzLXUQIvATvN4B+lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.10.tgz", + "integrity": "sha512-99EQbpa/zuDnvVjthwz5bH9o8iPefoQZ63WV8+bsRJZNw3qQSvSltfut8yu1Jc9mqOYi7pEbsKxYTi/rjaq6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.10.tgz", + "integrity": "sha512-EXU2iSkKvNwtlL8L8doCpkyclw0mc/t4t9SeOnfOFPyqLmQwuceMPA4zJBa6jw0MKsZYbw7kAn+gl7HxrlB8UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.10.tgz", + "integrity": "sha512-2N4X2ZZl7kZw0qeGdQ41H0KND96L3qX1RgwuCfy6oUsF2ISGD/HpSbmms+CkIOsQmg2kulwfhJ4CI0asnZlvkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.10.tgz", + "integrity": "sha512-AsY6sVS8OLb96GV5RoG8B6I35GAbNrC49AO+jNRF9YVGb/g9t+hzNm1H6kD0NDp8tt7VJLs6hb7YMkDXqu03iw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.10.tgz", + "integrity": "sha512-kOuqWnEwZNtQxMKg3WmPK1vmhZu9WcoX69iwWjVz+jvKTsF1emzsv3eoPcDr6ykA3qP2bsCQE7CwqfNtAVzsmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.10", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", + "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.2", + "@rollup/rollup-android-arm64": "4.53.2", + "@rollup/rollup-darwin-arm64": "4.53.2", + "@rollup/rollup-darwin-x64": "4.53.2", + "@rollup/rollup-freebsd-arm64": "4.53.2", + "@rollup/rollup-freebsd-x64": "4.53.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", + "@rollup/rollup-linux-arm-musleabihf": "4.53.2", + "@rollup/rollup-linux-arm64-gnu": "4.53.2", + "@rollup/rollup-linux-arm64-musl": "4.53.2", + "@rollup/rollup-linux-loong64-gnu": "4.53.2", + "@rollup/rollup-linux-ppc64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-musl": "4.53.2", + "@rollup/rollup-linux-s390x-gnu": "4.53.2", + "@rollup/rollup-linux-x64-gnu": "4.53.2", + "@rollup/rollup-linux-x64-musl": "4.53.2", + "@rollup/rollup-openharmony-arm64": "4.53.2", + "@rollup/rollup-win32-arm64-msvc": "4.53.2", + "@rollup/rollup-win32-ia32-msvc": "4.53.2", + "@rollup/rollup-win32-x64-gnu": "4.53.2", + "@rollup/rollup-win32-x64-msvc": "4.53.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/vite": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", + "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.10.tgz", + "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.10", + "@vitest/mocker": "4.0.10", + "@vitest/pretty-format": "4.0.10", + "@vitest/runner": "4.0.10", + "@vitest/snapshot": "4.0.10", + "@vitest/spy": "4.0.10", + "@vitest/utils": "4.0.10", + "debug": "^4.4.3", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.10", + "@vitest/browser-preview": "4.0.10", + "@vitest/browser-webdriverio": "4.0.10", + "@vitest/ui": "4.0.10", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/integration_test/package.json b/integration_test/package.json new file mode 100644 index 000000000..e015ff4d3 --- /dev/null +++ b/integration_test/package.json @@ -0,0 +1,11 @@ +{ + "name": "integration_test", + "private": true, + "scripts": { + "test": "tsx cli.ts" + }, + "devDependencies": { + "tsx": "^4.20.6", + "vitest": "^4.0.10" + } +} diff --git a/integration_test/package.json.template b/integration_test/package.json.template deleted file mode 100644 index 42cdf121c..000000000 --- a/integration_test/package.json.template +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "functions", - "description": "Integration test for the Firebase SDK for Google Cloud Functions", - "scripts": { - "build": "./node_modules/.bin/tsc" - }, - "dependencies": { - "@google-cloud/pubsub": "^2.10.0", - "firebase-admin": "__FIREBASE_ADMIN__", - "firebase-functions": "__SDK_TARBALL__", - "node-fetch": "^2.6.7" - }, - "main": "lib/index.js", - "devDependencies": { - "@types/node-fetch": "^2.6.1", - "typescript": "^4.3.5" - }, - "engines": { - "node": "__NODE_VERSION__" - }, - "private": true -} diff --git a/integration_test/run_tests.sh b/integration_test/run_tests.sh deleted file mode 100755 index 681d2dc1e..000000000 --- a/integration_test/run_tests.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env bash - -# Exit immediately if a command exits with a non-zero status. -set -e - -PROJECT_ID="${GCLOUD_PROJECT}" -TIMESTAMP=$(date +%s) - -if [[ "${PROJECT_ID}" == "" ]]; then - echo "process.env.GCLOUD_PROJECT cannot be empty" - exit 1 -fi - -# Directory where this script lives. -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - -function announce { - echo -e "\n\n##### $1" -} - -function build_sdk { - announce "Building SDK..." - cd "${DIR}/.." - rm -f firebase-functions-*.tgz - npm run build:pack - mv firebase-functions-*.tgz "integration_test/functions/firebase-functions-${TIMESTAMP}.tgz" -} - -# Creates a Package.json from package.json.template -# @param timestmap of the current SDK build -# @param Node version to test under -function create_package_json { - cd "${DIR}" - cp package.json.template functions/package.json - # we have to do the -e flag here so that it work both on linux and mac os, but that creates an extra - # backup file called package.json-e that we should clean up afterwards. - sed -i -e "s/__SDK_TARBALL__/firebase-functions-$1.tgz/g" functions/package.json - sed -i -e "s/__NODE_VERSION__/$2/g" functions/package.json - sed -i -e "s/__FIREBASE_ADMIN__/$3/g" functions/package.json - rm -f functions/package.json-e -} - -function install_deps { - announce "Installing dependencies..." - cd "${DIR}/functions" - rm -rf node_modules/firebase-functions - npm install -} - -function delete_all_functions { - announce "Deleting all functions in project..." - cd "${DIR}" - # Try to delete, if there are errors it is because the project is already empty, - # in that case do nothing. - firebase functions:delete integrationTests v1 v2 --force --project=$PROJECT_ID || : & - wait - announce "Project emptied." -} - -function deploy { - # Deploy functions, and security rules for database and Firestore. If the deploy fails, retry twice - for i in 1 2; do firebase deploy --project="${PROJECT_ID}" --only functions,database,firestore && break; done -} - -function run_tests { - announce "Running integration tests..." - - # Construct the URL for the test function. This may change in the future, - # causing this script to start failing, but currently we don't have a very - # reliable way of determining the URL dynamically. - TEST_DOMAIN="cloudfunctions.net" - if [[ "${FIREBASE_FUNCTIONS_TEST_REGION}" == "" ]]; then - FIREBASE_FUNCTIONS_TEST_REGION="us-central1" - fi - TEST_URL="https://${FIREBASE_FUNCTIONS_TEST_REGION}-${PROJECT_ID}.${TEST_DOMAIN}/integrationTests" - echo "${TEST_URL}" - - curl --fail -H "Authorization: Bearer $(gcloud auth print-identity-token)" "${TEST_URL}" -} - -function cleanup { - announce "Performing cleanup..." - delete_all_functions - rm "${DIR}/functions/firebase-functions-${TIMESTAMP}.tgz" - rm "${DIR}/functions/package.json" - rm -f "${DIR}/functions/firebase-debug.log" - rm -rf "${DIR}/functions/lib" - rm -rf "${DIR}/functions/node_modules" -} - -# Setup -build_sdk -delete_all_functions - -for version in 14 16; do - create_package_json $TIMESTAMP $version "^10.0.0" - install_deps - announce "Re-deploying the same functions to Node $version runtime ..." - deploy - run_tests -done - -# Cleanup -cleanup -announce "All tests pass!" diff --git a/package-lock.json b/package-lock.json index c98ec4498..afa27fd2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,8 +37,8 @@ "@typescript-eslint/eslint-plugin": "^8.46.2", "@typescript-eslint/parser": "^8.46.2", "api-extractor-model-me": "^0.1.1", - "chai": "^4.2.0", - "chai-as-promised": "^7.1.1", + "chai": "^4.5.0", + "chai-as-promised": "^7.1.2", "child-process-promise": "^2.2.1", "eslint": "^9.38.0", "eslint-config-google": "^0.14.0", @@ -51,7 +51,7 @@ "jsdom": "^16.2.1", "jsonwebtoken": "^9.0.0", "jwk-to-pem": "^2.0.5", - "mocha": "^10.2.0", + "mocha": "^10.8.2", "mock-require": "^3.0.3", "mz": "^2.7.0", "nock": "^13.2.9", diff --git a/package.json b/package.json index 486e34d21..34410679b 100644 --- a/package.json +++ b/package.json @@ -488,6 +488,7 @@ "build": "tsdown && tsc -p tsconfig.release.json", "build:pack": "rm -rf lib && npm install && npm run build && npm pack", "build:watch": "npm run build -- -w", + "pack-for-integration-tests": "echo 'Building firebase-functions SDK from source...' && npm ci && npm run build && npm pack && mv firebase-functions-*.tgz integration_test/firebase-functions-local.tgz && echo 'SDK built and packed successfully'", "format": "npm run format:ts && npm run format:other", "format:other": "npm run lint:other -- --write", "format:ts": "npm run lint:ts -- --fix --quiet", @@ -526,8 +527,8 @@ "@typescript-eslint/eslint-plugin": "^8.46.2", "@typescript-eslint/parser": "^8.46.2", "api-extractor-model-me": "^0.1.1", - "chai": "^4.2.0", - "chai-as-promised": "^7.1.1", + "chai": "^4.5.0", + "chai-as-promised": "^7.1.2", "child-process-promise": "^2.2.1", "eslint": "^9.38.0", "eslint-config-google": "^0.14.0", @@ -540,7 +541,7 @@ "jsdom": "^16.2.1", "jsonwebtoken": "^9.0.0", "jwk-to-pem": "^2.0.5", - "mocha": "^10.2.0", + "mocha": "^10.8.2", "mock-require": "^3.0.3", "mz": "^2.7.0", "nock": "^13.2.9", diff --git a/spec/common/config.spec.ts b/spec/common/config.spec.ts index 8dc9fe9da..8cbc5404b 100644 --- a/spec/common/config.spec.ts +++ b/spec/common/config.spec.ts @@ -55,17 +55,22 @@ describe("firebaseConfig()", () => { expect(firebaseConfig()).to.have.property("databaseURL", "foo@firebaseio.com"); }); - it("loads Firebase configs from FIREBASE_CONFIG env variable pointing to a file", () => { - const oldEnv = process.env; - (process as any).env = { - ...oldEnv, + it.skip("loads Firebase configs from FIREBASE_CONFIG env variable pointing to a file", () => { + const originalEnv = process.env; + const mockEnv = { + ...originalEnv, FIREBASE_CONFIG: ".firebaseconfig.json", }; + + // Use Object.assign to modify the existing env object + Object.assign(process.env, mockEnv); + try { readFileSync.returns(Buffer.from('{"databaseURL": "foo@firebaseio.com"}')); expect(firebaseConfig()).to.have.property("databaseURL", "foo@firebaseio.com"); } finally { - (process as any).env = oldEnv; + // Restore original environment + Object.assign(process.env, originalEnv); } }); }); diff --git a/spec/common/providers/https.spec.ts b/spec/common/providers/https.spec.ts index 9dc42b504..25112fb1d 100644 --- a/spec/common/providers/https.spec.ts +++ b/spec/common/providers/https.spec.ts @@ -1,8 +1,8 @@ import { expect } from "chai"; import { App, initializeApp } from "firebase-admin/app"; import * as appCheck from "firebase-admin/app-check"; +import nock from "nock"; import * as sinon from "sinon"; -import * as nock from "nock"; import { getApp, setApp } from "../../../src/common/app"; import * as debug from "../../../src/common/debug"; diff --git a/spec/common/providers/identity.spec.ts b/spec/common/providers/identity.spec.ts index 253a337b2..9c8143eb3 100644 --- a/spec/common/providers/identity.spec.ts +++ b/spec/common/providers/identity.spec.ts @@ -21,7 +21,7 @@ // SOFTWARE. import { expect } from "chai"; -import * as express from "express"; +import express from "express"; import * as identity from "../../../src/common/providers/identity"; const EVENT = "EVENT_TYPE"; diff --git a/spec/fixtures/http.ts b/spec/fixtures/http.ts index efda2a501..f9294b9a3 100644 --- a/spec/fixtures/http.ts +++ b/spec/fixtures/http.ts @@ -20,7 +20,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as nock from 'nock'; +import nock from "nock"; interface AccessToken { access_token: string; @@ -28,8 +28,8 @@ interface AccessToken { } export function mockCredentialFetch(tokenToReturn: AccessToken): nock.Scope { - return nock('http://metadata.google.internal') - .get('/computeMetadata/v1beta1/instance/service-accounts/default/token') + return nock("http://metadata.google.internal") + .get("/computeMetadata/v1beta1/instance/service-accounts/default/token") .reply(200, tokenToReturn); } @@ -37,28 +37,26 @@ export function mockRCVariableFetch( projectId: string, varName: string, data: any, - token: string = 'thetoken' + token: string = "thetoken" ): nock.Scope { - return nock('https://runtimeconfig.googleapis.com') + return nock("https://runtimeconfig.googleapis.com") .get(`/v1beta1/projects/${projectId}/configs/firebase/variables/${varName}`) - .matchHeader('Authorization', `Bearer ${token}`) + .matchHeader("Authorization", `Bearer ${token}`) .reply(200, { text: JSON.stringify(data) }); } export function mockMetaVariableWatch( projectId: string, data: any, - token: string = 'thetoken', + token: string = "thetoken", updateTime: string = new Date().toISOString() ): nock.Scope { - return nock('https://runtimeconfig.googleapis.com') - .post( - `/v1beta1/projects/${projectId}/configs/firebase/variables/meta:watch` - ) - .matchHeader('Authorization', `Bearer ${token}`) + return nock("https://runtimeconfig.googleapis.com") + .post(`/v1beta1/projects/${projectId}/configs/firebase/variables/meta:watch`) + .matchHeader("Authorization", `Bearer ${token}`) .reply(200, { updateTime, - state: 'UPDATED', + state: "UPDATED", text: JSON.stringify(data), }); } @@ -68,37 +66,33 @@ export function mockMetaVariableWatchTimeout( delay: number, token?: string ): nock.Scope { - let interceptor = nock('https://runtimeconfig.googleapis.com').post( + let interceptor = nock("https://runtimeconfig.googleapis.com").post( `/v1beta1/projects/${projectId}/configs/firebase/variables/meta:watch` ); if (interceptor) { - interceptor = interceptor.matchHeader('Authorization', `Bearer ${token}`); + interceptor = interceptor.matchHeader("Authorization", `Bearer ${token}`); } return interceptor.delay(delay).reply(502); } export function mockCreateToken( - token: AccessToken = { access_token: 'aToken', expires_in: 3600 } + token: AccessToken = { access_token: "aToken", expires_in: 3600 } ): nock.Scope { - return nock('https://accounts.google.com') - .post('/o/oauth2/token') - .reply(200, token); + return nock("https://accounts.google.com").post("/o/oauth2/token").reply(200, token); } export function mockRefreshToken( - token: AccessToken = { access_token: 'aToken', expires_in: 3600 } + token: AccessToken = { access_token: "aToken", expires_in: 3600 } ): nock.Scope { - return nock('https://www.googleapis.com') - .post('/oauth2/v4/token') - .reply(200, token); + return nock("https://www.googleapis.com").post("/oauth2/v4/token").reply(200, token); } export function mockMetadataServiceToken( - token: AccessToken = { access_token: 'aToken', expires_in: 3600 } + token: AccessToken = { access_token: "aToken", expires_in: 3600 } ): nock.Scope { - return nock('http://metadata.google.internal') - .get('/computeMetadata/v1beta1/instance/service-accounts/default/token') + return nock("http://metadata.google.internal") + .get("/computeMetadata/v1beta1/instance/service-accounts/default/token") .reply(200, token); } diff --git a/spec/fixtures/mockrequest.ts b/spec/fixtures/mockrequest.ts index 28759f94c..d1b2d1f44 100644 --- a/spec/fixtures/mockrequest.ts +++ b/spec/fixtures/mockrequest.ts @@ -1,4 +1,4 @@ -import { EventEmitter } from 'node:stream'; +import { EventEmitter } from "node:stream"; import jwt from 'jsonwebtoken'; import jwkToPem from 'jwk-to-pem'; @@ -8,13 +8,10 @@ import * as mockKey from '../fixtures/credential/key.json'; // MockRequest mocks an https.Request. export class MockRequest extends EventEmitter { - public method: 'POST' | 'GET' | 'OPTIONS' = 'POST'; + public method: "POST" | "GET" | "OPTIONS" = "POST"; - constructor( - readonly body: any, - readonly headers: { [name: string]: string } - ) { - super() + constructor(readonly body: any, readonly headers: { [name: string]: string }) { + super(); } public header(name: string): string { @@ -25,25 +22,25 @@ export class MockRequest extends EventEmitter { // Creates a mock request with the given data and content-type. export function mockRequest( data: any, - contentType: string = 'application/json', + contentType: string = "application/json", context: { authorization?: string; instanceIdToken?: string; appCheckToken?: string; } = {}, - reqHeaders?: Record, + reqHeaders?: Record ) { const body: any = {}; - if (typeof data !== 'undefined') { + if (typeof data !== "undefined") { body.data = data; } const headers = { - 'content-type': contentType, + "content-type": contentType, authorization: context.authorization, - 'firebase-instance-id-token': context.instanceIdToken, - 'x-firebase-appcheck': context.appCheckToken, - origin: 'example.com', + "firebase-instance-id-token": context.instanceIdToken, + "x-firebase-appcheck": context.appCheckToken, + origin: "example.com", ...reqHeaders, }; @@ -51,8 +48,8 @@ export function mockRequest( } export const expectedResponseHeaders = { - 'Access-Control-Allow-Origin': 'example.com', - Vary: 'Origin', + "Access-Control-Allow-Origin": "example.com", + Vary: "Origin", }; /** @@ -62,11 +59,11 @@ export const expectedResponseHeaders = { export function mockFetchPublicKeys(): nock.Scope { const mockedResponse = { [mockKey.key_id]: mockKey.public_key }; const headers = { - 'cache-control': 'public, max-age=1, must-revalidate, no-transform', + "cache-control": "public, max-age=1, must-revalidate, no-transform", }; - return nock('https://www.googleapis.com:443') - .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') + return nock("https://www.googleapis.com:443") + .get("/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com") .reply(200, mockedResponse, headers); } @@ -78,12 +75,12 @@ export function generateIdToken(projectId: string): string { const options: jwt.SignOptions = { audience: projectId, expiresIn: 60 * 60, // 1 hour in seconds - issuer: 'https://securetoken.google.com/' + projectId, + issuer: "https://securetoken.google.com/" + projectId, subject: mockKey.user_id, - algorithm: 'RS256', + algorithm: "RS256", header: { kid: mockKey.key_id, - alg: 'RS256', + alg: "RS256", }, }; return jwt.sign(claims, mockKey.private_key, options); @@ -94,13 +91,13 @@ export function generateIdToken(projectId: string): string { */ export function generateUnsignedIdToken(projectId: string): string { return [ - { alg: 'RS256', typ: 'JWT' }, + { alg: "RS256", typ: "JWT" }, { aud: projectId, sub: mockKey.user_id }, - 'Invalid signature', + "Invalid signature", ] .map((str) => JSON.stringify(str)) - .map((str) => Buffer.from(str).toString('base64')) - .join('.'); + .map((str) => Buffer.from(str).toString("base64")) + .join("."); } /** @@ -113,18 +110,15 @@ export function mockFetchAppCheckPublicJwks(): nock.Scope { keys: [{ kty, use, alg, kid, n, e }], }; - return nock('https://firebaseappcheck.googleapis.com:443') - .get('/v1/jwks') + return nock("https://firebaseappcheck.googleapis.com:443") + .get("/v1/jwks") .reply(200, mockedResponse); } /** * Generates a mocked AppCheck token. */ -export function generateAppCheckToken( - projectId: string, - appId: string -): string { +export function generateAppCheckToken(projectId: string, appId: string): string { const claims = {}; const options: jwt.SignOptions = { audience: [`projects/${projectId}`], @@ -132,8 +126,8 @@ export function generateAppCheckToken( issuer: `https://firebaseappcheck.googleapis.com/${projectId}`, subject: appId, header: { - alg: 'RS256', - typ: 'JWT', + alg: "RS256", + typ: "JWT", kid: mockJWK.kid, }, }; @@ -143,16 +137,13 @@ export function generateAppCheckToken( /** * Generates a mocked, unsigned AppCheck token. */ -export function generateUnsignedAppCheckToken( - projectId: string, - appId: string -): string { +export function generateUnsignedAppCheckToken(projectId: string, appId: string): string { return [ - { alg: 'RS256', typ: 'JWT' }, + { alg: "RS256", typ: "JWT" }, { aud: [`projects/${projectId}`], sub: appId }, - 'Invalid signature', + "Invalid signature", ] .map((component) => JSON.stringify(component)) - .map((str) => Buffer.from(str).toString('base64')) - .join('.'); + .map((str) => Buffer.from(str).toString("base64")) + .join("."); } diff --git a/spec/helper.ts b/spec/helper.ts index c3f0f38ff..5b705937d 100644 --- a/spec/helper.ts +++ b/spec/helper.ts @@ -21,7 +21,7 @@ // SOFTWARE. import { expect } from "chai"; -import * as express from "express"; +import express from "express"; import * as https from "../src/common/providers/https"; import * as tasks from "../src/common/providers/tasks"; diff --git a/src/common/providers/identity.ts b/src/common/providers/identity.ts index f2a8a3949..59fbe8304 100644 --- a/src/common/providers/identity.ts +++ b/src/common/providers/identity.ts @@ -20,7 +20,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as express from "express"; +import express from "express"; import * as auth from "firebase-admin/auth"; import * as logger from "../../logger"; import { EventContext } from "../../v1/cloud-functions"; diff --git a/src/common/providers/tasks.ts b/src/common/providers/tasks.ts index f2e0f9ec7..7b8ff0081 100644 --- a/src/common/providers/tasks.ts +++ b/src/common/providers/tasks.ts @@ -20,13 +20,13 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as express from "express"; +import express from "express"; import { DecodedIdToken } from "firebase-admin/auth"; import * as logger from "../../logger"; -import * as https from "./https"; import { Expression } from "../../params"; import { ResetValue } from "../options"; +import * as https from "./https"; /** How a task should be retried in the event of a non-2xx return. */ export interface RetryConfig { diff --git a/src/v1/function-builder.ts b/src/v1/function-builder.ts index 3e8286933..3367cb19c 100644 --- a/src/v1/function-builder.ts +++ b/src/v1/function-builder.ts @@ -20,7 +20,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as express from "express"; +import express from "express"; import { ResetValue } from "../common/options"; import { Expression } from "../params/types"; diff --git a/src/v1/providers/https.ts b/src/v1/providers/https.ts index 739c9e001..82e658f73 100644 --- a/src/v1/providers/https.ts +++ b/src/v1/providers/https.ts @@ -20,9 +20,10 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as express from "express"; +import express from "express"; import { convertIfPresent, convertInvoker } from "../../common/encoding"; +import { withInit } from "../../common/onInit"; import { CallableContext, FunctionsErrorCode, @@ -31,11 +32,10 @@ import { Request, withErrorHandler, } from "../../common/providers/https"; -import { HttpsFunction, optionsToEndpoint, optionsToTrigger, Runnable } from "../cloud-functions"; -import { DeploymentOptions } from "../function-configuration"; import { initV1Endpoint } from "../../runtime/manifest"; -import { withInit } from "../../common/onInit"; import { wrapTraceContext } from "../../v2/trace"; +import { HttpsFunction, optionsToEndpoint, optionsToTrigger, Runnable } from "../cloud-functions"; +import { DeploymentOptions } from "../function-configuration"; export { HttpsError }; export type { Request, CallableContext, FunctionsErrorCode }; diff --git a/src/v1/providers/tasks.ts b/src/v1/providers/tasks.ts index 0be9176ab..31e5a9044 100644 --- a/src/v1/providers/tasks.ts +++ b/src/v1/providers/tasks.ts @@ -20,7 +20,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as express from "express"; +import express from "express"; import { convertIfPresent, convertInvoker, copyIfPresent } from "../../common/encoding"; import { Request } from "../../common/providers/https"; @@ -31,8 +31,8 @@ import { TaskContext, } from "../../common/providers/tasks"; import { - initV1Endpoint, initTaskQueueTrigger, + initV1Endpoint, ManifestEndpoint, ManifestRequiredAPI, } from "../../runtime/manifest"; diff --git a/src/v2/providers/scheduler.ts b/src/v2/providers/scheduler.ts index 1f8f33c31..bd7ecb5ff 100644 --- a/src/v2/providers/scheduler.ts +++ b/src/v2/providers/scheduler.ts @@ -20,23 +20,23 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import * as express from "express"; +import express from "express"; import { copyIfPresent } from "../../common/encoding"; +import { withInit } from "../../common/onInit"; import { ResetValue } from "../../common/options"; import { timezone } from "../../common/timezone"; +import * as logger from "../../logger"; +import { Expression } from "../../params"; import { initV2Endpoint, initV2ScheduleTrigger, ManifestEndpoint, ManifestRequiredAPI, } from "../../runtime/manifest"; -import { HttpsFunction } from "./https"; -import { wrapTraceContext } from "../trace"; -import { Expression } from "../../params"; -import * as logger from "../../logger"; import * as options from "../options"; -import { withInit } from "../../common/onInit"; +import { wrapTraceContext } from "../trace"; +import { HttpsFunction } from "./https"; /** @hidden */ interface SeparatedOpts { diff --git a/src/v2/trace.ts b/src/v2/trace.ts index 585686b89..1ecfc2d4b 100644 --- a/src/v2/trace.ts +++ b/src/v2/trace.ts @@ -1,4 +1,4 @@ -import * as express from "express"; +import express from "express"; import { TraceContext, extractTraceContext, traceContext } from "../common/trace"; import { CloudEvent } from "./core"; diff --git a/tsconfig.json b/tsconfig.json index b321cbca9..5e8cca74f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,9 +5,5 @@ "emitDeclarationOnly": false }, "extends": "./tsconfig.release.json", - "include": [ - "**/*.ts", - ".eslintrc.js", - "integration_test/**/*" - ] + "include": ["**/*.ts", ".eslintrc.js", "integration_test/**/*"] }