fix(oracle-r2): same-origin baseUrl + refresh actually re-fetches
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

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:
2026-06-14 00:26:38 +04:00
parent 2b83e3ad0e
commit e3b4ed62c0
10 changed files with 237 additions and 81 deletions
+77
View File
@@ -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);
});
});