Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions apps/azure/e2e/full-lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { test, expect, type Page } from '@playwright/test';

const DB_NAME = 'VaRiScoutAzure';
const NOW = 1_777_161_600_000; // 2026-04-26T00:00:00.000Z

async function waitForAppReady(page: Page) {
await page.goto('/');
await expect(
page
.locator('text=Start Your Analysis')
.or(page.locator('[data-testid="project-dashboard"]'))
.or(page.locator('text=Process Hubs').first())
).toBeVisible({ timeout: 15000 });
}

async function seedLifecycleHub(page: Page) {
await waitForAppReady(page);

await page.evaluate(
({ dbName, now }) =>
new Promise<void>((resolve, reject) => {
const openReq = indexedDB.open(dbName);
openReq.onerror = () => reject(new Error(`IDB open failed: ${openReq.error?.message}`));
openReq.onsuccess = () => {
const db = openReq.result;
const hubId = 'lifecycle-hub';
const investigationId = 'lifecycle-investigation';
const projectId = 'lifecycle-ip';
const sustainmentId = 'lifecycle-sustainment';

try {
const tx = db.transaction(
[
'projects',
'processHubs',
'improvementProjects',
'actionItems',
'sustainmentRecords',
],
'readwrite'
);

tx.objectStore('projects').put({
name: 'Lifecycle Demo Hub',
location: 'personal',
modified: new Date('2026-05-01T00:00:00.000Z'),
synced: false,
data: {},
});

tx.objectStore('processHubs').put({
id: hubId,
name: 'Lifecycle Demo Hub',
processGoal: 'Demonstrate the full response path lifecycle.',
outcomes: [
{
id: 'outcome-1',
hubId,
columnName: 'fill_weight',
characteristicType: 'nominalIsBest',
createdAt: now,
deletedAt: null,
},
],
investigations: [
{
id: investigationId,
name: 'Fill weight lifecycle',
createdAt: now,
updatedAt: now,
deletedAt: null,
metadata: {
processHubId: hubId,
investigationStatus: 'controlled',
findingCounts: {},
questionCounts: {},
actionCounts: { total: 1, completed: 1, overdue: 0 },
},
},
],
createdAt: now,
updatedAt: now,
deletedAt: null,
});

tx.objectStore('improvementProjects').put({
id: projectId,
hubId,
status: 'closed',
metadata: { title: 'Reduce fill-weight drift', investigationId },
goal: {
outcomeGoal: { outcomeSpecId: 'outcome-1', target: 1.33 },
freeText: 'Hold Cpk above 1.33.',
},
sections: {
background: {},
investigationLineage: {},
approach: { actionItemIds: ['action-1'] },
outcomeReference: { sustainmentRecordId: sustainmentId },
},
createdAt: now,
updatedAt: now,
deletedAt: null,
});

tx.objectStore('actionItems').put({
id: 'action-1',
hubId,
text: 'Lock revised fill-weight standard work',
stepId: 'step-1',
status: 'done',
completedAt: '2026-04-20T00:00:00.000Z',
parentImprovementProjectId: projectId,
parentImprovementIdeaId: null,
createdAt: now,
deletedAt: null,
});

tx.objectStore('sustainmentRecords').put({
id: sustainmentId,
investigationId,
hubId,
status: 'confirmed-sustained',
title: 'Hold fill-weight gain',
improvementProjectId: projectId,
targetSummary: 'Cpk stays above 1.33.',
consecutiveOnTargetTicks: 4,
hasOverride: false,
lastEvaluatedSnapshotId: 'snapshot-1',
cadence: 'weekly',
latestVerdict: 'holding',
latestReviewAt: '2026-03-01T00:00:00.000Z',
createdAt: now - 70 * 24 * 60 * 60 * 1000,
updatedAt: now - 60 * 24 * 60 * 60 * 1000,
deletedAt: null,
});

tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(new Error(`IDB write failed: ${tx.error?.message}`));
};
} catch (error) {
db.close();
reject(error);
}
};
}),
{ dbName: DB_NAME, now: NOW }
);

await page.reload();
await expect(page.locator('text=Process Hubs').first()).toBeVisible({ timeout: 15000 });
}

test.describe('Azure full response-path lifecycle', () => {
test('opens a confirmed sustainment handoff prompt and advances handoff controls', async ({
page,
}) => {
await seedLifecycleHub(page);

await page.getByRole('button', { name: 'Open Lifecycle Demo Hub' }).click();

await expect(page.getByText(/Hold fill-weight gain confirmed sustained/i)).toBeVisible({
timeout: 10000,
});
await page.getByRole('button', { name: 'Record control handoff' }).click();

await expect(page.getByRole('heading', { name: 'Handoff' })).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Hold fill-weight gain')).toBeVisible();

await page.getByLabel('System name').fill('QMS-42');
await page.getByLabel('Process owner').fill('Process owner');
await page.getByLabel('Escalation path').fill('Escalate drift to production manager');
await page
.getByRole('textbox', { name: 'Reaction plan' })
.fill('Restore standard work before next shift');
await page.getByRole('button', { name: 'Acknowledge handoff' }).click();
await page.getByRole('button', { name: 'Mark operational' }).click();

await expect(page.getByText('Status operational')).toBeVisible({ timeout: 5000 });
await expect(
page
.getByRole('button', { name: 'Run sponsor signoff' })
.or(page.getByRole('button', { name: 'Sponsor signoff locked' }))
).toBeVisible();
});
});
15 changes: 12 additions & 3 deletions apps/azure/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ function AppContent({
// When true, Editor mounts directly into PasteScreen (used by "Add framing" CTA).
// Reset to false once consumed so subsequent navigations don't re-trigger paste.
const [pendingStartPaste, setPendingStartPaste] = useState(false);
const [pendingHandoffTargetId, setPendingHandoffTargetId] = useState<string | null>(null);

// Resolve deep link from URL params
const deepLink = useMemo<DeepLinkParams>(() => {
Expand Down Expand Up @@ -247,16 +248,23 @@ function AppContent({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const navigateToEditor = (projectId?: string, processHubId?: string, startPaste?: boolean) => {
const navigateToEditor = (
projectId?: string,
processHubId?: string,
startPaste?: boolean,
handoffTargetId?: string
) => {
setCurrentProject(projectId || null);
setPendingProcessHubId(processHubId || null);
if (startPaste) setPendingStartPaste(true);
setPendingHandoffTargetId(handoffTargetId || null);
setCurrentView('editor');
};

const navigateToDashboard = () => {
setCurrentProject(null);
setPendingStartPaste(false);
setPendingHandoffTargetId(null);
setCurrentView('dashboard');
};

Expand Down Expand Up @@ -292,8 +300,8 @@ function AppContent({
)}
{currentView === 'dashboard' && !deepLinkError && (
<ProjectDashboard
onOpenProject={(id, processHubId, startPaste) =>
navigateToEditor(id, processHubId, startPaste)
onOpenProject={(id, processHubId, startPaste, handoffTargetId) =>
navigateToEditor(id, processHubId, startPaste, handoffTargetId)
}
onLoadSample={handleLoadSample}
/>
Expand All @@ -303,6 +311,7 @@ function AppContent({
projectId={currentProject}
onBack={navigateToDashboard}
initialProcessHubId={currentProject ? undefined : (pendingProcessHubId ?? undefined)}
initialHandoffTargetId={pendingHandoffTargetId ?? undefined}
initialSample={pendingSample}
startPasteOnMount={pendingStartPaste}
onOpenSettings={() => setIsSettingsOpen(true)}
Expand Down
1 change: 1 addition & 0 deletions apps/azure/src/components/ControlHandoffEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const ControlHandoffEditor: React.FC<ControlHandoffEditorProps> = ({
id: existingHandoff?.id ?? crypto.randomUUID(),
investigationId,
hubId,
status: existingHandoff?.status ?? 'pending',
surface,
systemName,
// operationalOwner is the person operating the control, NOT the submitter.
Expand Down
6 changes: 6 additions & 0 deletions apps/azure/src/components/ProcessHubReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,14 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
improvementProjects: rollup.hub.improvementProjects ?? [],
sustainmentRecords: rollup.sustainmentRecords,
sustainmentReviews: rollup.hub.sustainmentReviews ?? [],
controlHandoffs: rollup.controlHandoffs,
now: Date.now(),
}),
[
rollup.hub.id,
rollup.hub.improvementProjects,
rollup.hub.sustainmentReviews,
rollup.controlHandoffs,
rollup.sustainmentRecords,
]
);
Expand Down Expand Up @@ -131,6 +133,10 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
onOpenInvestigation(targetId);
return;
}
if (surface === 'handoff' && targetId) {
onRecordHandoff(targetId);
return;
}
if (surface === 'improvement-projects' && targetId) {
onOpenInvestigation(targetProject?.metadata.investigationId ?? targetId);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ describe('ProcessHubSustainmentRegion', () => {
id: 'ho-1',
investigationId: 'inv-3',
hubId: 'hub-1',
status: 'operational',
surface: 'qms-procedure',
systemName: 'QMS',
operationalOwner: { displayName: 'Alice' },
Expand Down Expand Up @@ -328,6 +329,7 @@ describe('ProcessHubSustainmentRegion', () => {
id: 'ho-2',
investigationId: 'inv-5',
hubId: 'hub-1',
status: 'operational',
surface: 'dashboard-only',
systemName: 'Dashboard',
operationalOwner: { displayName: 'Bob' },
Expand Down
Loading