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 deleted file mode 100644 index 3b0f5413f..000000000 --- a/integration_test/README.md +++ /dev/null @@ -1,22 +0,0 @@ -## How to Use - -**_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)! - -Run the integration test as follows: - -```bash -./run_tests.sh [] -``` - -Test runs cycles of testing, once for Node.js 14 and another for Node.js 16. - -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`). - -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). - -### Debugging - -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. diff --git a/integration_test/cli.ts b/integration_test/cli.ts new file mode 100644 index 000000000..6d14650aa --- /dev/null +++ b/integration_test/cli.ts @@ -0,0 +1,166 @@ +#!/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 + } + await execCommand( + "firebase", + ["deploy", "--only", "functions"], + { RUN_ID: runId }, + integrationTestDir + ); + console.log("Functions deployed successfully"); +} + +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(): Promise { + console.log(`Cleaning up functions with RUN_ID: ${runId}...`); + await execCommand("firebase", ["functions:delete", runId, "--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..6c24c7b99 --- /dev/null +++ b/integration_test/functions/src/config.ts @@ -0,0 +1,9 @@ +export const config = { + apiKey: "AIzaSyBBt77mpu6TV0IA2tcNSyf4OltsVu_Z1Zw", + authDomain: "cf3-integration-tests-v2-qa.firebaseapp.com", + databaseURL: "https://cf3-integration-tests-v2-qa-default-rtdb.firebaseio.com", + projectId: "cf3-integration-tests-v2-qa", + storageBucket: "cf3-integration-tests-v2-qa.firebasestorage.app", + messagingSenderId: "576826020291", + appId: "1:576826020291:web:488d568c5d4109df12ed76", +}; 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..e4fd22649 100644 --- a/integration_test/functions/src/index.ts +++ b/integration_test/functions/src/index.ts @@ -1,230 +1,18 @@ -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; -}; -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"); - } - }); +export * from "./v1/database.v1"; +export * from "./v1/firestore.v1"; +export * from "./v1/https.v1"; +export * from "./v1/remoteConfig.v1"; +export * from "./v1/pubsub.v1"; +export * from "./v1/storage.v1"; +export * from "./v1/tasks.v1"; + +export * from "./v2/database.v2"; +export * from "./v2/eventarc.v2"; +export * from "./v2/firestore.v2"; +export * from "./v2/https.v2"; +export * from "./v2/identity.v2"; +export * from "./v2/pubsub.v2"; +export * from "./v2/remoteConfig.v2"; +export * from "./v2/scheduler.v2"; +export * from "./v2/storage.v2"; +export * from "./v2/tasks.v2"; 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..123eaced5 --- /dev/null +++ b/integration_test/functions/src/v1/database.v1.ts @@ -0,0 +1,21 @@ +import { sendEvent } from "../utils"; +import { serializeChangeEvent, serializeDataSnapshot } from "../serializers/database"; +import * as functions from "firebase-functions/v1"; + +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)); + }); 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..ab682403d --- /dev/null +++ b/integration_test/functions/src/v1/firestore.v1.ts @@ -0,0 +1,23 @@ +import * as functions from "firebase-functions/v1"; +import { sendEvent } from "../utils"; +import { serializeChangeEvent, serializeQueryDocumentSnapshot } from "../serializers/firestore"; + +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)); + }); + +// TODO: onWrite - need multiple event handler 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..91c1a651b --- /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, "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("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..bd017265d --- /dev/null +++ b/integration_test/functions/src/v1/https.v1.ts @@ -0,0 +1,20 @@ +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; + }); 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..40571621a --- /dev/null +++ b/integration_test/functions/src/v1/pubsub.v1.ts @@ -0,0 +1,11 @@ +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(), + }); + }); 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..d27dc062d --- /dev/null +++ b/integration_test/functions/src/v1/remoteConfig.v1.ts @@ -0,0 +1,11 @@ +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, + }); + } +); 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..fecb084f1 --- /dev/null +++ b/integration_test/functions/src/v1/storage.v1.ts @@ -0,0 +1,30 @@ +import * as functions from "firebase-functions/v1"; +import { sendEvent } from "../utils"; +import { serializeEventContext } from "../serializers"; + +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, + }); + }); 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..47833de7c --- /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 = "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..0c77d2140 --- /dev/null +++ b/integration_test/functions/src/v1/tasks.v1.ts @@ -0,0 +1,22 @@ +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, + }); + }); 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..a5d947b9b --- /dev/null +++ b/integration_test/functions/src/v2/database.v2.ts @@ -0,0 +1,43 @@ +import { sendEvent } from "../utils"; +import { + serializeChangeEvent, + serializeDatabaseEvent, + serializeDataSnapshot, +} from "../serializers/database"; +import { onValueCreated, onValueDeleted, onValueUpdated } from "firebase-functions/database"; + +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)) + ); + } +); 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..7eca3ac96 --- /dev/null +++ b/integration_test/functions/src/v2/eventarc.v2.ts @@ -0,0 +1,15 @@ +import { onCustomEventPublished } from "firebase-functions/eventarc"; +import { sendEvent } from "../utils"; +import { serializeCloudEvent } from "../serializers"; + +export const eventarcOnCustomEventPublishedTrigger = onCustomEventPublished( + { + eventType: "vitest-test", + }, + async (event) => { + await sendEvent("onCustomEventPublished", { + ...serializeCloudEvent(event), + eventData: JSON.stringify(event.data), + }); + } +); 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..56a992b96 --- /dev/null +++ b/integration_test/functions/src/v2/firestore.v2.ts @@ -0,0 +1,86 @@ +import { + onDocumentCreated, + onDocumentCreatedWithAuthContext, + onDocumentDeleted, + onDocumentDeletedWithAuthContext, + onDocumentUpdated, + onDocumentUpdatedWithAuthContext, +} from "firebase-functions/v2/firestore"; +import { sendEvent } from "../utils"; +import { + serializeChangeEvent, + serializeFirestoreAuthEvent, + serializeFirestoreEvent, + serializeQueryDocumentSnapshot, +} from "../serializers/firestore"; + +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!)) + ); + } +); 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..e09c46cb4 --- /dev/null +++ b/integration_test/functions/src/v2/https.v2.test.ts @@ -0,0 +1,79 @@ +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"; + +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, "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/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..e3918364c --- /dev/null +++ b/integration_test/functions/src/v2/https.v2.ts @@ -0,0 +1,31 @@ +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; + } +); 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..01a91059a --- /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("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("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..ce125ab2c --- /dev/null +++ b/integration_test/functions/src/v2/identity.v2.ts @@ -0,0 +1,11 @@ +import { beforeUserCreated, beforeUserSignedIn } from "firebase-functions/v2/identity"; +import { sendEvent } from "../utils"; +import { serializeAuthBlockingEvent } from "../serializers/identity"; + +export const authBeforeUserCreatedTrigger = beforeUserCreated(async (event) => { + await sendEvent("beforeUserCreated", serializeAuthBlockingEvent(event)); +}); + +export const authBeforeUserSignedInTrigger = beforeUserSignedIn(async (event) => { + await sendEvent("beforeUserSignedIn", serializeAuthBlockingEvent(event)); +}); 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..313e02ede --- /dev/null +++ b/integration_test/functions/src/v2/pubsub.v2.ts @@ -0,0 +1,21 @@ +import { onMessagePublished } from "firebase-functions/v2/pubsub"; +import { sendEvent } from "../utils"; +import { serializeCloudEvent } from "../serializers"; + +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, + }, + }); + } +); 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..1a104da2b --- /dev/null +++ b/integration_test/functions/src/v2/remoteConfig.v2.ts @@ -0,0 +1,10 @@ +import { onConfigUpdated } from "firebase-functions/v2/remoteConfig"; +import { sendEvent } from "../utils"; +import { serializeCloudEvent } from "../serializers"; + +export const remoteConfigOnConfigUpdatedTests = onConfigUpdated(async (event) => { + await sendEvent("onConfigUpdated", { + ...serializeCloudEvent(event), + update: event.data, + }); +}); 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..5bc267d3b --- /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-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..c5fe743ed --- /dev/null +++ b/integration_test/functions/src/v2/scheduler.v2.ts @@ -0,0 +1,6 @@ +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); +}); 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..c8447e4af --- /dev/null +++ b/integration_test/functions/src/v2/storage.v2.ts @@ -0,0 +1,19 @@ +import { + onObjectDeleted, + onObjectFinalized, + onObjectMetadataUpdated, +} from "firebase-functions/v2/storage"; +import { sendEvent } from "../utils"; +import { serializeStorageEvent } from "../serializers/storage"; + +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)); +}); 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..d61d0bf29 --- /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 = "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..66eedafb6 --- /dev/null +++ b/integration_test/functions/src/v2/tasks.v2.ts @@ -0,0 +1,23 @@ +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, + }); + } +); 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.json b/package.json index e1fede750..08e497af2 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", @@ -524,8 +525,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", @@ -537,7 +538,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/**/*"] }