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:
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -40,10 +40,28 @@ export function useDeepLink(options: UseDeepLinkOptions): UseDeepLinkResult {
|
||||
// Prevent duplicate fetches when project switching causes the effect to re-run.
|
||||
const deepLinkFetchedRef = useRef(false);
|
||||
|
||||
// Guard against StrictMode double-effect path rewrites.
|
||||
const pathRewroteRef = useRef(false);
|
||||
|
||||
// Track whether the currently open detail modal came from a deep-link.
|
||||
const deepLinkTaskIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathRewroteRef.current) {
|
||||
const pathMatch = window.location.pathname.match(/^\/tasks\/([A-Z]+-\d+)\/?$/);
|
||||
if (pathMatch) {
|
||||
const taskIdFromPath = pathMatch[1];
|
||||
if (/^[A-Z]+-\d+$/.test(taskIdFromPath)) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("task", taskIdFromPath);
|
||||
const query = params.toString();
|
||||
const existingState = window.history.state ?? {};
|
||||
window.history.replaceState(existingState, "", query ? `/?${query}` : "/");
|
||||
pathRewroteRef.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const projectParam = params.get("project");
|
||||
const taskId = params.get("task");
|
||||
|
||||
@@ -15,35 +15,28 @@ const THEME_DATA_FILENAME = "theme-data.css";
|
||||
/**
|
||||
* Get the resolved URL for theme-data.css.
|
||||
*
|
||||
* This function handles both HTTP/HTTPS origins and Electron file:// contexts.
|
||||
* Using document.baseURI ensures the stylesheet path resolves correctly regardless
|
||||
* of whether the app is served over HTTP or loaded from a file:// URL.
|
||||
*
|
||||
* For file:// URLs, the path is derived relative to the HTML file's directory.
|
||||
* For HTTP/HTTPS URLs, the path is derived relative to the HTML file's directory
|
||||
* (same as file://) to ensure correct resolution in nested deployments.
|
||||
* NOTE: index.html contains an inline pre-hydration copy of this logic.
|
||||
* Keep both implementations behaviorally equivalent.
|
||||
*/
|
||||
function getThemeDataUrl(): string {
|
||||
// Get base URL from document.baseURI (most reliable across contexts)
|
||||
// Falls back to document.location.href if baseURI is unavailable
|
||||
const base = document.baseURI || (typeof document.location !== "undefined" ? document.location.href : "");
|
||||
|
||||
if (!base) {
|
||||
// Fallback to absolute path if no base available
|
||||
return `/${THEME_DATA_FILENAME}`;
|
||||
}
|
||||
|
||||
// Derive path relative to HTML file directory
|
||||
// Handle two cases:
|
||||
// 1. Base ends with "/" (directory path): replace trailing "/" with "/filename"
|
||||
// 2. Base ends with filename: replace filename with "/filename"
|
||||
if (base.endsWith("/")) {
|
||||
// Directory path: replace trailing "/" with "/theme-data.css"
|
||||
return base.slice(0, -1) + `/${THEME_DATA_FILENAME}`;
|
||||
} else {
|
||||
// Filename path: replace last segment with "/theme-data.css"
|
||||
if (base.startsWith("http://") || base.startsWith("https://")) {
|
||||
return new URL(`/${THEME_DATA_FILENAME}`, base).toString();
|
||||
}
|
||||
|
||||
if (base.startsWith("file://")) {
|
||||
if (base.endsWith("/")) {
|
||||
return base.slice(0, -1) + `/${THEME_DATA_FILENAME}`;
|
||||
}
|
||||
return base.replace(/\/[^/]+$/, `/${THEME_DATA_FILENAME}`);
|
||||
}
|
||||
|
||||
return `/${THEME_DATA_FILENAME}`;
|
||||
}
|
||||
|
||||
// Check if we're in a browser environment
|
||||
@@ -430,6 +423,42 @@ export function getThemeInitScript(): string {
|
||||
document.documentElement.setAttribute('data-theme', effectiveMode);
|
||||
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
||||
document.documentElement.style.fontSize = fontScale + '%';
|
||||
if (colorTheme !== 'default') {
|
||||
var base = document.baseURI || (document.location && document.location.href) || '';
|
||||
var themeDataUrl;
|
||||
if (!base) {
|
||||
themeDataUrl = '/theme-data.css';
|
||||
} else if (base.indexOf('http://') === 0 || base.indexOf('https://') === 0) {
|
||||
themeDataUrl = new URL('/theme-data.css', base).toString();
|
||||
} else if (base.indexOf('file://') === 0) {
|
||||
if (base.endsWith('/')) {
|
||||
themeDataUrl = base.slice(0, -1) + '/theme-data.css';
|
||||
} else {
|
||||
var lastSlashIndex = base.lastIndexOf('/');
|
||||
themeDataUrl = lastSlashIndex >= 0
|
||||
? base.slice(0, lastSlashIndex) + '/theme-data.css'
|
||||
: '/theme-data.css';
|
||||
}
|
||||
} else {
|
||||
themeDataUrl = '/theme-data.css';
|
||||
}
|
||||
|
||||
var existingLink = document.getElementById('theme-data');
|
||||
if (existingLink && existingLink.tagName === 'LINK') {
|
||||
if (existingLink.href !== themeDataUrl) {
|
||||
existingLink.href = themeDataUrl;
|
||||
}
|
||||
if (existingLink.parentNode === document.head && document.head.lastChild !== existingLink) {
|
||||
document.head.appendChild(existingLink);
|
||||
}
|
||||
} else {
|
||||
var link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = themeDataUrl;
|
||||
link.id = 'theme-data';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
document.documentElement.setAttribute('data-color-theme', 'default');
|
||||
|
||||
@@ -39,28 +39,20 @@
|
||||
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
||||
document.documentElement.style.fontSize = fontScale + '%';
|
||||
// Load theme-data.css for non-default themes to prevent flash
|
||||
// Use path-safe resolution that works in both HTTP and file:// contexts
|
||||
// This logic mirrors app/hooks/useTheme.ts#getThemeDataUrl()
|
||||
if (colorTheme !== 'default') {
|
||||
var base = document.baseURI || (document.location && document.location.href) || '';
|
||||
// Derive path relative to HTML file directory
|
||||
// Handle two cases:
|
||||
// 1. Base ends with "/" (directory path): replace trailing "/" with "/filename"
|
||||
// 2. Base ends with filename: replace filename with "/filename"
|
||||
var themeDataUrl;
|
||||
if (base.indexOf('file://') === 0) {
|
||||
// For file:// URLs
|
||||
if (base.endsWith('/')) {
|
||||
themeDataUrl = base.slice(0, -1) + '/theme-data.css';
|
||||
} else {
|
||||
themeDataUrl = base.replace(/\/[^\/]+$/, '/theme-data.css');
|
||||
}
|
||||
if (!base) {
|
||||
themeDataUrl = '/theme-data.css';
|
||||
} else if (base.indexOf('http://') === 0 || base.indexOf('https://') === 0) {
|
||||
themeDataUrl = new URL('/theme-data.css', base).toString();
|
||||
} else if (base.indexOf('file://') === 0) {
|
||||
themeDataUrl = base.endsWith('/')
|
||||
? base.slice(0, -1) + '/theme-data.css'
|
||||
: base.replace(/\/[^\/]+$/, '/theme-data.css');
|
||||
} else {
|
||||
// For HTTP/HTTPS URLs - same logic
|
||||
if (base.endsWith('/')) {
|
||||
themeDataUrl = base.slice(0, -1) + '/theme-data.css';
|
||||
} else {
|
||||
themeDataUrl = base.replace(/\/[^\/]+$/, '/theme-data.css');
|
||||
}
|
||||
themeDataUrl = '/theme-data.css';
|
||||
}
|
||||
|
||||
// Check for existing link and update href if stale
|
||||
|
||||
@@ -446,6 +446,45 @@ describe("API Error Handling Middleware", () => {
|
||||
expect(res.body).not.toContain("<html");
|
||||
}
|
||||
});
|
||||
|
||||
it("redirects /tasks/:id to canonical ?task query", async () => {
|
||||
const app = createServer(store);
|
||||
const res = await GET(app, "/tasks/FN-9999");
|
||||
|
||||
expect(res.status).toBe(301);
|
||||
expect(res.headers.location).toBe("/?task=FN-9999");
|
||||
});
|
||||
|
||||
it("preserves project query when redirecting /tasks/:id", async () => {
|
||||
const app = createServer(store);
|
||||
const res = await GET(app, "/tasks/FN-9999?project=demo");
|
||||
|
||||
expect(res.status).toBe(301);
|
||||
expect(res.headers.location).toBe("/?task=FN-9999&project=demo");
|
||||
});
|
||||
|
||||
it("does not redirect invalid /tasks/:id", async () => {
|
||||
const app = createServer(store, { headless: true });
|
||||
const res = await GET(app, "/tasks/not-a-task");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers.location).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps canonical query deep-link behavior unchanged", async () => {
|
||||
const previousClientDir = process.env.FUSION_CLIENT_DIR;
|
||||
process.env.FUSION_CLIENT_DIR = join(__dirname, "..", "..", "app");
|
||||
try {
|
||||
const app = createServer(store);
|
||||
const res = await GET(app, "/?task=FN-9999");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body).toBe("string");
|
||||
expect(res.body).toContain("<div id=\"root\"></div>");
|
||||
} finally {
|
||||
process.env.FUSION_CLIENT_DIR = previousClientDir;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("planning API route content types", () => {
|
||||
|
||||
@@ -1183,6 +1183,23 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
});
|
||||
|
||||
if (!isHeadless) {
|
||||
app.get("/tasks/:id", (req, res, next) => {
|
||||
const taskId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
if (!taskId || !/^[A-Z]+-\d+$/.test(taskId)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("task", taskId);
|
||||
const project = typeof req.query.project === "string" ? req.query.project : undefined;
|
||||
if (project) {
|
||||
params.set("project", project);
|
||||
}
|
||||
|
||||
res.redirect(301, `/?${params.toString()}`);
|
||||
});
|
||||
|
||||
// SPA fallback. Only serve index.html for navigation requests — never for
|
||||
// hashed asset URLs (/assets/*, /icons/*, /fonts/*) or any path that looks
|
||||
// like a static file. Returning index.html for a missing JS chunk poisons
|
||||
|
||||
Reference in New Issue
Block a user