Files
multica/apps/web/proxy.test.ts
T
6ec162bd33 feat(web): make the mobile web app installable (MUL-6237) (#7025)
* feat(web): make the mobile web app installable (MUL-6237)

Adds the first tier of PWA support: the app can be added to a phone's
home screen and runs in its own window instead of a browser tab.

- app/manifest.ts, served at /manifest.webmanifest. start_url is /inbox
  rather than /, because the official marketing hosts keep the root path
  on the public site even for a signed-in session, so an installed app
  pointed at / would open the landing page. /inbox is a legacy segment
  that proxy.ts already resolves per session: the last workspace when
  signed in, /login when not. manifest.test.ts runs start_url and both
  launcher shortcuts through the real proxy so that coupling cannot rot.
- Home-screen icons generated from the existing brand marks, so an
  installed web app matches the desktop app. The maskable one is
  full-bleed with the mark at 52% to stay inside the safe zone.
- public/sw.js, which caches nothing. Chrome only offers to install a
  site whose worker has a fetch handler, and landing skipWaiting +
  clients.claim + a cache sweep now means a later caching change adds a
  strategy instead of retrofitting a safe upgrade path. Registration is
  skipped in development, where a worker only ever serves stale bundles.

Offline support and web push are deliberately out of scope.

Co-authored-by: multica-agent <[email protected]>

* fix(web): resolve the PWA launch path for a session with no workspace (MUL-6237)

Review found the installed app could open the marketing site. `start_url`
is /inbox, a legacy segment, and proxy.ts bounced a request carrying
`multica_logged_in` but no `last_workspace_slug` to "/" — which the
official marketing hosts deliberately keep on the public site even with a
session. The bounce therefore dead-ended on the landing page, and for a
launcher icon there is no URL bar to recover with.

Reachable whenever the session cookie outlives the slug cookie: a member
who signed up but has not opened a workspace yet, or cleared cookies.

Send that state to /login instead, which already resolves an
authenticated visitor against their workspace list, including pending
invitations and the no-workspace-yet case. The deep-link path is dropped
rather than passed as `next`, because a legacy segment fed back through
`next` would return to this branch and loop. This also fixes the same
dead end for plain legacy bookmarks on the marketing hosts.

Also drops the service worker. It only ever existed to satisfy Chrome's
installability check, and that check no longer needs one: menu install
dropped the fetch-handler requirement in Chrome 108 (Android) / 112
(desktop). Only the automatic install prompt still requires a handler,
which this app has no custom UI for anyway — so the worker bought a
prompt heuristic in exchange for an empty fetch handler Chrome's own
guidance warns against, plus a sticky registration needing an unregister
path. Android installs from the menu, iOS from the share sheet. The
worker comes back with tier 2, when there is something real to cache.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-16 14:37:08 +08:00

248 lines
7.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { NextRequest } from "next/server";
import { MULTICA_LOCALE_HEADER } from "./lib/locale-routing";
import { proxy } from "./proxy";
function makeRequest(
path: string,
cookies: Record<string, string> = {},
host = "app.multica.test",
) {
const cookieHeader = Object.entries(cookies)
.map(([key, value]) => `${key}=${value}`)
.join("; ");
return new NextRequest(`https://${host}${path}`, {
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
});
}
function redirectLocation(
path: string,
cookies: Record<string, string> = {},
host?: string,
) {
return proxy(makeRequest(path, cookies, host)).headers.get("location");
}
function restoreEnv(key: string, value: string | undefined) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
function withoutRuntimeUpstreams(run: () => void) {
const previousRemoteApiUrl = process.env.REMOTE_API_URL;
const previousDocsUrl = process.env.DOCS_URL;
const previousPublicApiUrl = process.env.NEXT_PUBLIC_API_URL;
const previousPort = process.env.PORT;
delete process.env.REMOTE_API_URL;
delete process.env.DOCS_URL;
delete process.env.NEXT_PUBLIC_API_URL;
process.env.PORT = "3000";
try {
run();
} finally {
restoreEnv("REMOTE_API_URL", previousRemoteApiUrl);
restoreEnv("DOCS_URL", previousDocsUrl);
restoreEnv("NEXT_PUBLIC_API_URL", previousPublicApiUrl);
restoreEnv("PORT", previousPort);
}
}
describe("proxy legacy workspace route redirects", () => {
const sessionCookies = {
multica_logged_in: "1",
last_workspace_slug: "acme",
};
it.each([
["issues", "/acme/issues"],
["projects", "/acme/projects"],
["agents", "/acme/agents"],
["squads", "/acme/squads"],
["inbox", "/acme/inbox"],
["my-issues", "/acme/my-issues"],
["autopilots", "/acme/autopilots"],
["runtimes", "/acme/runtimes"],
["skills", "/acme/skills"],
["settings", "/acme/settings"],
["usage", "/acme/usage"],
])(
"redirects legacy /%s URLs through the last workspace slug",
(segment, expectedPath) => {
expect(redirectLocation(`/${segment}?tab=all`, sessionCookies)).toBe(
`https://app.multica.test${expectedPath}?tab=all`,
);
},
);
it("preserves nested legacy paths and query strings", () => {
expect(
redirectLocation("/squads/squad-123?view=members", sessionCookies),
).toBe("https://app.multica.test/acme/squads/squad-123?view=members");
});
it("sends logged-out legacy URLs to login", () => {
expect(redirectLocation("/usage?tab=billing")).toBe(
"https://app.multica.test/login?tab=billing",
);
});
it("sends logged-in legacy URLs without a last workspace cookie to login", () => {
// Not root: the root-path rule below leaves "/" on the public site for the
// official marketing hosts even with a session, so bouncing there would
// dead-end on the landing page. /login resolves against the workspace list
// instead. The deep-link query is dropped because feeding a legacy path
// back through `next` would return here and loop.
expect(
redirectLocation("/squads?view=members", { multica_logged_in: "1" }),
).toBe("https://app.multica.test/login");
});
it.each(["multica.ai", "www.multica.ai"])(
"resolves a slugless session off the marketing host %s instead of stranding it",
(host) => {
expect(
redirectLocation("/inbox", { multica_logged_in: "1" }, host),
).toBe(`https://${host}/login`);
},
);
it("does not redirect workspace-scoped URLs whose first segment is already a slug", () => {
expect(redirectLocation("/acme/squads", sessionCookies)).toBeNull();
});
it("redirects app-host root URLs to the last workspace", () => {
expect(redirectLocation("/", sessionCookies)).toBe(
"https://app.multica.test/acme/issues",
);
});
it.each(["multica.ai", "www.multica.ai"])(
"does not redirect public marketing root on %s",
(host) => {
expect(redirectLocation("/", sessionCookies, host)).toBeNull();
},
);
it("still redirects explicit legacy app routes on the public marketing host", () => {
expect(redirectLocation("/issues/ABC-123", sessionCookies, "multica.ai")).toBe(
"https://multica.ai/acme/issues/ABC-123",
);
});
});
describe("proxy runtime upstream rewrites", () => {
it("does not rewrite API requests when no runtime API origin is configured", () => {
withoutRuntimeUpstreams(() => {
const res = proxy(makeRequest("/api/config?x=1"));
expect(res.status).toBe(200);
expect(res.headers.get("x-middleware-rewrite")).toBeNull();
expect(
res.headers.get(`x-middleware-request-${MULTICA_LOCALE_HEADER}`),
).toBe("en");
});
});
it("does not rewrite docs requests when no runtime docs origin is configured", () => {
withoutRuntimeUpstreams(() => {
const res = proxy(makeRequest("/docs/zh"));
expect(res.status).toBe(200);
expect(res.headers.get("x-middleware-rewrite")).toBeNull();
expect(
res.headers.get(`x-middleware-request-${MULTICA_LOCALE_HEADER}`),
).toBe("en");
});
});
it("rewrites API requests to the runtime API origin", () => {
const previous = process.env.REMOTE_API_URL;
process.env.REMOTE_API_URL = "http://backend:8080";
try {
const res = proxy(makeRequest("/api/config?x=1"));
expect(res.status).toBe(200);
expect(res.headers.get("x-middleware-rewrite")).toBe(
"http://backend:8080/api/config?x=1",
);
} finally {
restoreEnv("REMOTE_API_URL", previous);
}
});
it("rewrites docs requests to the runtime docs origin", () => {
const previous = process.env.DOCS_URL;
process.env.DOCS_URL = "http://docs:4000";
try {
const res = proxy(makeRequest("/docs/zh/agents"));
expect(res.status).toBe(200);
expect(res.headers.get("x-middleware-rewrite")).toBe(
"http://docs:4000/docs/zh/agents",
);
} finally {
restoreEnv("DOCS_URL", previous);
}
});
it("rewrites websocket requests to the runtime API origin", () => {
const previous = process.env.REMOTE_API_URL;
process.env.REMOTE_API_URL = "http://backend:8080";
try {
const res = proxy(makeRequest("/ws"));
expect(res.status).toBe(200);
expect(res.headers.get("x-middleware-rewrite")).toBe(
"http://backend:8080/ws",
);
} finally {
restoreEnv("REMOTE_API_URL", previous);
}
});
it("does not rewrite frontend auth callback pages", () => {
const previous = process.env.REMOTE_API_URL;
process.env.REMOTE_API_URL = "http://backend:8080";
try {
const res = proxy(makeRequest("/auth/callback"));
expect(res.status).toBe(200);
expect(res.headers.get("x-middleware-rewrite")).toBeNull();
expect(
res.headers.get(`x-middleware-request-${MULTICA_LOCALE_HEADER}`),
).toBe("en");
} finally {
restoreEnv("REMOTE_API_URL", previous);
}
});
});
describe("proxy root and locale handling", () => {
it("redirects logged-in root visits to the last workspace", () => {
const res = proxy(
makeRequest("/", {
multica_logged_in: "1",
last_workspace_slug: "acme",
}),
);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toBe(
"https://app.multica.test/acme/issues",
);
});
it("forwards locale on login requests", () => {
const res = proxy(makeRequest("/login", { "multica-locale": "zh-Hans" }));
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
expect(
res.headers.get(`x-middleware-request-${MULTICA_LOCALE_HEADER}`),
).toBe("zh-Hans");
});
});