Skip to content

feat: Add global dataDir for emulator persistence #8782

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

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
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 npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions schema/firebase-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -1194,6 +1194,9 @@
},
"type": "object"
},
"dataDir": {
"type": "string"
},
"database": {
"additionalProperties": false,
"properties": {
Expand Down
15 changes: 15 additions & 0 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,21 @@ export class Command {
return this;
}

// TODO: refactor this to be a private method and ensure all commands that need it call it.
// TODO: add a test for this.
/**
* Adds the --ephemeral flag to the command.
* @return The command, for chaining.
*/
withEphemeral(): Command {
this.option(
"--ephemeral",
"ignore emulators.dataDir and start with a clean state",
false, // Default value if the flag is not present
);
return this;
}

/**
* Sets up --force flag for the command.
*
Expand Down
1 change: 1 addition & 0 deletions src/commands/emulators-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const command = new Command("emulators:exec <script>")
.description(
"start the local Firebase emulators, run a test script, then shut down the emulators",
)
.withEphemeral()
.option(commandUtils.FLAG_ONLY, commandUtils.DESC_ONLY)
.option(commandUtils.FLAG_INSPECT_FUNCTIONS, commandUtils.DESC_INSPECT_FUNCTIONS)
.option(commandUtils.FLAG_IMPORT, commandUtils.DESC_IMPORT)
Expand Down
1 change: 1 addition & 0 deletions src/commands/emulators-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const command = new Command("emulators:start")
.before(commandUtils.setExportOnExitOptions)
.before(commandUtils.beforeEmulatorCommand)
.description("start the local Firebase emulators")
.withEphemeral()
.option(commandUtils.FLAG_ONLY, commandUtils.DESC_ONLY)
.option(commandUtils.FLAG_INSPECT_FUNCTIONS, commandUtils.DESC_INSPECT_FUNCTIONS)
.option(commandUtils.FLAG_IMPORT, commandUtils.DESC_IMPORT)
Expand Down
48 changes: 47 additions & 1 deletion src/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,21 @@ import { FIREBASE_JSON_PATH as VALID_CONFIG_PATH } from "./test/fixtures/valid-c
import { FIXTURE_DIR as SIMPLE_CONFIG_DIR } from "./test/fixtures/config-imports";
import { FIXTURE_DIR as DUP_TOP_LEVEL_CONFIG_DIR } from "./test/fixtures/dup-top-level";

import * as sinon from "sinon";
import * as utils from "./utils";

describe("Config", () => {
describe("#load", () => {
let logWarningStub: sinon.SinonStub;

beforeEach(() => {
logWarningStub = sinon.stub(utils, "logWarning");
});

afterEach(() => {
logWarningStub.restore();
});

describe("#constructor", () => {
it("should load a cjson file when configPath is specified", () => {
const cwd = __dirname;
const config = Config.load({
Expand All @@ -19,6 +32,39 @@ describe("Config", () => {
expect(config.get("database.rules")).to.eq("config/security-rules.json");
}
});

it("should handle emulators.dataDir and emulators.dataconnect.dataDir correctly", () => {
// Scenario 1: Only emulators.dataDir is present
let config = new Config({ emulators: { dataDir: "global_data" } }, {});
expect(config.get("emulators.dataDir")).to.equal("global_data");
expect(logWarningStub.called).to.be.false;

// Scenario 2: Only emulators.dataconnect.dataDir is present
logWarningStub.resetHistory();
config = new Config({ emulators: { dataconnect: { dataDir: "dc_data" } } }, {});
expect(config.get("emulators.dataDir")).to.equal("dc_data");
expect(config.get("emulators.dataconnect.dataDir")).to.be.undefined;
expect(logWarningStub.calledOnceWith("emulators.dataconnect.dataDir is deprecated. Please move your dataDir setting to emulators.dataDir.")).to.be.true;

// Scenario 3: Both are present
logWarningStub.resetHistory();
config = new Config({ emulators: { dataDir: "global_data", dataconnect: { dataDir: "dc_data" } } }, {});
expect(config.get("emulators.dataDir")).to.equal("global_data");
expect(config.get("emulators.dataconnect.dataDir")).to.be.undefined;
expect(logWarningStub.calledOnceWith("emulators.dataconnect.dataDir is deprecated and will be ignored. Use emulators.dataDir instead.")).to.be.true;

// Scenario 4: Neither is present
logWarningStub.resetHistory();
config = new Config({ emulators: {} }, {});
expect(config.get("emulators.dataDir")).to.be.undefined;
expect(logWarningStub.called).to.be.false;

// Scenario 5: emulators object is not present
logWarningStub.resetHistory();
config = new Config({}, {});
expect(config.get("emulators.dataDir")).to.be.undefined;
expect(logWarningStub.called).to.be.false;
});
});

describe("#path", () => {
Expand Down
30 changes: 30 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,36 @@ export class Config {
"Please remove 'dataconnect.location' from 'firebase.json' and add it as top level field to 'dataconnect.yaml' instead ",
);
}

// Handle emulators.dataDir and emulators.dataconnect.dataDir
const emulatorsDataDir = _.get(this.data, "emulators.dataDir");
const dataconnectDataDir = _.get(this.data, "emulators.dataconnect.dataDir");

if (emulatorsDataDir && dataconnectDataDir) {
utils.logWarning(
"emulators.dataconnect.dataDir is deprecated and will be ignored. Use emulators.dataDir instead.",
);
// emulators.dataDir takes precedence, remove dataconnect.dataDir from internal representation
if (_.has(this.data, "emulators.dataconnect.dataDir")) {
delete this.data.emulators.dataconnect.dataDir;
}
if (_.has(this._src, "emulators.dataconnect.dataDir")) {
delete this._src.emulators.dataconnect.dataDir;
}
} else if (dataconnectDataDir) {
utils.logWarning(
"emulators.dataconnect.dataDir is deprecated. Please move your dataDir setting to emulators.dataDir.",
);
// Use dataconnect.dataDir if emulators.dataDir is not set
_.set(this.data, "emulators.dataDir", dataconnectDataDir);
// Remove dataconnect.dataDir from internal representation after copying its value
if (_.has(this.data, "emulators.dataconnect.dataDir")) {
delete this.data.emulators.dataconnect.dataDir;
}
if (_.has(this._src, "emulators.dataconnect.dataDir")) {
delete this._src.emulators.dataconnect.dataDir;
}
}
}

materialize(target: string) {
Expand Down
48 changes: 48 additions & 0 deletions src/emulator/commandUtils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,20 @@ import { EXPORT_ON_EXIT_USAGE_ERROR, EXPORT_ON_EXIT_CWD_DANGER } from "./command
import * as path from "path";
import * as sinon from "sinon";

import { Config } from "../config";
import * as utils from "../utils";

describe("commandUtils", () => {
let logLabeledBulletStub: sinon.SinonStub;

beforeEach(() => {
logLabeledBulletStub = sinon.stub(utils, "logLabeledBullet");
});

afterEach(() => {
logLabeledBulletStub.restore();
});

const testSetExportOnExitOptions = (options: any): any => {
commandUtils.setExportOnExitOptions(options);
return options;
Expand Down Expand Up @@ -120,4 +133,39 @@ describe("commandUtils", () => {
}).someUnrelatedOption,
).to.eql("isHere");
});

describe("beforeEmulatorCommand", () => {
it("should unset dataDir if --ephemeral is true and dataDir is set", async () => {
const options = {
ephemeral: true,
config: new Config({ emulators: { dataDir: "./emulator_data" } }, {}),
project: "test-project",
};
await commandUtils.beforeEmulatorCommand(options);
expect(options.config.get("emulators.dataDir")).to.be.undefined;
expect(logLabeledBulletStub.calledOnceWith("emulators", "Ignoring dataDir due to --ephemeral flag.")).to.be.true;
});

it("should not modify dataDir if --ephemeral is false", async () => {
const options = {
ephemeral: false,
config: new Config({ emulators: { dataDir: "./emulator_data" } }, {}),
project: "test-project",
};
await commandUtils.beforeEmulatorCommand(options);
expect(options.config.get("emulators.dataDir")).to.equal("./emulator_data");
expect(logLabeledBulletStub.called).to.be.false;
});

it("should not modify dataDir if --ephemeral is true but dataDir is not set", async () => {
const options = {
ephemeral: true,
config: new Config({ emulators: {} }, {}),
project: "test-project",
};
await commandUtils.beforeEmulatorCommand(options);
expect(options.config.get("emulators.dataDir")).to.be.undefined;
expect(logLabeledBulletStub.called).to.be.false;
});
});
});
6 changes: 6 additions & 0 deletions src/emulator/commandUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ export async function beforeEmulatorCommand(options: any): Promise<any> {
} else {
await requireConfig(options);
}

// Handle --ephemeral flag
if (options.ephemeral && options.config?.emulators?.dataDir) {
delete options.config.emulators.dataDir;
utils.logLabeledBullet("emulators", "Ignoring dataDir due to --ephemeral flag.");
}
}

/**
Expand Down
130 changes: 128 additions & 2 deletions src/emulator/controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,141 @@
import { Emulators } from "./types";
import { Emulators, EmulatorInfo, ExportMetadata } from "./types";
import { EmulatorRegistry } from "./registry";
import { expect } from "chai";
import * as sinon from "sinon";
import * as fs from "fs-extra";
import * as controller from "./controller";
import { Config } from "../config";
import { FakeEmulator } from "./testing/fakeEmulator";
import * as utils from "../utils";
import { EmulatorHub } from "./hub";

describe("EmulatorController", () => {
let fsExistsSyncStub: sinon.SinonStub;
let fsLstatSyncStub: sinon.SinonStub;
let fsReaddirSyncStub: sinon.SinonStub;
let findExportMetadataStub: sinon.SinonStub;
let exportEmulatorDataStub: sinon.SinonStub;
let logBulletStub: sinon.SinonStub;
let logLabeledStub: sinon.SinonStub;
let hubGetInstanceStub: sinon.SinonStub;


beforeEach(() => {
fsExistsSyncStub = sinon.stub(fs, "existsSync");
fsLstatSyncStub = sinon.stub(fs, "lstatSync");
fsReaddirSyncStub = sinon.stub(fs, "readdirSync");
findExportMetadataStub = sinon.stub(controller, "findExportMetadata" as any);
exportEmulatorDataStub = sinon.stub(controller, "exportEmulatorData" as any);
logBulletStub = sinon.stub(utils, "logBullet");
logLabeledStub = sinon.stub(utils, "logLabeled"); // For hubLogger.logLabeled

// Stub EmulatorRegistry.get(Emulators.HUB) to return a mock hub instance
// so that hubLogger doesn't throw an error.
const mockHubInstance = sinon.createStubInstance(EmulatorHub);
hubGetInstanceStub = sinon.stub(EmulatorRegistry, "get").withArgs(Emulators.HUB).returns(mockHubInstance as any);

});

afterEach(async () => {
await EmulatorRegistry.stopAll();
sinon.restore();
});

describe("startAll", () => {
const baseOptions = () => ({
project: "test-project",
only: "firestore", // Assume at least one emulator is always being started
config: new Config({ firestore: {}, emulators: {} }, {}),
targets: [Emulators.FIRESTORE], // Added to satisfy filterEmulatorTargets
});

it("should attempt to import from dataDir if set and valid", async () => {
const opts = baseOptions();
opts.config = new Config({ firestore: {}, emulators: { dataDir: "./emulator_data_dir" } }, {});
fsExistsSyncStub.withArgs(sinon.match.string).returns(true);
fsLstatSyncStub.returns({ isDirectory: () => true } as fs.Stats);
const mockMetadata: ExportMetadata = { version: "1.0", firestore: { version: "prod", path: "./", metadata_file: "f.json"} };
findExportMetadataStub.resolves(mockMetadata);

// Stubbing minimum required for startAll to proceed without erroring out on other parts
sinon.stub(controller, "shouldStart" as any).returns(true);
sinon.stub(controller, "getListenConfig" as any).returns({ host: "localhost", port: 8080, portFixed: true });
sinon.stub(controller, "resolveHostAndAssignPorts" as any).resolves({ firestore: [{address: "localhost", port: 8080, family: "ipv4"}]});
sinon.stub(EmulatorRegistry, "start").resolves();
sinon.stub(fsConfig, "getFirestoreConfig" as any).returns([{rules: "firestore.rules"}]);


await controller.startAll(opts);

expect(findExportMetadataStub.calledWith(sinon.match("emulator_data_dir"))).to.be.true;
// Further assertions would check if this metadata was passed to individual emulator imports
});

it("should use --import path if dataDir is not set or invalid, and --import is set", async () => {
const opts = baseOptions();
opts.config = new Config({ firestore: {}, emulators: {} }, {}); // No dataDir
(opts as any).import = "./import_dir";

fsExistsSyncStub.withArgs(sinon.match("import_dir")).returns(true);
fsLstatSyncStub.withArgs(sinon.match("import_dir")).returns({ isDirectory: () => true } as fs.Stats);
const mockMetadata: ExportMetadata = { version: "1.0", firestore: { version: "prod", path: "./", metadata_file: "f.json"} };
findExportMetadataStub.withArgs(sinon.match("import_dir")).resolves(mockMetadata);

sinon.stub(controller, "shouldStart" as any).returns(true);
sinon.stub(controller, "getListenConfig" as any).returns({ host: "localhost", port: 8080, portFixed: true });
sinon.stub(controller, "resolveHostAndAssignPorts" as any).resolves({ firestore: [{address: "localhost", port: 8080, family: "ipv4"}]});
sinon.stub(EmulatorRegistry, "start").resolves();
sinon.stub(fsConfig, "getFirestoreConfig" as any).returns([{rules: "firestore.rules"}]);

await controller.startAll(opts);

expect(findExportMetadataStub.calledWith(sinon.match("import_dir"))).to.be.true;
});
});

describe("exportOnExit", () => {
it("should export to dataDir if set and --export-on-exit is not used", async () => {
const options = {
project: "test-project",
config: new Config({ emulators: { dataDir: "./emulator_data_dir" } }, {}),
};
exportEmulatorDataStub.resolves();

await controller.exportOnExit(options);

expect(exportEmulatorDataStub.calledOnceWith("./emulator_data_dir", options, "datadir_exit")).to.be.true;
expect(logBulletStub.calledWith(sinon.match("Automatically exporting data to global dataDir: \"./emulator_data_dir\""))).to.be.true;
});

it("should use --export-on-exit path if provided, even if dataDir is set", async () => {
const options = {
project: "test-project",
config: new Config({ emulators: { dataDir: "./emulator_data_dir" } }, {}),
exportOnExit: "./explicit_export_dir",
};
exportEmulatorDataStub.resolves();

await controller.exportOnExit(options);

expect(exportEmulatorDataStub.calledOnceWith("./explicit_export_dir", options, "exit")).to.be.true;
});

it("should do nothing if neither dataDir nor --export-on-exit is set", async () => {
const options = {
project: "test-project",
config: new Config({ emulators: {} }, {}),
};

await controller.exportOnExit(options);

expect(exportEmulatorDataStub.called).to.be.false;
});
});

// Previous tests for start and stop can remain here
it("should start and stop an emulator", async () => {
const name = Emulators.FUNCTIONS;
hubGetInstanceStub.restore() // remove hub stub for this specific test if it interferes

expect(EmulatorRegistry.isRunning(name)).to.be.false;

Expand All @@ -19,4 +145,4 @@ describe("EmulatorController", () => {
expect(EmulatorRegistry.isRunning(name)).to.be.true;
expect(EmulatorRegistry.getInfo(name)!.port).to.eql(fake.getInfo().port);
});
}).timeout(2000);
}).timeout(5000); // Increased timeout for async operations
Loading
Loading