fix(oracle-r2): same-origin baseUrl + refresh actually re-fetches
Oracle round-2 review caught two real bugs: 1. Production baseUrl bypassed the nginx /api proxy - api.ts defaulted to https://demo.flow-master.ai in prod - Browser would hit cross-origin and CORS-fail - Now: baseUrl='' everywhere; nginx.conf already reverse-proxies /api/* - vite.config.ts proxy still handles dev 2. Refresh button didn't refresh - setMode('live') early-returned when already in live mode - Now: setMode() and refreshLive() share runLiveFetch(); refreshLive ignores the same-mode guard and always re-runs - 4 new vitest regressions in state/store.test.ts cover the contract - Smoke now asserts /api/ea2/work-items is called twice after Refresh Also: - buildScenarios.ts parallelized: cap N=6 candidates, Promise.all per- candidate fetches → live mode now ~3s instead of 30s - CommandBar + LeftRail preview toasts now name the exact endpoint (/api/runtime/transactions/{id}/actions) in the visible text - Landing 'Go live' button rebound to refreshLive() when already live; copy changed to 'Live · refresh' - README: scenario table now renders (added separator row); deploy section points at the real ops PR + the actual overlay path (overlays/demo, not overlays/mc.flow-master.ai); CORS doc clarifies same-origin requirement Constraint: browsers reject cross-origin → same-origin /api/* required Rejected: dev/prod baseUrl divergence | created production bug Confidence: high Scope-risk: narrow Not-tested: production image actually built + served by ops PR (gated by trusted updater + DNS)
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
// Regression: refreshLive() must re-fetch when already in live mode,
|
||||
// and setMode("live") repeated must not early-return.
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { useApp } from "./store";
|
||||
|
||||
function stubFetch() {
|
||||
const calls: string[] = [];
|
||||
const store = new Map<string, string>();
|
||||
globalThis.sessionStorage = {
|
||||
getItem: (k) => store.get(k) ?? null,
|
||||
setItem: (k, v) => { store.set(k, String(v)); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
clear: () => store.clear(),
|
||||
key: () => null,
|
||||
length: 0,
|
||||
} as Storage;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
calls.push(url);
|
||||
if (url.endsWith("/api/v1/auth/dev-login")) {
|
||||
return new Response(JSON.stringify({ access_token: "T" }), { status: 200 });
|
||||
}
|
||||
if (url.endsWith("/api/v1/auth/me")) {
|
||||
return new Response(JSON.stringify({ user_id: "u", tenant_id: "t", email: "dev@flow-master.ai" }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/ea2/work-items")) {
|
||||
return new Response(JSON.stringify({ items: [] }), { status: 200 });
|
||||
}
|
||||
return new Response("not stubbed", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
return calls;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useApp.setState({ mode: "snapshot", liveLoading: false, liveError: null, liveFetchedAt: null });
|
||||
});
|
||||
|
||||
describe("store live-mode + refresh", () => {
|
||||
it("setMode('live') performs a network fetch", async () => {
|
||||
const calls = stubFetch();
|
||||
await useApp.getState().setMode("live");
|
||||
const meCalls = calls.filter((u) => u.endsWith("/api/v1/auth/me")).length;
|
||||
const wiCalls = calls.filter((u) => u.includes("/api/ea2/work-items")).length;
|
||||
expect(meCalls).toBeGreaterThanOrEqual(1);
|
||||
expect(wiCalls).toBeGreaterThanOrEqual(1);
|
||||
expect(useApp.getState().mode).toBe("live");
|
||||
expect(useApp.getState().liveFetchedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refreshLive() triggers a second fetch and bumps liveFetchedAt", async () => {
|
||||
const calls = stubFetch();
|
||||
await useApp.getState().setMode("live");
|
||||
const initialFetched = useApp.getState().liveFetchedAt!;
|
||||
const initialWi = calls.filter((u) => u.includes("/api/ea2/work-items")).length;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await useApp.getState().refreshLive();
|
||||
|
||||
const afterWi = calls.filter((u) => u.includes("/api/ea2/work-items")).length;
|
||||
expect(afterWi).toBeGreaterThan(initialWi);
|
||||
expect(useApp.getState().liveFetchedAt!).toBeGreaterThanOrEqual(initialFetched);
|
||||
});
|
||||
|
||||
it("refreshLive() in snapshot mode is a no-op (does NOT fetch)", async () => {
|
||||
const calls = stubFetch();
|
||||
await useApp.getState().refreshLive();
|
||||
expect(calls.length).toBe(0);
|
||||
expect(useApp.getState().mode).toBe("snapshot");
|
||||
});
|
||||
|
||||
it("setMode('snapshot') when already snapshot does not re-toast", async () => {
|
||||
stubFetch();
|
||||
const before = useApp.getState().toasts.length;
|
||||
await useApp.getState().setMode("snapshot");
|
||||
expect(useApp.getState().toasts.length).toBe(before);
|
||||
});
|
||||
});
|
||||
+41
-21
@@ -28,6 +28,8 @@ interface AppState {
|
||||
/** snapshot = bundled scenarios.json; live = in-browser API client */
|
||||
mode: DataMode;
|
||||
setMode: (m: DataMode) => Promise<void>;
|
||||
/** Force re-fetch of live scenarios. No-op in snapshot mode. */
|
||||
refreshLive: () => Promise<void>;
|
||||
|
||||
liveLoading: boolean;
|
||||
liveError: string | null;
|
||||
@@ -69,38 +71,56 @@ const initialScenario = SNAPSHOT_SCENARIOS[0];
|
||||
|
||||
let toastSeq = 0;
|
||||
|
||||
async function runLiveFetch(
|
||||
set: (partial: Partial<AppState>) => void,
|
||||
get: () => AppState,
|
||||
) {
|
||||
const prevMode = get().mode;
|
||||
set({ mode: "live", liveLoading: true, liveError: null });
|
||||
try {
|
||||
const ping = await api.ping();
|
||||
if (!ping.ok) throw new Error(ping.reason || "backend unreachable");
|
||||
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
|
||||
const merged = [...scenarios, ...syntheticScenarios];
|
||||
const first = merged[0];
|
||||
const keepCurrent = get().scenarios.some((s) => s.id === get().scenarioId);
|
||||
set({
|
||||
scenarios: merged,
|
||||
liveTotals: { workItems: workItems.length, distinctDefs },
|
||||
liveFetchedAt: Date.now(),
|
||||
liveLoading: false,
|
||||
scenarioId: keepCurrent ? get().scenarioId : first?.id ?? get().scenarioId,
|
||||
selectedStepId: keepCurrent ? get().selectedStepId : first?.defaultStepId ?? null,
|
||||
});
|
||||
get().pushToast(
|
||||
"ok",
|
||||
prevMode === "live"
|
||||
? `Refreshed · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`
|
||||
: `Live mode · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`,
|
||||
);
|
||||
} catch (e) {
|
||||
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
|
||||
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`);
|
||||
}
|
||||
}
|
||||
|
||||
export const useApp = create<AppState>((set, get) => ({
|
||||
scene: "landing",
|
||||
setScene: (scene) => set({ scene }),
|
||||
|
||||
mode: "snapshot",
|
||||
setMode: async (mode) => {
|
||||
if (mode === get().mode) return;
|
||||
if (mode === "snapshot") {
|
||||
if (get().mode === "snapshot") return;
|
||||
set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
|
||||
get().pushToast("info", "Switched to snapshot mode (bundled JSON)");
|
||||
return;
|
||||
}
|
||||
set({ mode: "live", liveLoading: true, liveError: null });
|
||||
try {
|
||||
const ping = await api.ping();
|
||||
if (!ping.ok) throw new Error(ping.reason || "backend unreachable");
|
||||
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
|
||||
const merged = [...scenarios, ...syntheticScenarios];
|
||||
const first = merged[0];
|
||||
set({
|
||||
scenarios: merged,
|
||||
liveTotals: { workItems: workItems.length, distinctDefs },
|
||||
liveFetchedAt: Date.now(),
|
||||
liveLoading: false,
|
||||
scenarioId: first?.id ?? get().scenarioId,
|
||||
selectedStepId: first?.defaultStepId ?? null,
|
||||
});
|
||||
get().pushToast("ok", `Live mode · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`);
|
||||
} catch (e) {
|
||||
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
|
||||
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`);
|
||||
}
|
||||
await runLiveFetch(set, get);
|
||||
},
|
||||
refreshLive: async () => {
|
||||
if (get().mode !== "live") return;
|
||||
await runLiveFetch(set, get);
|
||||
},
|
||||
|
||||
liveLoading: false,
|
||||
|
||||
Reference in New Issue
Block a user