-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix: make storage, apphosting, and dataconnect emulator startup resilient #10934
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
75b40e0
c312365
a159024
54a0cee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: { | ||
|
|
@@ -20,6 +21,7 @@ function createMockOptions( | |
| return { | ||
| only, | ||
| config, | ||
| cwd: process.cwd(), | ||
| project: "test-project", | ||
| } as any; | ||
| } | ||
|
|
@@ -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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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`, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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; | ||
| } | ||
|
|
||
|
|
@@ -584,10 +636,6 @@ export async function startAll( | |
| } | ||
| } | ||
|
|
||
| if (extensionEmulator) { | ||
| await startEmulator(extensionEmulator); | ||
| } | ||
|
|
||
| const account = getProjectDefaultAccount(options.projectRoot); | ||
|
|
||
| if (emulatableBackends.length) { | ||
|
|
@@ -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, | ||
|
|
@@ -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) { | ||
|
|
@@ -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, | ||
|
|
@@ -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) { | ||
|
|
||
There was a problem hiding this comment.
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 anyin test helperRationale: The helper
createMockOptionsusesas anyon return andanyfor theconfigValuesmap. Typing this more precisely (e.g.Record<string, unknown>) would improve type safety in tests.Suggested Fix: