feat(FN-3792): add /tasks deep-link support with theme-data URL resolution

Adds a `/tasks` deep-link that rewrites legacy hash-style URLs on the client and redirects root-absolute paths on the server (FN-3792), along with a new `fusion-plugin-reports` scaffold including manifest, settings, and notification service improvements (FN-3778, FN-3790); also adds a session switch

Fusion-Task-Id: FN-3792
This commit is contained in:
Fusion
2026-05-08 23:24:04 -07:00
committed by gsxdsm
parent 7a2c0b53a2
commit 3b52721b70
8 changed files with 230 additions and 53 deletions

View File

@@ -36,7 +36,14 @@ describe("useDeepLink", () => {
beforeEach(() => {
vi.clearAllMocks();
window.history.replaceState = vi.fn();
window.history.replaceState = vi.fn((_state, _unused, url) => {
if (typeof url === "string" && url.length > 0) {
Object.defineProperty(window, "location", {
configurable: true,
value: new URL(url, "http://localhost:3000"),
});
}
}) as typeof window.history.replaceState;
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/"),
@@ -83,7 +90,51 @@ describe("useDeepLink", () => {
});
});
it("fetches and opens task detail for task deep-link", async () => {
it("rewrites /tasks/:id path and opens detail", async () => {
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/tasks/FN-9999"),
});
const { openTaskDetail } = renderUseDeepLink();
await waitFor(() => {
expect(window.history.replaceState).toHaveBeenCalledWith(expect.anything(), "", "/?task=FN-9999");
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-9999", "proj_123");
expect(openTaskDetail).toHaveBeenCalledTimes(1);
});
});
it("preserves project query when rewriting /tasks/:id path", async () => {
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/tasks/FN-9999?project=proj_456"),
});
const { setCurrentProject } = renderUseDeepLink();
await waitFor(() => {
expect(window.history.replaceState).toHaveBeenCalledWith(expect.anything(), "", "/?project=proj_456&task=FN-9999");
expect(setCurrentProject).toHaveBeenCalledWith(otherProject);
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-9999", "proj_456");
});
});
it("ignores invalid /tasks/:id path", async () => {
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/tasks/not-a-task-id"),
});
renderUseDeepLink();
await waitFor(() => {
expect(window.history.replaceState).not.toHaveBeenCalled();
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
});
});
it("fetches and opens task detail for existing ?task deep-link without path rewrite", async () => {
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/?task=FN-123"),
@@ -95,6 +146,8 @@ describe("useDeepLink", () => {
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123");
expect(openTaskDetail).toHaveBeenCalledTimes(1);
});
expect(window.history.replaceState).not.toHaveBeenCalledWith(expect.anything(), "", "/?task=FN-123");
});
it("switches project and uses project param for task fetch", async () => {

View File

@@ -649,8 +649,7 @@ describe("useTheme", () => {
}
});
it("resolves theme-data.css relative to document.baseURI for HTTP paths", () => {
// Simulate a non-root HTTP path like http://localhost:3000/some/path/
it("resolves theme-data.css to origin root for HTTP sub-paths", () => {
Object.defineProperty(document, "baseURI", {
value: "http://localhost:3000/some/path/",
configurable: true,
@@ -662,12 +661,12 @@ describe("useTheme", () => {
result.current.setColorTheme("ocean");
});
const link = document.getElementById("theme-data");
const link = document.getElementById("theme-data") as HTMLLinkElement | null;
expect(link).not.toBeNull();
// URL resolution should work with non-root base path
expect(link?.getAttribute("href")?.endsWith("theme-data.css")).toBe(true);
const resolved = new URL(link!.href);
expect(resolved.origin).toBe("http://localhost:3000");
expect(resolved.pathname).toBe("/theme-data.css");
// Clean up
Object.defineProperty(document, "baseURI", {
value: "http://localhost:3000/",
configurable: true,
@@ -779,8 +778,8 @@ describe("useTheme", () => {
// The link should exist and href should be updated to the correct value
const link = document.getElementById("theme-data") as HTMLLinkElement;
expect(link).not.toBeNull();
// href should be updated to resolve correctly for the new baseURI
expect(link?.href).toBe("http://localhost:3000/some/nested/path/theme-data.css");
// HTTP(S) always resolves to origin-root stylesheet path.
expect(link?.href).toBe("http://localhost:3000/theme-data.css");
// Clean up
link?.remove();
@@ -1210,17 +1209,42 @@ describe("getThemeInitScript", () => {
expect(script).toContain("effectiveMode");
});
it("index.html uses correct URL replacement pattern", () => {
// Verify that the inline script in index.html uses the correct URL replacement
// pattern (handle both directory paths and filename paths) rather than buggy concatenation
it("pre-hydration script resolves theme-data path like runtime loader", () => {
const script = getThemeInitScript();
const runScript = () => {
window.eval(script);
};
localStorage.setItem(COLOR_THEME_STORAGE_KEY, "ocean");
Object.defineProperty(document, "baseURI", {
value: "http://localhost:4040/tasks/FN-3773",
configurable: true,
});
runScript();
let link = document.getElementById("theme-data") as HTMLLinkElement | null;
expect(link).not.toBeNull();
expect(new URL(link!.href).origin).toBe("http://localhost:4040");
expect(new URL(link!.href).pathname).toBe("/theme-data.css");
link?.remove();
Object.defineProperty(document, "baseURI", {
value: "file:///Users/me/Projects/kb/packages/dashboard/dist/client/index.html",
configurable: true,
});
runScript();
link = document.getElementById("theme-data") as HTMLLinkElement | null;
expect(link).not.toBeNull();
expect(link!.href).toBe("file:///Users/me/Projects/kb/packages/dashboard/dist/client/theme-data.css");
});
it("index.html uses HTTP root-absolute and file-relative theme URL logic", () => {
const indexHtml = readFileSync(resolve(PACKAGE_ROOT, "app/index.html"), "utf8");
// The correct pattern: check if base ends with '/' and use slice or replace accordingly
// The buggy pattern: base.substring(0, 7) + dirPath + 'theme-data.css'
expect(indexHtml).toContain("new URL('/theme-data.css', base)");
expect(indexHtml).toContain("base.endsWith('/')");
expect(indexHtml).toContain("base.slice(0, -1)");
// Ensure the buggy pattern is NOT present
expect(indexHtml).not.toContain("base.substring(0, 7)");
expect(indexHtml).not.toContain("pathMatch");
});