283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { render } from 'svelte/server';
|
|
import { writable } from 'svelte/store';
|
|
import type { AuthUser } from '../../src/lib/auth.js';
|
|
import type { ProvisioningDetail, RecoveryObservation } from '../../src/lib/provisioning-detail.js';
|
|
import { provisioningNeedsRefresh } from '../../src/lib/ui/provisioning-presentation.js';
|
|
import ProvisioningPage from '../../src/routes/provisioning/[id]/+page.svelte';
|
|
|
|
vi.mock('$app/stores', () => ({
|
|
page: writable({
|
|
url: new URL('http://localhost/provisioning/operation-a'),
|
|
data: { user: null }
|
|
})
|
|
}));
|
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
|
|
|
const start = '2026-10-09T12:00:00Z';
|
|
const finish = '2026-10-09T12:01:00Z';
|
|
function fixture(state = 'done', recovery: RecoveryObservation | null = null): ProvisioningDetail {
|
|
return {
|
|
deployment: {
|
|
id: 'operation-a',
|
|
server: 't5_zombie_theater_1',
|
|
action: 'restart',
|
|
state,
|
|
stage: 'Completed',
|
|
server_deleted_at: null,
|
|
server_exists: 1,
|
|
started_at: start,
|
|
finished_at: finish,
|
|
triggered_by: 'watchdog:incident-a',
|
|
requested_by: 'Maxis',
|
|
error: null,
|
|
warnings: '[]'
|
|
},
|
|
generatedAt: finish,
|
|
events: [],
|
|
activity: { total: 0, latestId: null, failureExcerpt: null },
|
|
sources: [
|
|
{
|
|
kind: 'watchdog',
|
|
source_id: 'incident-a',
|
|
label: 'Deleted watchdog incident',
|
|
href: null
|
|
}
|
|
],
|
|
recipe: null,
|
|
blueprintName: null,
|
|
canResumeCleanup: false,
|
|
canResumeRebuild: false,
|
|
canResumeVerification: false,
|
|
canCancelRemoval: false,
|
|
collection: { id: 'collection-a', name: 'Evening lobbies' },
|
|
progress: {
|
|
version: 1,
|
|
phase: 'verify-game',
|
|
phaseStartedAt: '2026-10-09T12:00:50Z',
|
|
observedAt: finish,
|
|
blocker: null,
|
|
checks: [],
|
|
checkpoints: [],
|
|
command: null,
|
|
recovery,
|
|
recipeStatus: null
|
|
},
|
|
agentOperation: {
|
|
id: 'command-a',
|
|
hostId: 'host-a',
|
|
state: 'completed',
|
|
createdAt: start,
|
|
updatedAt: finish,
|
|
error: null
|
|
},
|
|
capacity: { evaluatedAt: finish, pools: [] },
|
|
warningMessages: []
|
|
};
|
|
}
|
|
function pageBody(detail: ProvisioningDetail) {
|
|
return render(ProvisioningPage, {
|
|
props: { data: { detail, user: null, setupRequired: false } }
|
|
}).body;
|
|
}
|
|
|
|
it('lets Super Viewers inspect actionable operations without mutation controls', () => {
|
|
const detail = fixture('failed');
|
|
Object.assign(detail, {
|
|
canResumeVerification: true,
|
|
canResumeCleanup: true,
|
|
canResumeRebuild: true,
|
|
canRetryWithRebuild: true,
|
|
canCancel: true,
|
|
canCancelCreation: true,
|
|
canOverrideSafety: true
|
|
});
|
|
const bodyFor = (role: AuthUser['role']) =>
|
|
render(ProvisioningPage, {
|
|
props: { data: { detail, user: { role } as AuthUser, setupRequired: false } }
|
|
}).body;
|
|
const readOnly = bodyFor('super_viewer');
|
|
const admin = bodyFor('admin');
|
|
expect(readOnly).toContain('View server');
|
|
expect(readOnly).toContain('operation-a');
|
|
for (const action of [
|
|
'Resume health verification',
|
|
'Resume rebuild cleanup',
|
|
'Resume cleanup',
|
|
'Retry with rebuild',
|
|
'Cancel operation',
|
|
'Cancel creation and remove server',
|
|
'Admin override…'
|
|
]) {
|
|
expect(readOnly).not.toContain(action);
|
|
expect(admin).toContain(action);
|
|
}
|
|
});
|
|
function recovery(state: RecoveryObservation['state']): RecoveryObservation {
|
|
return {
|
|
state,
|
|
at: finish,
|
|
message: 'Recovery status updated',
|
|
...(state === 'transferred' ? { operationId: 'rebuild-a' } : {})
|
|
};
|
|
}
|
|
|
|
describe('provisioning detail summary and technical details', () => {
|
|
it('explains file preparation while preserving the Queued lifecycle state', () => {
|
|
const detail = fixture('queued');
|
|
detail.deployment.finished_at = null;
|
|
detail.deployment.stage = 'Preparing launch files for the host agent';
|
|
detail.progress!.phase = 'prepare';
|
|
const body = pageBody(detail);
|
|
const badges = body.match(/<div\b[^>]*class="badges[^>]*>[\s\S]*?<\/div>/)?.[0];
|
|
expect(badges).toContain('Queued');
|
|
expect(body).toContain('Current step: Prepare server files');
|
|
expect(body).toContain('The command has not been submitted yet.');
|
|
expect(body).not.toContain('awaiting provisioning evaluation');
|
|
});
|
|
|
|
it('shows Validating with the current step and blocker as separate supporting text', () => {
|
|
const detail = fixture('awaiting-health');
|
|
detail.deployment.finished_at = null;
|
|
detail.deployment.stage = 'Checking game status';
|
|
detail.progress!.blocker = {
|
|
code: 'native-host',
|
|
message: 'Waiting for fresh game telemetry',
|
|
automatic: true
|
|
};
|
|
const body = pageBody(detail);
|
|
const badges = body.match(/<div\b[^>]*class="badges[^>]*>[\s\S]*?<\/div>/)?.[0];
|
|
expect(badges).toContain('Validating');
|
|
expect(badges).not.toContain('Waiting for fresh game telemetry');
|
|
expect(body).toContain('Current step: Verify game status');
|
|
expect(body).toContain('Waiting for fresh game telemetry');
|
|
expect(body).not.toContain('Verifying health');
|
|
});
|
|
|
|
it('keeps failed outcomes visible even when a prior blocker remains recorded', () => {
|
|
const detail = fixture('failed');
|
|
detail.progress!.blocker = {
|
|
code: 'startup-capacity',
|
|
message: 'Waiting for startup capacity',
|
|
automatic: true
|
|
};
|
|
const body = pageBody(detail);
|
|
expect(body).toContain('Restart failed');
|
|
expect(body).not.toContain('Current step:');
|
|
});
|
|
|
|
it('puts requester, absolute start and one duration above the outcome', () => {
|
|
const body = pageBody(fixture());
|
|
const summary = body.match(/<p\b[^>]*class="operation-summary[^>]*>[\s\S]*?<\/p>/)?.[0];
|
|
expect(summary).toBeDefined();
|
|
expect(summary).toContain('Requested by Maxis');
|
|
expect(summary).toContain('datetime="2026-10-09T12:00:00.000Z"');
|
|
expect(summary).toContain('Oct 9, 2026');
|
|
expect(summary).toContain('took 1m 0s');
|
|
expect(body.indexOf(summary!)).toBeLessThan(body.indexOf('id="outcome-title"'));
|
|
expect(body.match(/1m 0s/g)).toHaveLength(1);
|
|
expect(body).not.toContain('Recorded duration');
|
|
expect(body).not.toContain('Operation outcome');
|
|
expect(body).not.toContain('Updates every 5 seconds');
|
|
});
|
|
|
|
it('keeps operation, command and source IDs and copy controls in closed Technical details', () => {
|
|
const body = pageBody(fixture());
|
|
const technical = body.match(
|
|
/<details\b[^>]*class="technical-details[^>]*>[\s\S]*?<\/details>/
|
|
)?.[0];
|
|
expect(technical).toBeDefined();
|
|
expect(technical).toContain('Technical details</summary>');
|
|
expect(technical!.split('>')[0]).not.toMatch(/\bopen\b/);
|
|
const outside = body.replace(technical!, '');
|
|
for (const [value, label] of [
|
|
['operation-a', 'operation ID'],
|
|
['command-a', 'agent command ID'],
|
|
['collection-a', 'collection launch ID'],
|
|
['incident-a', 'Deleted watchdog incident source ID']
|
|
]) {
|
|
const code = new RegExp(`<code\\b[^>]*>${value}</code>`);
|
|
expect(technical).toMatch(code);
|
|
expect(outside).not.toMatch(code);
|
|
expect(technical).toContain(`aria-label="Copy ${label}"`);
|
|
expect(outside).not.toContain(`aria-label="Copy ${label}"`);
|
|
}
|
|
expect(technical).toContain('Copy link');
|
|
expect(outside).not.toContain('Copy link');
|
|
expect(technical).toContain('datetime="2026-10-09T12:01:00.000Z"');
|
|
expect(outside).toContain('href="/collections/launches/collection-a"');
|
|
expect(outside).toContain('Source record no longer available');
|
|
});
|
|
|
|
it('shows one elapsed duration for active work and preserves phase timing and freshness', () => {
|
|
const detail = fixture('running');
|
|
detail.deployment.finished_at = null;
|
|
const body = pageBody(detail);
|
|
expect(body).toContain('1m 0s elapsed');
|
|
expect(body.match(/1m 0s/g)).toHaveLength(1);
|
|
expect(body).not.toContain('Total elapsed');
|
|
expect(body).toContain('Time in phase');
|
|
expect(body).toContain('10s');
|
|
expect(body).toContain('Last evaluated');
|
|
expect(body).toContain('Updates every 5 seconds');
|
|
});
|
|
|
|
it('does not invent a duration when the finish time is missing', () => {
|
|
const detail = fixture('interrupted');
|
|
detail.deployment.finished_at = null;
|
|
const body = pageBody(detail);
|
|
expect(body).toContain('Duration: Not recorded');
|
|
expect(body).not.toContain('took ');
|
|
expect(body).not.toContain(' elapsed');
|
|
expect(body).not.toContain('Updates every 5 seconds');
|
|
});
|
|
});
|
|
|
|
describe('provisioning refresh policy', () => {
|
|
it.each(['queued', 'waiting', 'running', 'awaiting-health'])(
|
|
'refreshes %s operations',
|
|
(state) => {
|
|
expect(provisioningNeedsRefresh(fixture(state))).toBe(true);
|
|
}
|
|
);
|
|
|
|
it.each(['done', 'failed', 'interrupted', 'warning', 'cancelled'])(
|
|
'stops refreshing %s operations without held recovery',
|
|
(state) => {
|
|
const detail = fixture(state);
|
|
expect(provisioningNeedsRefresh(detail)).toBe(false);
|
|
for (const value of ['released', 'transferred', 'unknown'] as const) {
|
|
detail.progress!.recovery = recovery(value);
|
|
expect(provisioningNeedsRefresh(detail)).toBe(false);
|
|
}
|
|
detail.progress = null;
|
|
expect(provisioningNeedsRefresh(detail)).toBe(false);
|
|
}
|
|
);
|
|
|
|
it.each(['released', 'transferred'] as const)(
|
|
'keeps failed restart recovery refreshing until %s without marking it live',
|
|
(state) => {
|
|
const detail = fixture('failed', recovery('held'));
|
|
expect(provisioningNeedsRefresh(detail)).toBe(true);
|
|
const held = pageBody(detail);
|
|
expect(held).toContain('Recovery capacity held');
|
|
expect(held).not.toContain('Updates every 5 seconds');
|
|
expect(held).not.toContain('View recovery operation');
|
|
|
|
detail.progress!.recovery = recovery(state);
|
|
expect(provisioningNeedsRefresh(detail)).toBe(false);
|
|
const settled = pageBody(detail);
|
|
expect(settled).not.toContain('Recovery capacity held');
|
|
expect(settled).not.toContain('Updates every 5 seconds');
|
|
expect(settled).toContain(
|
|
state === 'released' ? 'Recovery capacity released' : 'Recovery transferred'
|
|
);
|
|
if (state === 'transferred') {
|
|
expect(settled).toContain('href="/provisioning/rebuild-a"');
|
|
expect(settled).toContain('View recovery operation');
|
|
}
|
|
}
|
|
);
|
|
});
|