feat(FN-2166): persist dev server script configuration across sessions

- Extend dev server store with config defaults, normalization, and JSON persistence alongside runtime state.
- Add GET/PUT /api/dev-server/config endpoints with strict request validation for nullable fields and preview URLs.
- Add dashboard API helpers plus a useDevServerConfig hook to load and update selected script, source, command, and preview override.
- Update DevServerView and styles to support saved script selection, change/clear actions, and synchronized command/preview inputs.
- Expand dev server store/routes/component tests and document the config endpoint in architecture docs.
This commit is contained in:
Fusion
2026-04-20 11:36:39 -07:00
committed by gsxdsm
parent ca6ed92dd1
commit 42476ec3da
12 changed files with 1096 additions and 229 deletions

View File

@@ -0,0 +1,109 @@
// @vitest-environment node
import express from "express";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { request } from "../test-request.js";
import { createDevServerRouter, destroyAllDevServerManagers } from "../dev-server-routes.js";
function createProjectRoot(): string {
return mkdtempSync(join(os.tmpdir(), "fn-dev-server-config-routes-"));
}
function buildApp(projectRoot: string): express.Express {
const app = express();
app.use(express.json());
app.use("/api/dev-server", createDevServerRouter({ projectRoot }));
return app;
}
describe("dev-server config routes", () => {
const tempDirs: string[] = [];
afterEach(async () => {
await destroyAllDevServerManagers();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("GET /api/dev-server/config returns defaults when config file is missing", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const res = await request(app, "GET", "/api/dev-server/config");
expect(res.status).toBe(200);
expect(res.body).toEqual({
selectedScript: null,
selectedSource: null,
selectedCommand: null,
previewUrlOverride: null,
detectedPreviewUrl: null,
selectedAt: null,
});
});
it("PUT /api/dev-server/config saves a valid partial update", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const res = await request(
app,
"PUT",
"/api/dev-server/config",
JSON.stringify({
selectedScript: "dev",
selectedSource: "root",
selectedCommand: "vite",
selectedAt: "2026-04-19T15:00:00.000Z",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
selectedScript: "dev",
selectedSource: "root",
selectedCommand: "vite",
selectedAt: "2026-04-19T15:00:00.000Z",
});
});
it("PUT /api/dev-server/config returns 400 for empty body", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const res = await request(
app,
"PUT",
"/api/dev-server/config",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
it("PUT /api/dev-server/config returns 400 for invalid previewUrlOverride", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const res = await request(
app,
"PUT",
"/api/dev-server/config",
JSON.stringify({ previewUrlOverride: "localhost:3000" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
});

View File

@@ -5,6 +5,7 @@ import os from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
DEV_SERVER_CONFIG_DEFAULTS,
DEV_SERVER_DEFAULT_STATE,
DEV_SERVER_LOG_MAX_LINES,
DevServerStore,
@@ -16,7 +17,7 @@ function createTempProject(): string {
return mkdtempSync(join(os.tmpdir(), "fn-dev-server-store-"));
}
function readPersistedState(projectDir: string): Record<string, unknown> {
function readPersistedStoreFile(projectDir: string): Record<string, unknown> {
const filePath = join(projectDir, ".fusion", "dev-server.json");
return JSON.parse(readFileSync(filePath, "utf-8")) as Record<string, unknown>;
}
@@ -92,6 +93,132 @@ describe("DevServerStore", () => {
await store.load();
expect(store.getState()).toEqual(DEV_SERVER_DEFAULT_STATE());
expect(store.getConfig()).toEqual(DEV_SERVER_CONFIG_DEFAULTS);
});
it("loading from missing file initializes with default config", async () => {
const projectDir = createTempProject();
tempDirs.push(projectDir);
const store = new DevServerStore(projectDir);
await store.load();
expect(store.getConfig()).toEqual(DEV_SERVER_CONFIG_DEFAULTS);
});
it("loading from valid JSON populates config", async () => {
const projectDir = createTempProject();
tempDirs.push(projectDir);
mkdirSync(join(projectDir, ".fusion"), { recursive: true });
writeFileSync(
join(projectDir, ".fusion", "dev-server.json"),
JSON.stringify(
{
config: {
selectedScript: "dev",
selectedSource: "apps/web",
selectedCommand: "pnpm dev",
previewUrlOverride: "http://localhost:4173",
detectedPreviewUrl: "http://localhost:3000",
selectedAt: "2026-04-19T12:00:00.000Z",
},
},
null,
2,
),
"utf-8",
);
const store = new DevServerStore(projectDir);
await store.load();
expect(store.getConfig()).toEqual({
selectedScript: "dev",
selectedSource: "apps/web",
selectedCommand: "pnpm dev",
previewUrlOverride: "http://localhost:4173",
detectedPreviewUrl: "http://localhost:3000",
selectedAt: "2026-04-19T12:00:00.000Z",
});
});
it("updateConfig merges partial updates and persists to disk", async () => {
const projectDir = createTempProject();
tempDirs.push(projectDir);
const store = new DevServerStore(projectDir);
await store.load();
const updated = await store.updateConfig({
selectedScript: "start",
selectedSource: "root",
selectedCommand: "next dev",
selectedAt: "2026-04-19T13:00:00.000Z",
});
expect(updated).toEqual({
...DEV_SERVER_CONFIG_DEFAULTS,
selectedScript: "start",
selectedSource: "root",
selectedCommand: "next dev",
selectedAt: "2026-04-19T13:00:00.000Z",
});
const persisted = readPersistedStoreFile(projectDir) as {
config: Record<string, string | null>;
};
expect(persisted.config).toMatchObject({
selectedScript: "start",
selectedSource: "root",
selectedCommand: "next dev",
selectedAt: "2026-04-19T13:00:00.000Z",
});
});
it("updateConfig overwrites previous values", async () => {
const projectDir = createTempProject();
tempDirs.push(projectDir);
const store = new DevServerStore(projectDir);
await store.load();
await store.updateConfig({ selectedScript: "dev", previewUrlOverride: "http://localhost:3000" });
const updated = await store.updateConfig({ selectedScript: "serve", previewUrlOverride: null });
expect(updated.selectedScript).toBe("serve");
expect(updated.previewUrlOverride).toBeNull();
});
it("saveConfig persists full config payload", async () => {
const projectDir = createTempProject();
tempDirs.push(projectDir);
const store = new DevServerStore(projectDir);
await store.load();
await store.saveConfig({
selectedScript: "storybook",
selectedSource: "apps/docs",
selectedCommand: "storybook dev -p 6006",
previewUrlOverride: "http://localhost:6006",
detectedPreviewUrl: "http://localhost:6006",
selectedAt: "2026-04-19T14:00:00.000Z",
});
const persisted = readPersistedStoreFile(projectDir) as {
config: Record<string, string | null>;
};
expect(persisted.config).toEqual({
selectedScript: "storybook",
selectedSource: "apps/docs",
selectedCommand: "storybook dev -p 6006",
previewUrlOverride: "http://localhost:6006",
detectedPreviewUrl: "http://localhost:6006",
selectedAt: "2026-04-19T14:00:00.000Z",
});
});
it("updateState merges partial updates and persists to disk", async () => {
@@ -116,7 +243,7 @@ describe("DevServerStore", () => {
name: "default",
});
const persisted = readPersistedState(projectDir) as { state: Record<string, unknown> };
const persisted = readPersistedStoreFile(projectDir) as { state: Record<string, unknown> };
expect(persisted.state).toMatchObject({
id: "abc",
command: "pnpm dev",
@@ -151,7 +278,7 @@ describe("DevServerStore", () => {
expect(store.getState().logHistory).toEqual(["line one", "line two"]);
const persisted = readPersistedState(projectDir) as { state: { logHistory: string[] } };
const persisted = readPersistedStoreFile(projectDir) as { state: { logHistory: string[] } };
expect(persisted.state.logHistory).toEqual(["line one", "line two"]);
});
@@ -184,7 +311,7 @@ describe("DevServerStore", () => {
expect(store.getState().logHistory).toEqual([]);
const persisted = readPersistedState(projectDir) as { state: { logHistory: string[] } };
const persisted = readPersistedStoreFile(projectDir) as { state: { logHistory: string[] } };
expect(persisted.state.logHistory).toEqual([]);
});