Files
PaperClipAI/ui/src/hooks/useCompanyMission.test.tsx
T
6542ad1f4d fix(onboarding): carry an existing company's mission into the wizard (#11416)
Ports the substance of #11259, which predated the recent onboarding work and
was still open. One of the parts it solves is a regression #11352 introduced.

A company that already has its mission opens on the agent step, which is the
point of #11352. What that missed is that the mission field is filled only by
the step being skipped, and that the field is not decoration:
`composeCeoInstructions` seeds the lead agent's instructions from it, and the
Review checklist reads it. So every Cloud-seeded company hired its lead agent
with no Mission line at all, having been routed there precisely *because* it
had a mission. Before #11352 those companies dead-ended on the mission step; a
dead end became a quiet data loss, which is worse, because it completes.

`selectExistingCompanyMission` reads the company's own goal back into the shape
the mission field holds, and the wizard hydrates from it - only when the field
is empty, so a customer editing their mission is never overwritten by the
stored copy. The marker recording that hydration travels with the field it
describes, cleared wherever `companyGoal` is.

`isExistingCompanyMissionUnresolved` holds the hire while that read is
outstanding, counting an in-flight refetch over cached goals as unresolved.
That is the rule #11382 settled on a day earlier for a different consumer -
`isFetching`, not `isLoading`, because retained data is not an answer to the
question being asked now. #11259 had it first, on 11 August.

`canGoBackFromOnboardingStep` and `canJumpToOnboardingStep` bound how far back
a run can walk by the step it entered on. The Back button already applied that
rule inline; the progress bar applied only the "already completed" half, so a
run holding a company could still jump to step 1 - the step whose job is to
create one. The entry step is captured once, when the wizard opens, for the
same reason the step itself is.

`planMissionPersistence` came with them and turned out to be required rather
than tidying. Hydration sets `createdCompanyGoalId` from the company's existing
goal, and confirming the mission read that id as "already written" and skipped
the write, discarding the customer's edit. That skip was safe only while the id
could arrive one way - by writing. A goal in hand now means update it.

Each piece was checked by removing it and confirming a specific case fails.
The hydration case asserts on `saveInstructionsFile`'s content, the actual
consumer, rather than on the mission textarea, because the entry path never
renders that field and the navigation bound now prevents reaching it. One
caveat recorded rather than smoothed over: the reopen case fails only when
both marker-clears are removed, since `reset()` also clears the company id and
the next introduction routes through `clearCompanyScopedState`. They are kept
as one invariant rather than one guard plus a coincidence.

ui typecheck clean; full ui suite 4004 pass. Two failures remain, in
IssueProperties and StatusCards/format; both are date-dependent, both
reproduce on master with these changes stashed, and neither file is touched
here.

Co-Authored-By: Jannes Stubbemann <stubbi@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:42:03 -07:00

154 lines
4.6 KiB
TypeScript

// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Goal } from "@paperclipai/shared";
import { useCompanyMission } from "./useCompanyMission";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const mockGoalsApi = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../api/goals", () => ({ goalsApi: mockGoalsApi }));
function companyGoal(id: string): Goal {
return {
id,
companyId: "company-1",
title: "Ship the thing",
description: null,
level: "company",
status: "active",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-02T00:00:00Z"),
updatedAt: new Date("2026-03-02T00:00:00Z"),
} as Goal;
}
let captured: ReturnType<typeof useCompanyMission> | null = null;
function Harness({ companyId }: { companyId: string | null }) {
captured = useCompanyMission(companyId);
return null;
}
describe("useCompanyMission", () => {
let container: HTMLDivElement;
let queryClient: QueryClient;
let root: Root | null = null;
function render(companyId: string | null) {
root = createRoot(container);
flushSync(() => {
root!.render(
<QueryClientProvider client={queryClient}>
<Harness companyId={companyId} />
</QueryClientProvider>,
);
});
}
// React Query resolves through microtasks and React schedules the re-render
// after them, so a single tick is not reliably enough under load. Drain
// until the hook reports an answer rather than guessing at a tick count.
async function settle() {
for (let i = 0; i < 50 && !captured?.settled; i++) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
beforeEach(() => {
captured = null;
container = document.createElement("div");
document.body.appendChild(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => {
flushSync(() => root?.unmount());
root = null;
queryClient.clear();
container.remove();
vi.clearAllMocks();
});
it("withholds an answer while the lookup is in flight", () => {
mockGoalsApi.list.mockReturnValue(new Promise(() => {}));
render("company-1");
expect(captured).toEqual({
hasMission: undefined,
settled: false,
mission: { goalId: null, goalInput: "" },
fetching: true,
});
});
it("reports a mission when the company has a company-level goal", async () => {
mockGoalsApi.list.mockResolvedValue([companyGoal("goal-1")]);
render("company-1");
await settle();
// The mission comes back in the shape the wizard's textarea holds, so the
// agent step can seed the lead agent's instructions from it.
expect(captured).toEqual({
hasMission: true,
settled: true,
mission: { goalId: "goal-1", goalInput: "Ship the thing" },
fetching: false,
});
});
it("reports no mission when the company has no company-level goal", async () => {
mockGoalsApi.list.mockResolvedValue([]);
render("company-1");
await settle();
expect(captured).toEqual({
hasMission: false,
settled: true,
mission: { goalId: null, goalInput: "" },
fetching: false,
});
});
it("settles with an unknown mission when the lookup fails", async () => {
// The fail-open rule. Waiting for the data itself would leave `settled`
// false forever after a request exhausts its retries, and every caller
// gates opening onboarding on it — an agentless company would then get no
// onboarding at all, which is worse than being asked for its mission
// twice.
mockGoalsApi.list.mockRejectedValue(new Error("goals unavailable"));
render("company-1");
await settle();
expect(captured).toEqual({
hasMission: undefined,
settled: true,
mission: { goalId: null, goalInput: "" },
fetching: false,
});
});
it("settles immediately when there is no company to ask about", () => {
// A disabled query stays pending forever. Reading that as "still loading"
// is the same failure as above, reached without a request.
render(null);
expect(captured).toEqual({
hasMission: undefined,
settled: true,
mission: { goalId: null, goalInput: "" },
fetching: false,
});
expect(mockGoalsApi.list).not.toHaveBeenCalled();
});
});