feat(ri-050): RI-5-001 typed freshness states and stale-safe Mission Control surfaces (#1275) (#1300)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
This commit was merged in pull request #1300.
This commit is contained in:
@@ -35,6 +35,7 @@ afterEach(async () => {
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||
@@ -64,21 +65,49 @@ function clickButtonByText(text: string): void {
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushAct(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const projectOneTasks = taskFixtures.filter((task) => task.projectId === 'project-1');
|
||||
|
||||
function mockHealthyLoad(): void {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(projectOneTasks);
|
||||
}
|
||||
|
||||
describe('ProjectDetailPage', () => {
|
||||
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
mockHealthyLoad();
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
expect(apiMock.mock.calls).toEqual([
|
||||
['/api/projects/project-1'],
|
||||
['/api/missions'],
|
||||
['/api/tasks?projectId=project-1'],
|
||||
expect(apiMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
'/api/projects/project-1',
|
||||
'/api/missions',
|
||||
'/api/tasks?projectId=project-1',
|
||||
]);
|
||||
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.textContent).toContain('Route /projects/:id');
|
||||
expect(container.textContent).toContain('Tasks');
|
||||
@@ -101,10 +130,7 @@ describe('ProjectDetailPage', () => {
|
||||
});
|
||||
|
||||
it('opens and closes the existing read-only task modal from the tasks tab', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
mockHealthyLoad();
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
@@ -134,35 +160,153 @@ describe('ProjectDetailPage', () => {
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the project with an empty missions tab when the missions request fails', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockRejectedValueOnce(new Error('Missions request failed'))
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
it('shows verified completion verdicts when the task collection is current', async () => {
|
||||
mockHealthyLoad();
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Missions (0)');
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('No missions for this project');
|
||||
const doneCard = [...container.querySelectorAll('div')].find(
|
||||
(candidate) => candidate.textContent === 'Done1',
|
||||
);
|
||||
expect(doneCard).toBeTruthy();
|
||||
const inProgressCard = [...container.querySelectorAll('div')].find(
|
||||
(candidate) => candidate.textContent === 'In Progress1',
|
||||
);
|
||||
expect(inProgressCard).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a visible alert when the project request fails and lets the user navigate back', async () => {
|
||||
it('renders an explicit unavailable missions tab when the missions request fails (partial, not empty)', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockRejectedValueOnce(new Error('Missions request failed'))
|
||||
.mockResolvedValueOnce(projectOneTasks);
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
// Secondary failure degrades the surface to partial; the project itself
|
||||
// still renders.
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'partial',
|
||||
);
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
const partial = container.querySelector('[role="status"]');
|
||||
expect(partial?.textContent).toContain('Missions');
|
||||
expect(partial?.textContent).toContain('unavailable');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Missions (?)');
|
||||
});
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('Missions request failed');
|
||||
// Negative control: a failed fetch must not look like an empty list.
|
||||
expect(container.textContent).not.toContain('No missions for this project');
|
||||
});
|
||||
|
||||
it('marks derived verdicts unknown when the tasks collection is unavailable', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockRejectedValueOnce(new Error('Tasks request failed'));
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'partial',
|
||||
);
|
||||
|
||||
// Completion verdicts become unknown ('?') — never green counts.
|
||||
for (const label of ['Done', 'In Progress', 'Blocked', 'Tasks']) {
|
||||
const unknownCard = [...container.querySelectorAll('div')].find(
|
||||
(candidate) => candidate.textContent === `${label}?`,
|
||||
);
|
||||
expect(unknownCard, `expected ${label} card to render ?`).toBeTruthy();
|
||||
}
|
||||
// Negative control: no green "Done 1" verdict anywhere.
|
||||
expect(
|
||||
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
|
||||
).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Tasks (?)');
|
||||
});
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('Tasks request failed');
|
||||
// Negative control: no healthy empty task list from a failed fetch.
|
||||
expect(container.textContent).not.toContain('No tasks found');
|
||||
expect(container.querySelector('table')).toBeNull();
|
||||
});
|
||||
|
||||
it('recovers a partial surface to current after revalidation', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockRejectedValueOnce(new Error('Tasks request failed'))
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(projectOneTasks);
|
||||
|
||||
await renderProjectDetailPage();
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'partial',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Revalidate');
|
||||
});
|
||||
await flushAct();
|
||||
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
expect(
|
||||
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("never shows one project's data on another project's route after navigation", async () => {
|
||||
mockHealthyLoad();
|
||||
|
||||
const router = await renderProjectDetailPage();
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
|
||||
const deferred = createDeferred<(typeof projectFixtures)[number]>();
|
||||
apiMock
|
||||
.mockResolvedValueOnce(deferred.promise)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await act(async () => {
|
||||
await router.navigate('/projects/project-2');
|
||||
});
|
||||
|
||||
// While project-2 loads, nothing from project-1 may render on its route.
|
||||
expect(container.textContent).toContain('Loading project...');
|
||||
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||
expect(container.textContent).not.toContain('Route /projects/:id');
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(projectFixtures[1]!);
|
||||
await deferred.promise;
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Agent Runtime');
|
||||
expect(apiMock.mock.calls[3]?.[0]).toBe('/api/projects/project-2');
|
||||
});
|
||||
|
||||
it('renders a visible unavailable state when the project request fails and lets the user navigate back', async () => {
|
||||
apiMock
|
||||
.mockRejectedValueOnce(new Error('Project request failed'))
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
.mockResolvedValueOnce(projectOneTasks);
|
||||
|
||||
const router = await renderProjectDetailPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Project request failed');
|
||||
expect(alert?.textContent).toContain('not an empty result');
|
||||
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||
|
||||
await act(async () => {
|
||||
|
||||
Reference in New Issue
Block a user