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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- Fixed an issue where missing configurations for Storage, App Hosting, and Data Connect emulators caused blocking startup errors for the entire emulator suite.
- Fixed an issue where managed service accounts for declarative security were not deleted when all functions in a codebase were deleted, or caused IAM permission errors on empty codebase deploys.
- Configured OneMCP server tools to require a Firebase project by default, with options to opt-out specific tools (such as Developer Knowledge document search).
- Fixed a bug where deploying functions with the `dartfunctions` experiment enabled could incorrectly prompt to delete existing GCF v2 functions.
Expand Down
52 changes: 52 additions & 0 deletions src/emulator/controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ function createMockOptions(
configValues: { [key: string]: any },
): Options {
const config = {
projectDir: ".",
get: (key: string) => configValues[key],
has: (key: string) => !!configValues[key],
src: {
Expand All @@ -20,6 +21,7 @@ function createMockOptions(
return {
only,
config,
cwd: process.cwd(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: [TypeScript Precision] Legacy as any in test helper

Rationale: The helper createMockOptions uses as any on return and any for the configValues map. Typing this more precisely (e.g. Record<string, unknown>) would improve type safety in tests.

Suggested Fix:

function createMockOptions(
  only: string | undefined,
  configValues: Record<string, unknown>,
): Options {
  ...
  return {
    only,
    config,
    cwd: process.cwd(),
    project: "test-project",
  } as unknown as Options;
}

project: "test-project",
} as any;
}
Expand Down Expand Up @@ -103,5 +105,55 @@ describe("EmulatorController", () => {
});
expect(shouldStart(options, Emulators.FUNCTIONS)).to.be.false;
});

it("should not start dataconnect emulator if there is no dataconnect service configured", () => {
const options = createMockOptions("dataconnect", {
dataconnect: undefined,
});
expect(shouldStart(options, Emulators.DATACONNECT)).to.be.false;
});

it("should start dataconnect emulator if dataconnect service is configured", () => {
const options = createMockOptions("dataconnect", {
dataconnect: [{ source: "dataconnect" }],
});
expect(shouldStart(options, Emulators.DATACONNECT)).to.be.true;
});

it("should not start extensions emulator if no extensions are configured and functions cannot start", () => {
const options = createMockOptions("extensions", {
extensions: undefined,
functions: {},
});
expect(shouldStart(options, Emulators.EXTENSIONS)).to.be.false;
});

it("should start extensions emulator if extensions are configured in firebase.json", () => {
const options = createMockOptions("extensions", {
extensions: { "my-ext": "firebase/storage-resize-images@0.1.18" },
});
expect(shouldStart(options, Emulators.EXTENSIONS)).to.be.true;
});

it("should start extensions emulator if functions emulator can start", () => {
const options = createMockOptions("extensions,functions", {
functions: { source: "functions" },
});
expect(shouldStart(options, Emulators.EXTENSIONS)).to.be.true;
});

it("should not start apphosting emulator if start command is not set and no lockfile exists", () => {
const options = createMockOptions("apphosting", {
apphosting: { rootDirectory: "./nonexistent-dir" },
});
expect(shouldStart(options, Emulators.APPHOSTING)).to.be.false;
});

it("should start apphosting emulator if startCommand is configured", () => {
const options = createMockOptions("apphosting", {
apphosting: { startCommand: "npm run dev" },
});
expect(shouldStart(options, Emulators.APPHOSTING)).to.be.true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [Testing Discipline] Missing test for lockfile auto-detection

Rationale: The PR implements auto-detection for the App Hosting start command by looking for a lockfile in the backend root. However, there is no test verifying that the emulator starts when a lockfile is present.

Suggested Fix: Add a unit test stubbing fs.existsSync using sinon to return true for a lockfile path.

    it("should start apphosting emulator if start command is not set but lockfile is present", () => {
      const existsStub = sinon.stub(fs, "existsSync");
      existsStub.withArgs(sinon.match(/package-lock.json/)).returns(true);
      try {
        const options = createMockOptions("apphosting", {
          apphosting: { rootDirectory: "./my-app" },
        });
        expect(shouldStart(options, Emulators.APPHOSTING)).to.be.true;
      } finally {
        existsStub.restore();
      }
    });

});
});
}).timeout(2000);
205 changes: 145 additions & 60 deletions src/emulator/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,58 @@ export function shouldStart(options: Options, name: Emulators): boolean {
return false;
}

if (
name === Emulators.DATACONNECT &&
emulatorInTargets &&
!readFirebaseJson(options.config).length
) {
EmulatorLogger.forEmulator(Emulators.DATACONNECT).logLabeled(
"ERROR",
"dataconnect",
`Failed to start Data Connect emulator: No valid Data Connect configuration detected in firebase.json`,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 [Error Remediation] Missing remediation instructions

Rationale: CLI error messages should provide explicit instructions on how to resolve the issue. The warning for missing Data Connect configuration should advise running firebase init dataconnect (similar to how Hosting emulator advises running firebase init hosting).

Suggested Fix:

    EmulatorLogger.forEmulator(Emulators.DATACONNECT).logLabeled(
      "ERROR",
      "dataconnect",
      `Failed to start Data Connect emulator: No valid Data Connect configuration detected in firebase.json. Run ${clc.bold("firebase init dataconnect")} to configure it.`,
    );

);
return false;
}

if (
name === Emulators.EXTENSIONS &&
emulatorInTargets &&
!options.config.has("extensions") &&
!shouldStart(options, Emulators.FUNCTIONS)
) {
return false;
}

if (name === Emulators.APPHOSTING && emulatorInTargets) {
const apphostingEmulatorConfig = options.config.src.emulators?.[Emulators.APPHOSTING];
const startCommand =
apphostingEmulatorConfig?.startCommand || apphostingEmulatorConfig?.startCommandOverride;
if (!startCommand) {
const rootDirectory = apphostingEmulatorConfig?.rootDirectory;
let backendRoot: string;
try {
backendRoot = resolveProjectPath(options, rootDirectory ?? "./");
} catch {
backendRoot = path.resolve(
options.config?.projectDir || options.cwd || process.cwd(),
rootDirectory ?? "./",
);
}
const hasLockfile =
fs.existsSync(path.join(backendRoot, "package-lock.json")) ||
fs.existsSync(path.join(backendRoot, "yarn.lock")) ||
fs.existsSync(path.join(backendRoot, "pnpm-lock.yaml"));
if (!hasLockfile) {
EmulatorLogger.forEmulator(Emulators.APPHOSTING).logLabeled(
"ERROR",
Emulators.APPHOSTING,
"Failed to start App Hosting emulator: Failed to auto-detect your project's start command. Consider manually setting the start command by setting `firebase.json#emulators.apphosting.startCommand`",
);
return false;
}
}
}

return emulatorInTargets;
}

Expand Down Expand Up @@ -584,10 +636,6 @@ export async function startAll(
}
}

if (extensionEmulator) {
await startEmulator(extensionEmulator);
}

const account = getProjectDefaultAccount(options.projectRoot);

if (emulatableBackends.length) {
Expand Down Expand Up @@ -645,6 +693,10 @@ export async function startAll(
});
await startEmulator(functionsEmulator);

if (extensionEmulator) {
await startEmulator(extensionEmulator);
}

const eventarcAddr = legacyGetFirstAddr(Emulators.EVENTARC);
const eventarcEmulator = new EventarcEmulator({
host: eventarcAddr.host,
Expand Down Expand Up @@ -893,70 +945,90 @@ export async function startAll(
}

if (listenForEmulator.dataconnect) {
const dataconnectLogger = EmulatorLogger.forEmulator(Emulators.DATACONNECT);
const config = readFirebaseJson(options.config);
if (!config.length) {
throw new FirebaseError("No SQL Connect service found in firebase.json");
} else if (config.length > 1) {
logger.warn(
`TODO: Add support for multiple services in the SQL Connect emulator. Currently emulating first service ${config[0].source}`,
dataconnectLogger.logLabeled(
"ERROR",
"dataconnect",
`Failed to start Data Connect emulator: No valid Data Connect configuration detected in firebase.json`,
);
}
} else {
if (config.length > 1) {
logger.warn(
`TODO: Add support for multiple services in the SQL Connect emulator. Currently emulating first service ${config[0].source}`,
);
}

const args: DataConnectEmulatorArgs = {
listen: listenForEmulator.dataconnect,
projectId,
auto_download: true,
configDir: config[0].source,
config: options.config,
autoconnectToPostgres: true,
postgresListen: listenForEmulator["dataconnect.postgres"],
enable_output_generated_sdk: true, // TODO: source from arguments
enable_output_schema_extensions: true,
debug: options.debug,
account,
};
const args: DataConnectEmulatorArgs = {
listen: listenForEmulator.dataconnect,
projectId,
auto_download: true,
configDir: config[0].source,
config: options.config,
autoconnectToPostgres: true,
postgresListen: listenForEmulator["dataconnect.postgres"],
enable_output_generated_sdk: true, // TODO: source from arguments
enable_output_schema_extensions: true,
debug: options.debug,
account,
};

if (exportMetadata.dataconnect) {
utils.assertIsString(options.import);
const importDirAbsPath = path.resolve(options.import);
const exportMetadataFilePath = path.resolve(
importDirAbsPath,
exportMetadata.dataconnect.path,
);
const dataDirectory = options.config.get("emulators.dataconnect.dataDir");
if (exportMetadataFilePath && dataDirectory) {
dataconnectLogger.logLabeled(
"WARN",
"dataconnect",
"'firebase.json#emulators.dataconnect.dataDir' is set and `--import` flag was passed. " +
"This will overwrite any data saved from previous runs.",
);
if (
!options.nonInteractive &&
!(await confirm({
message: `Do you wish to continue and overwrite data in ${dataDirectory}?`,
default: false,
}))
) {
await cleanShutdown();
throw new FirebaseError("Command aborted");
}
}

if (exportMetadata.dataconnect) {
utils.assertIsString(options.import);
const importDirAbsPath = path.resolve(options.import);
const exportMetadataFilePath = path.resolve(
importDirAbsPath,
exportMetadata.dataconnect.path,
);
const dataDirectory = options.config.get("emulators.dataconnect.dataDir");
if (exportMetadataFilePath && dataDirectory) {
EmulatorLogger.forEmulator(Emulators.DATACONNECT).logLabeled(
"WARN",
dataconnectLogger.logLabeled(
"BULLET",
"dataconnect",
"'firebase.json#emulators.dataconnect.dataDir' is set and `--import` flag was passed. " +
"This will overwrite any data saved from previous runs.",
`Importing data from ${exportMetadataFilePath}`,
);
if (
!options.nonInteractive &&
!(await confirm({
message: `Do you wish to continue and overwrite data in ${dataDirectory}?`,
default: false,
}))
) {
await cleanShutdown();
throw new FirebaseError("Command aborted");
}
args.importPath = exportMetadataFilePath;
void trackEmulator("emulator_import", {
initiated_by: "start",
emulator_name: Emulators.DATACONNECT,
});
}

EmulatorLogger.forEmulator(Emulators.DATACONNECT).logLabeled(
"BULLET",
"dataconnect",
`Importing data from ${exportMetadataFilePath}`,
);
args.importPath = exportMetadataFilePath;
void trackEmulator("emulator_import", {
initiated_by: "start",
emulator_name: Emulators.DATACONNECT,
});
try {
const dataConnectEmulator = new DataConnectEmulator(args);
await startEmulator(dataConnectEmulator);
} catch (err: unknown) {
try {
await EmulatorRegistry.stop(Emulators.DATACONNECT);
} catch {
// Ignore errors stopping failed instance
}
dataconnectLogger.logLabeled(
"ERROR",
"dataconnect",
`Failed to start Data Connect emulator: ${getErrMsg(err)}`,
);
}
}

const dataConnectEmulator = new DataConnectEmulator(args);
await startEmulator(dataConnectEmulator);
}

if (listenForEmulator.storage) {
Expand Down Expand Up @@ -1018,8 +1090,8 @@ export async function startAll(
}

const apphostingAddr = legacyGetFirstAddr(Emulators.APPHOSTING);
const apphostingLogger = EmulatorLogger.forEmulator(Emulators.APPHOSTING);
if (apphostingEmulatorConfig?.startCommandOverride) {
const apphostingLogger = EmulatorLogger.forEmulator(Emulators.APPHOSTING);
apphostingLogger.logLabeled(
"WARN",
Emulators.APPHOSTING,
Expand All @@ -1037,7 +1109,20 @@ export async function startAll(
options,
});

await startEmulator(apphostingEmulator);
try {
await startEmulator(apphostingEmulator);
} catch (err: unknown) {
try {
await EmulatorRegistry.stop(Emulators.APPHOSTING);
} catch {
// Ignore errors stopping failed instance
}
apphostingLogger.logLabeled(
"ERROR",
Emulators.APPHOSTING,
`Failed to start App Hosting emulator: ${getErrMsg(err)}`,
);
}
}

if (listenForEmulator.logging) {
Expand Down
20 changes: 10 additions & 10 deletions src/emulator/storage/rules/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,20 +92,20 @@ describe("Storage Rules Config", () => {
expect(result[2].rules.content).to.contain("allow read, write: if request.auth!=null");
});

it("should throw FirebaseError when storage config is missing", () => {
it("should use default config when storage config is missing", () => {
const config = getOptions({ data: {}, path: resolvePath });
expect(() => getStorageRulesConfig(PROJECT_ID, config)).to.throw(
FirebaseError,
"Cannot start the Storage emulator without rules file specified in firebase.json: run 'firebase init' and set up your Storage configuration",
);
const result = getStorageRulesConfig(PROJECT_ID, config) as SourceFile;

expect(result.name).to.contain("templates/emulators/default_storage.rules");
expect(result.content).to.contain("allow read, write;");
});

it("should throw FirebaseError when rules file is missing", () => {
it("should use default config when rules file is missing", () => {
const config = getOptions({ data: { storage: {} }, path: resolvePath });
expect(() => getStorageRulesConfig(PROJECT_ID, config)).to.throw(
FirebaseError,
"Cannot start the Storage emulator without rules file specified in firebase.json: run 'firebase init' and set up your Storage configuration",
);
const result = getStorageRulesConfig(PROJECT_ID, config) as SourceFile;

expect(result.name).to.contain("templates/emulators/default_storage.rules");
expect(result.content).to.contain("allow read, write;");
});

it("should throw FirebaseError when rules file is invalid", () => {
Expand Down
Loading
Loading