Files
fusion/packages/dashboard/app/utils/__tests__/appLifecycle.test.ts
gsxdsm 109204c590 fix: the query class — three sweeps that never ran on a renamed board (#2818)
Three sweeps that **never ran at all** on a renamed board, plus the
shared answer the rest of the class needs. Consolidated from three
handoff branches so the helper appears once. #2811 merged, so this is my
only open PR.

`#2800` measured this class and shipped evidence deliberately without
conversions: `listTasks({ column: "<literal>" })` filters in the store,
so on a renamed board the read returns an **empty array** and the sweep
it feeds does nothing. The census scores the comparison *inside* the
loop, never the query above it.

## What was broken

| file | census count | what actually happened on a renamed board |
|---|---|---|
| `backlog-pressure-reporter.ts` | **0** | both reads empty, ratio
computed as 0/0 — **the alert never fired**, on a board that may be
under exactly the pressure it reports |
| `stale-task-reporter.ts` | **0** | both reads empty — **no stale-task
signal ever raised**, where work is most likely sitting unnoticed |
| `restart-recovery-coordinator.ts` | flagged | sweep never ran — **an
engine restart left interrupted tasks stuck with no requeue** |

Two of the three have a census count of **zero**. They contain no
lifecycle comparison at all, so they have never appeared in the backlog,
in a per-file list, or in any "N → 0" claim — and were completely inert.
**A file at zero is not evidence of anything.**

## The shared answer, and what it is not

Every existing resolver answers a **per-task** question. A query has no
task in hand, so it needs the project-level one: every column any
workflow declares for a role, unioned with the legacy ids so a board
mid-rename still finds rows under the old ones. The set is never empty,
so a caller cannot accidentally query nothing.

The header states what it is **not**: answering a per-card question from
the union would mark a card as review because some *other* workflow
calls its column review — the flat-set mistake this program has made
four times.

## The finding that generalises: the query is rarely the whole defect

`stale-task-reporter` **still reported zero after the query was fixed**
— `getTaskAgeStalenessSignal` defaults to the legacy pair, so a card the
query now returned was refused inside the signal. Converting only the
query would have looked like a fix and changed nothing.

That is a caveat on #2800's approach, offered as refinement rather than
correction: **asserting the query ARGUMENT is right when pinning a known
defect** (the outcome is 0 either way) **and insufficient when proving a
fix**, because the outcome is the only thing that distinguishes a real
conversion from a deeper one. All three conversions here assert
outcomes.

`restart-recovery` had three layers — query, a redundant re-assertion
(deleted; a test pins the `paused` guard it did contribute), and a move
destination that was **already** resolved but whose warning comment was
stale. A stale warning is its own hazard: it told the next reader a
defect existed where none did.

## Verification

- helper **8 passed** · three reporter/coordinator suites **29 passed**
- `pnpm test:gate` **161 / 13 / 487 / 71** · lint clean · `--strict`
exits 0 · four `tsc` targets clean
- each conversion revert-proven independently; the failing case is named
in each test header

## Two mistakes worth recording

**The helper's own test caught a bug in it.** My first draft wrapped the
definition loop in one `try`, and `parseWorkflowIr` **validates** rather
than parses — one malformed row would have returned legacy-only lanes
for *every* workflow, indistinguishable from the bug it exists to fix.
Now isolated per definition.

**I clobbered the core barrel** by taking `index.ts` wholesale from a
handoff branch, dropping two exports `main` had added since; three
packages stopped compiling. Taking a file from another branch takes its
whole contents, including what is now stale — for a barrel that is
nearly always wrong. Re-applied as a single edit on top of `main`.

## Not included

`self-healing.ts`'s 49 — actively owned and mid-conversion; an outside
refactor there produces conflicting halves of one sweep.
`project-engine.ts` (7) and `executor.ts` (2) need their own read of
what each sweep does with the rows, which these three are the argument
for.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:55:10 -07:00

278 lines
8.9 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { AiSessionSummary } from "../../api";
import {
buildRemoteDashboardUrl,
shouldShowSessionInBanner,
isSessionNeedingInputForBanner,
resolveDesktopShellRedirectTarget,
executeCliSessionBannerAction,
} from "../appLifecycle";
function makeSession(overrides: Partial<AiSessionSummary> & Pick<AiSessionSummary, "id">): AiSessionSummary {
return {
id: overrides.id,
type: overrides.type ?? "planning",
status: overrides.status ?? "generating",
title: overrides.title ?? overrides.id,
projectId: overrides.projectId ?? null,
updatedAt: overrides.updatedAt ?? "2026-04-08T00:00:00.000Z",
};
}
/*
FNXC:SessionBanner 2026-07-16-20:55:
FN-8229 replaces the footer AI pill with the banner for non-planning progress
and actionable states. Planning remains visible only through its docked view
and navigation badge, including retained error sessions.
*/
describe("shouldShowSessionInBanner", () => {
it("includes non-planning generating, needs-input, and error sessions", () => {
for (const status of ["generating", "awaiting_input", "error"] as const) {
expect(shouldShowSessionInBanner(makeSession({ id: status, type: "subtask", status }))).toBe(true);
}
});
it("excludes planning sessions at every status", () => {
for (const status of ["generating", "awaiting_input", "error"] as const) {
expect(shouldShowSessionInBanner(makeSession({ id: `planning-${status}`, type: "planning", status }))).toBe(false);
}
});
it("keeps the needs-input predicate separate from generating progress", () => {
expect(isSessionNeedingInputForBanner(makeSession({ id: "generating", type: "subtask", status: "generating" }))).toBe(false);
});
});
describe("resolveDesktopShellRedirectTarget", () => {
const remoteProfile = {
id: "remote-1",
serverUrl: "https://fusionstudio:4040",
authToken: "tok-123",
};
it("returns null for non-desktop-shell hosts", () => {
expect(
resolveDesktopShellRedirectTarget(
{
host: "web",
desktopMode: "local",
activeProfileId: null,
profiles: [],
localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123" },
},
"https://fusionstudio:4040/",
),
).toBeNull();
expect(
resolveDesktopShellRedirectTarget(
{
host: "mobile-shell",
desktopMode: "local",
activeProfileId: null,
profiles: [],
localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123" },
},
"https://fusionstudio:4040/",
),
).toBeNull();
});
it("returns null when desktopMode is undefined", () => {
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
activeProfileId: null,
profiles: [],
},
"https://fusionstudio:4040/",
),
).toBeNull();
});
it("resolves the local runtime origin (baseUrl) when switching remote -> local", () => {
const target = resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "local",
activeProfileId: "remote-1",
profiles: [remoteProfile],
localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123", port: 50123 },
},
"https://fusionstudio:4040/",
);
expect(target).toBe("http://127.0.0.1:50123");
});
it("falls back to localhost:<port> when localRuntime has no baseUrl", () => {
const target = resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "local",
activeProfileId: null,
profiles: [],
localRuntime: { state: "running", port: 50123 },
},
"https://fusionstudio:4040/",
);
expect(target).toBe("http://localhost:50123");
});
it("returns null when the local runtime is not running", () => {
for (const state of ["stopped", "starting", "error"] as const) {
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "local",
activeProfileId: null,
profiles: [],
localRuntime: { state, baseUrl: "http://127.0.0.1:50123" },
},
"https://fusionstudio:4040/",
),
).toBeNull();
}
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "local",
activeProfileId: null,
profiles: [],
localRuntime: undefined,
},
"https://fusionstudio:4040/",
),
).toBeNull();
});
it("returns null when already on the local runtime origin", () => {
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "local",
activeProfileId: null,
profiles: [],
localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123" },
},
"http://127.0.0.1:50123/",
),
).toBeNull();
});
it("resolves buildRemoteDashboardUrl(...) when switching local -> a remote profile", () => {
const target = resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "remote",
activeProfileId: "remote-1",
profiles: [remoteProfile],
localRuntime: { state: "stopped" },
},
"http://127.0.0.1:50123/",
);
expect(target).toBe(buildRemoteDashboardUrl(remoteProfile.serverUrl, remoteProfile.authToken));
});
it("returns null when already on the target remote url", () => {
const nextUrl = buildRemoteDashboardUrl(remoteProfile.serverUrl, remoteProfile.authToken);
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "remote",
activeProfileId: "remote-1",
profiles: [remoteProfile],
},
nextUrl,
),
).toBeNull();
});
it("returns null when there is no matching/active profile", () => {
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "remote",
activeProfileId: null,
profiles: [remoteProfile],
},
"http://127.0.0.1:50123/",
),
).toBeNull();
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "remote",
activeProfileId: "missing",
profiles: [remoteProfile],
},
"http://127.0.0.1:50123/",
),
).toBeNull();
});
});
/*
FNXC:WorkflowLifecycleColumns 2026-08-01-02:10:
THE INVARIANT: the CLI-session banner's Cancel returns the card to ITS OWN hold lane.
CENSUS-INVISIBLE IN TWO WAYS AT ONCE — the literal lived in a call argument AND in the dep's TYPE
(`moveTask: (id: string, column: "todo")`), so the signature itself prevented any caller from passing
anything else. No scan for comparisons could reach either.
Post-U12 the rejection in `moves.ts` is live: a move to a column the workflow does not declare throws
"Unknown column for this workflow" unless the caller sets `recoveryRehome`, which this is not. So on a
renamed board **Cancel threw instead of cancelling** — an operator-facing button that fails.
WIRED, NOT OPTIONAL. `App.tsx` supplies `resolveCancelColumn` from the board-workflow metadata it
already holds. An optional parameter no caller fills is the inert shape this program has found five
times; adding a sixth to fix a broken button would have been worse than leaving it.
REVERT PROOF, measured: restore the hardcoded `"todo"` and the renamed case moves to `todo` instead of
the board's own hold lane.
*/
describe("CLI banner cancel resolves the board's own hold lane", () => {
const baseDeps = () => ({
retryTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
openAuthenticationSettings: vi.fn(),
addToast: vi.fn(),
});
it("moves to the RENAMED hold lane when the caller resolves one", async () => {
const deps = { ...baseDeps(), resolveCancelColumn: () => "backlog" };
await executeCliSessionBannerAction({ id: "FN-1" } as never, "cancel", deps as never);
expect(deps.moveTask).toHaveBeenCalledWith("FN-1", "backlog");
});
it("keeps the legacy destination when metadata has not resolved", async () => {
// Board-workflow metadata is absent on first paint and for remote projects; the documented
// fallback must stay exactly today's behaviour rather than refusing to cancel.
const deps = { ...baseDeps(), resolveCancelColumn: () => undefined };
await executeCliSessionBannerAction({ id: "FN-2" } as never, "cancel", deps as never);
expect(deps.moveTask).toHaveBeenCalledWith("FN-2", "todo");
});
it("keeps the legacy destination when no resolver is supplied at all", async () => {
const deps = baseDeps();
await executeCliSessionBannerAction({ id: "FN-3" } as never, "cancel", deps as never);
expect(deps.moveTask).toHaveBeenCalledWith("FN-3", "todo");
});
});