browser()->timeout(30_000); uses(RefreshDatabase::class); function spec332RestoreWizardSmokeLoginUrl(User $user, ManagedEnvironment $tenant, string $redirect = ''): string { return route('admin.local.smoke-login', array_filter([ 'email' => $user->email, 'tenant' => $tenant->external_id, 'workspace' => $tenant->workspace->slug, 'redirect' => $redirect, ], static fn (?string $value): bool => filled($value))); } function spec332BrowserTenant(): array { $tenant = ManagedEnvironment::factory()->create([ 'rbac_status' => 'ok', 'rbac_last_checked_at' => now(), ]); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); ensureDefaultProviderConnection($tenant, 'microsoft'); bindFailHardGraphClient(); return [$user, $tenant]; } function spec332BrowserRedirect(ManagedEnvironment $tenant): string { $redirectBase = RestoreRunResource::getUrl('create', panel: 'admin', tenant: $tenant); return parse_url($redirectBase, PHP_URL_PATH) ?: '/admin'; } function spec332BrowserSelectBackupSet($page, BackupSet $backupSet): void { $selected = $page->script(<< { const select = document.getElementById('form.backup_set_id'); if (! select) { return false; } select.value = '{$backupSet->getKey()}'; select.dispatchEvent(new Event('input', { bubbles: true })); select.dispatchEvent(new Event('change', { bubbles: true })); return true; })() JS); expect($selected)->toBeTrue(); } function spec332BrowserElementIsVisibleScript(string $testId): string { return << { const element = document.querySelector('[data-testid="{$testId}"]'); if (! element) { return false; } const style = window.getComputedStyle(element); return style.display !== 'none' && style.visibility !== 'hidden' && ! element.hidden && Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); })() JS; } function spec332BrowserWizardNext($page): void { $clicked = $page->script(<<<'JS' (() => { const footer = document.querySelector('.fi-sc-wizard-footer'); if (! footer) { return false; } const nextTrigger = footer.querySelector('div[x-on\\:click*="requestNextStep"]'); if (! nextTrigger) { return false; } if (nextTrigger.classList.contains('fi-hidden')) { return false; } nextTrigger.scrollIntoView({ block: 'center' }); nextTrigger.click(); return true; })() JS); expect($clicked)->toBeTrue(); } function spec332BrowserUsableBackupFixture(ManagedEnvironment $tenant): array { $policy = Policy::create([ 'managed_environment_id' => (int) $tenant->getKey(), 'external_id' => 'spec332-browser-policy-usable', 'policy_type' => 'deviceConfiguration', 'display_name' => 'Spec332 Browser Policy', 'platform' => 'windows', ]); PolicyVersion::create([ 'managed_environment_id' => (int) $tenant->getKey(), 'policy_id' => (int) $policy->getKey(), 'version_number' => 1, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'captured_at' => now(), 'snapshot' => [ 'foo' => 'current', ], 'metadata' => [], 'assignments' => [], 'scope_tags' => [], ]); $backupSet = BackupSet::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'name' => 'Spec332 Browser Usable Backup', 'status' => 'completed', 'item_count' => 1, ]); $backupItem = BackupItem::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'backup_set_id' => (int) $backupSet->getKey(), 'policy_id' => (int) $policy->getKey(), 'policy_identifier' => $policy->external_id, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'captured_at' => now(), 'payload' => [ 'foo' => 'backup', 'displayName' => 'Spec332 Browser Policy', ], 'assignments' => [], 'metadata' => [ 'displayName' => 'Spec332 Browser Policy', ], ]); return [$backupSet, $backupItem]; } function spec332BrowserUnresolvedGroupFixture(ManagedEnvironment $tenant): array { $policy = Policy::create([ 'managed_environment_id' => (int) $tenant->getKey(), 'external_id' => 'spec332-browser-policy-group', 'policy_type' => 'deviceConfiguration', 'display_name' => 'Spec332 Group Mapping Policy', 'platform' => 'windows', ]); $backupSet = BackupSet::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'name' => 'Spec332 Browser Group Backup', 'status' => 'completed', 'item_count' => 1, ]); $backupItem = BackupItem::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'backup_set_id' => (int) $backupSet->getKey(), 'policy_id' => (int) $policy->getKey(), 'policy_identifier' => $policy->external_id, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'captured_at' => now(), 'payload' => [ 'foo' => 'backup', ], 'assignments' => [[ 'target' => [ '@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => '11111111-1111-1111-1111-111111111111', 'group_display_name' => 'Spec332 Missing Group', ], ]], 'metadata' => [ 'displayName' => 'Spec332 Group Mapping Policy', ], ]); return [$backupSet, $backupItem]; } function spec332BrowserMetadataOnlyFixture(ManagedEnvironment $tenant): array { $policy = Policy::create([ 'managed_environment_id' => (int) $tenant->getKey(), 'external_id' => 'spec332-browser-policy-metadata-only', 'policy_type' => 'deviceConfiguration', 'display_name' => 'Spec332 Metadata Only Browser Policy', 'platform' => 'windows', ]); $backupSet = BackupSet::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'name' => 'Spec332 Browser Metadata-only Backup', 'status' => 'completed', 'item_count' => 1, ]); $backupItem = BackupItem::factory()->create([ 'managed_environment_id' => (int) $tenant->getKey(), 'backup_set_id' => (int) $backupSet->getKey(), 'policy_id' => (int) $policy->getKey(), 'policy_identifier' => $policy->external_id, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'captured_at' => now(), 'payload' => [], 'assignments' => [], 'metadata' => [ 'displayName' => 'Spec332 Metadata Only Browser Policy', 'snapshot_source' => 'metadata_only', 'warnings' => ['metadata only fallback'], ], ]); return [$backupSet, $backupItem]; } it('shows compact safety evidence on step 1', function (): void { [$user, $tenant] = spec332BrowserTenant(); [$backupSet] = spec332BrowserUsableBackupFixture($tenant); $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); $page->resize(1920, 1200) ->waitForText('Select Backup Set'); spec332BrowserSelectBackupSet($page, $backupSet); $page->waitForText('Source selected') ->assertNoJavaScriptErrors() ->assertNoConsoleLogs() ->assertSee('Restore Safety') ->assertSee('Backup quality summary') ->assertSee('Safety evidence') ->assertSee('View safety gates and proof') ->assertSee('View quality caveat and detail') ->assertSee('Continue to scope.') ->assertSee('Validate impact.') ->assertDontSee('Technical startability') ->assertDontSee('write-gate') ->assertDontSee('hard-blocker') ->assertDontSee('Is this dangerous?') ->assertDontSee('tenant-wide recoverability') ->assertScript(<<<'JS' Array.from(document.querySelectorAll('[data-testid="restore-run-safety-evidence"]')).some((element) => { const style = window.getComputedStyle(element); return style.display !== 'none' && style.visibility !== 'hidden' && ! element.hidden && Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); }) JS, true) ->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-full'), false) ->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-compact'), false) ->assertScript('document.querySelector("[data-testid=\"restore-run-safety-evidence\"] details")?.open === false', true) ->assertScript('document.querySelector("[data-testid=\"restore-run-backup-quality-summary\"] details")?.open === false', true) ->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics-disclosure\"]")?.open === false', true) ->assertSee('A usable source backup is selected.'); }); it('shows compact restore evidence by default on step 2', function (): void { [$user, $tenant] = spec332BrowserTenant(); [$backupSet] = spec332BrowserUsableBackupFixture($tenant); $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); $page->resize(1920, 1200) ->waitForText('Select Backup Set'); spec332BrowserSelectBackupSet($page, $backupSet); $page->waitForText('Source selected'); spec332BrowserWizardNext($page); $page->waitForText('3/7 complete') ->assertSee('Restore evidence') ->assertSee('3/7 complete') ->assertSee('View validation gates and restore proof') ->assertDontSee('Technical startability') ->assertDontSee('write-gate') ->assertDontSee('hard-blocker') ->assertScript(<<<'JS' Array.from(document.querySelectorAll('[data-testid="restore-run-safety-evidence"]')).some((element) => { const style = window.getComputedStyle(element); return style.display !== 'none' && style.visibility !== 'hidden' && ! element.hidden && Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); }) JS, true) ->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-full'), false) ->assertScript('document.querySelector("[data-testid=\"restore-run-safety-evidence\"] details")?.open === false', true) ->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics-disclosure\"]")?.open === false', true); }); it('keeps group mapping details collapsed until explicitly opened on step 2', function (): void { [$user, $tenant] = spec332BrowserTenant(); [$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant); $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); $page->resize(1920, 1200) ->waitForText('Select Backup Set'); spec332BrowserSelectBackupSet($page, $backupSet); $page->waitForText('Continue to scope and resolve required mappings.'); spec332BrowserWizardNext($page); $page->waitForText('Resolve target mappings') ->assertSee('Scope summary') ->assertSee('1 mapping required') ->assertSee('Validation blocked') ->assertSee('0 of 1 mappings resolved') ->assertSee('Resolve target mappings') ->assertSee('Restore evidence') ->assertSee('View validation gates and restore proof') ->assertSee('Resolve required mappings before validation can run.') ->assertDontSee('Paste the target Entra ID group Object ID (GUID).') ->assertScript(<<<'JS' (() => { const section = document.querySelector('[data-testid="restore-run-mapping-resolver-section"]'); return section?.querySelector('.fi-section-content-ctn')?.getAttribute('aria-expanded') === 'false'; })() JS, true) ->assertScript('document.querySelector("[data-testid=\"restore-run-process-flow-compact-expanded\"]") === null', true); $page->script(<<<'JS' (() => { const section = document.querySelector('[data-testid="restore-run-mapping-resolver-section"]'); section?.querySelector('.fi-section-header')?.click(); })() JS); $page->waitForText('0 of 1 mappings resolved') ->assertSee('0 of 1 mappings resolved') ->assertSee('1 unresolved') ->assertSee('0 skipped') ->assertSee('Resolve required mappings before validation can run.') ->assertSee('Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.') ->assertSee('Hide mapping details') ->assertDontSee('Return to scope summary') ->assertDontSee('Paste the target Entra ID group Object ID (GUID).') ->assertScript(<<<'JS' (() => { const section = document.querySelector('[data-testid="restore-run-mapping-resolver-section"]'); return section?.querySelector('.fi-section-content-ctn')?.getAttribute('aria-expanded') === 'true'; })() JS, true); }); it('shows a task-specific empty picker when the directory group cache is unavailable on step 2', function (): void { [$user, $tenant] = spec332BrowserTenant(); [$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant); $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); $page->resize(1920, 1200) ->waitForText('Select Backup Set'); spec332BrowserSelectBackupSet($page, $backupSet); $page->waitForText('Continue to scope and resolve required mappings.'); spec332BrowserWizardNext($page); $page->waitForText('Resolve target mappings'); $pickerOpened = $page->script(<<<'JS' (() => { const header = Array.from(document.querySelectorAll('.fi-section-header')).find((element) => element.textContent?.includes('Resolve target mappings') ); header?.click(); const pickerButton = document.querySelector('button[wire\\:click*="select_from_directory_cache_11111111_1111_1111_1111_111111111111"]'); pickerButton?.click(); return Boolean(pickerButton); })() JS); expect($pickerOpened)->toBeTrue(); $page->waitForText('Resolve target group mapping') ->assertSee('Source group') ->assertSee('Spec332 Missing Group') ->assertSee('Source ID: 11111111-1111-1111-1111-111111111111') ->assertSee('No directory group cache available') ->assertSee('TenantPilot needs cached directory groups before target mappings can be selected.') ->assertSee('Sync directory groups, then return to this mapping.') ->assertSee('Open group sync') ->assertSee('View group sync operations') ->assertScript(<<<'JS' (() => { const modalContent = document.querySelector('[data-testid="restore-group-picker-modal-content"]'); if (! modalContent) { return false; } const actionLabels = Array.from(modalContent.querySelectorAll('a, button')) .map((element) => element.textContent?.trim() ?? '') .filter(Boolean); return ! actionLabels.includes('Directory Groups') && ! actionLabels.includes('Operations') && ! modalContent.textContent?.includes('No cached groups found') && ! modalContent.textContent?.includes('No groups found in tenant') && ! modalContent.textContent?.includes('Search groups…'); })() JS, true); }); it('shows cached directory group results in the picker when cache exists on step 2', function (): void { [$user, $tenant] = spec332BrowserTenant(); [$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant); EntraGroup::factory()->for($tenant)->create([ 'entra_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'display_name' => 'Spec332 Cached Target Group', 'last_seen_at' => now(), ]); $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); $page->resize(1920, 1200) ->waitForText('Select Backup Set'); spec332BrowserSelectBackupSet($page, $backupSet); $page->waitForText('Continue to scope and resolve required mappings.'); spec332BrowserWizardNext($page); $page->waitForText('Resolve target mappings'); $page->script(<<<'JS' (() => { const section = document.querySelector('[data-testid="restore-run-mapping-resolver-section"]'); section?.querySelector('.fi-section-header')?.click(); })() JS); $pickerOpened = $page->script(<<<'JS' (() => { const pickerButton = document.querySelector('button[wire\\:click*="select_from_directory_cache_11111111_1111_1111_1111_111111111111"]'); pickerButton?.click(); return Boolean(pickerButton); })() JS); expect($pickerOpened)->toBeTrue(); $page->waitForText('Resolve target group mapping') ->assertSee('Spec332 Cached Target Group') ->assertDontSee('No directory group cache available'); }); it('blocks next on step 2 when required mappings are unresolved', function (): void { [$user, $tenant] = spec332BrowserTenant(); [$backupSet] = spec332BrowserUnresolvedGroupFixture($tenant); $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); $page->resize(1920, 1200) ->waitForText('Select Backup Set'); spec332BrowserSelectBackupSet($page, $backupSet); $page->waitForText('Continue to scope and resolve required mappings.'); spec332BrowserWizardNext($page); $page->waitForText('Resolve target mappings'); spec332BrowserWizardNext($page); $page->waitForText('Mappings required') ->assertDontSee('field is required.') ->assertSee('Resolve required mappings before validation can run.') ->assertSee('Define Restore Scope'); }); it('blocks next on step 3 when validation has blockers', function (): void { [$user, $tenant] = spec332BrowserTenant(); [$backupSet] = spec332BrowserMetadataOnlyFixture($tenant); $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); $page->resize(1920, 1200) ->waitForText('Select Backup Set'); spec332BrowserSelectBackupSet($page, $backupSet); $page->waitForText('Source not usable'); spec332BrowserWizardNext($page); $page->waitForText('Define Restore Scope'); spec332BrowserWizardNext($page); $page->waitForText('Safety & Conflict Checks'); $page->waitForText('Run checks') ->click('Run checks') ->waitForText('Snapshot completeness'); spec332BrowserWizardNext($page); $page ->waitForText('Validation blocked') ->assertSee('Validation decision') ->assertSee('Select another backup set.') ->assertSee('Snapshot completeness') ->assertSee('Validation evidence') ->assertSee('Safety & Conflict Checks'); }); it('keeps preview decision-first on step 4 while safety gates stay compact', function (): void { [$user, $tenant] = spec332BrowserTenant(); [$backupSet] = spec332BrowserUsableBackupFixture($tenant); $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); $page->resize(1920, 1200) ->waitForText('Select Backup Set'); spec332BrowserSelectBackupSet($page, $backupSet); $page->waitForText('Source selected'); spec332BrowserWizardNext($page); $page->waitForText('Define Restore Scope'); spec332BrowserWizardNext($page); $page->waitForText('Safety & Conflict Checks'); $page->waitForText('Run checks') ->click('Run checks') ->waitForText('Validation passed') ->assertSee('Validation decision') ->assertSee('Validation evidence') ->assertSee('View validation gates and restore proof') ->assertSee('Checks are current for this scope. Rerun only after scope or mapping changes.') ->assertDontSee('Run checks after defining scope and mapping missing groups.') ->assertScript('document.querySelector("[data-testid=\"restore-run-safe-checks-details\"]")?.open === false', true); spec332BrowserWizardNext($page); $page->waitForText('Generate preview') ->click('Generate preview') ->waitForText('Preview details') ->assertSee('Review the preview and continue to confirmation.') ->assertSee('Regenerate preview') ->assertSee('Preview is current for this scope. Regenerate only after scope, mapping, or source changes.') ->assertSee('Preview evidence') ->assertSee('View safety gates and restore proof') ->assertSee('Restore action') ->assertSee('Update existing') ->assertSee('Policy diff') ->assertSee('Assignments') ->assertSee('Scope tags') ->assertDontSee('Restore safety status') ->assertDontSee('Hide safety gates') ->assertDontSee('tenant-wide recovery is proven') ->assertScript('document.querySelector("[data-testid=\"restore-run-preview-evidence-details\"]")?.open === false', true) ->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-compact'), false) ->assertScript(spec332BrowserElementIsVisibleScript('restore-run-process-flow-full'), false) ->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics-disclosure\"]")?.open === false', true); }); it('shows confirm step readiness after preview and checks are current', function (): void { [$user, $tenant] = spec332BrowserTenant(); [$backupSet] = spec332BrowserUsableBackupFixture($tenant); $page = visit(spec332RestoreWizardSmokeLoginUrl($user, $tenant, spec332BrowserRedirect($tenant))); $page->resize(1920, 1200) ->waitForText('Select Backup Set'); spec332BrowserSelectBackupSet($page, $backupSet); $page->waitForText('Source selected'); spec332BrowserWizardNext($page); $page->waitForText('Define Restore Scope'); spec332BrowserWizardNext($page); $page->waitForText('Safety & Conflict Checks'); $page->waitForText('Run checks') ->click('Run checks') ->waitForText('Validation passed') ->assertSee('Validation evidence') ->assertScript('document.querySelector("[data-testid=\"restore-run-safe-checks-details\"]")?.open === false', true); spec332BrowserWizardNext($page); $page->waitForText('Generate preview') ->click('Generate preview') ->waitForText('Preview details'); spec332BrowserWizardNext($page); $page ->waitForText('Confirm & Execute') ->assertSee('Confirmation summary') ->assertSee('Execution unavailable until confirmation') ->assertSee('Recovery is not verified until post-run evidence exists.') ->assertSee('Restore Proof') ->assertSee('Operation proof') ->assertSee('Post-run evidence') ->assertDontSee('tenant-wide recovery is proven'); });