362 lines
15 KiB
PHP
362 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\User;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
|
|
pest()->browser()->timeout(60_000);
|
|
|
|
it('Spec328 smokes non-empty operations hub decision workbench entry', function (): void {
|
|
[$user, $environmentA, $environmentB] = spec328OperationsHubFixture();
|
|
spec328AuthenticateOperationsHubBrowser($this, $user, $environmentA);
|
|
|
|
$page = visit(OperationRunLinks::index(workspace: $environmentA->workspace))
|
|
->resize(1440, 1100)
|
|
->waitForText('Operations Hub')
|
|
->assertDontSee(__('localization.shell.no_environment_selected'))
|
|
->assertDontSee('Environment filter:')
|
|
->assertSee('Execution follow-up')
|
|
->assertSee('Which operation needs attention now?')
|
|
->assertSee('Decision workbench')
|
|
->assertSee('Needs attention')
|
|
->assertSee('Active operations')
|
|
->assertSee('Failed or blocked')
|
|
->assertSee('Completed recently')
|
|
->assertDontSee('Total Operations')
|
|
->assertDontSee('Avg Duration')
|
|
->assertDontSee('sparkline')
|
|
->assertDontSee('trend')
|
|
->assertSee('Inventory sync')
|
|
->assertSee('Outcome')
|
|
->assertSee('Blocked')
|
|
->assertSee('Reason')
|
|
->assertSee('Impact')
|
|
->assertSee($environmentA->name)
|
|
->assertSee('Proof')
|
|
->assertSee('Operation detail available')
|
|
->assertSee('Primary next action')
|
|
->assertSee('Next action')
|
|
->assertSee('Recent runs')
|
|
->assertSee('Policy sync')
|
|
->assertSee('View affected families')
|
|
->assertDontSee('tenant filter')
|
|
->assertDontSee('current tenant')
|
|
->assertDontSee('entitled tenant')
|
|
->assertDontSee('all tenants')
|
|
->assertDontSee('raw payload should stay hidden')
|
|
->assertDontSee('stack trace should stay hidden')
|
|
->assertDontSee('provider secret should stay hidden')
|
|
->assertDontSee('debug metadata should stay hidden')
|
|
->assertDontSee('internal exception should stay hidden')
|
|
->assertScript('document.querySelector("[data-testid=\"operations-hub-diagnostics\"]")?.open === false', true)
|
|
->assertScript('(() => {
|
|
const labels = Array.from(document.querySelectorAll("td.fi-ta-cell-next-action p"))
|
|
.filter((element) => element.textContent?.includes("View affected families"));
|
|
|
|
if (labels.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
return labels.every((element) => {
|
|
const styles = getComputedStyle(element);
|
|
|
|
return element.scrollWidth <= element.clientWidth + 1
|
|
&& element.scrollHeight <= element.clientHeight + 1
|
|
&& styles.textOverflow !== "ellipsis"
|
|
&& styles.webkitLineClamp !== "1";
|
|
});
|
|
})()', true)
|
|
->assertScript('(() => {
|
|
const summaryCards = document.querySelector("[data-testid=\"operations-hub-summary-cards\"]");
|
|
const nativeStats = summaryCards?.querySelector(".fi-wi-stats-overview");
|
|
const needsAttention = document.querySelector("[data-testid=\"operations-workbench-stat-needs-attention\"]");
|
|
const activeOperations = document.querySelector("[data-testid=\"operations-workbench-stat-active-operations\"]");
|
|
const failedOrBlocked = document.querySelector("[data-testid=\"operations-workbench-stat-failed-or-blocked\"]");
|
|
const completedRecently = document.querySelector("[data-testid=\"operations-workbench-stat-completed-recently\"]");
|
|
|
|
if (! summaryCards || ! nativeStats || ! needsAttention || ! activeOperations || ! failedOrBlocked || ! completedRecently) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
needsAttention.dataset.statColor !== "warning"
|
|
|| needsAttention.dataset.statValue !== "5"
|
|
|| activeOperations.dataset.statColor !== "info"
|
|
|| activeOperations.dataset.statValue !== "0"
|
|
|| failedOrBlocked.dataset.statColor !== "danger"
|
|
|| failedOrBlocked.dataset.statValue !== "5"
|
|
|| completedRecently.dataset.statColor !== "success"
|
|
|| completedRecently.dataset.statValue !== "0"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (summaryCards.textContent.includes("Total Operations") || summaryCards.textContent.includes("Avg Duration")) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
summaryCards.querySelector("canvas")
|
|
|| summaryCards.querySelector("[data-card-style]")
|
|
|| summaryCards.querySelector("[data-accent-placement]")
|
|
|| summaryCards.outerHTML.includes("wire:poll")
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const decisionGrid = document.querySelector("[data-testid=\"operations-hub-decision-workbench\"]");
|
|
const workbench = document.querySelector("[data-testid=\"operations-hub-priority-card\"]");
|
|
const detail = document.querySelector("[data-testid=\"operations-hub-operation-proof-panel\"]");
|
|
|
|
if (! decisionGrid || ! workbench || ! detail) {
|
|
return false;
|
|
}
|
|
|
|
const children = Array.from(decisionGrid.children);
|
|
const summaryBox = summaryCards.getBoundingClientRect();
|
|
const decisionBox = decisionGrid.getBoundingClientRect();
|
|
const workbenchBox = workbench.getBoundingClientRect();
|
|
const detailBox = detail.getBoundingClientRect();
|
|
|
|
return window.innerWidth >= 1024
|
|
&& summaryBox.bottom <= decisionBox.top
|
|
&& decisionGrid.classList.contains("lg:grid-cols-[minmax(0,1fr)_22rem]")
|
|
&& detail.tagName === "ASIDE"
|
|
&& children.indexOf(workbench) !== -1
|
|
&& children.indexOf(detail) > children.indexOf(workbench)
|
|
&& detailBox.left > workbenchBox.right
|
|
&& Math.abs(detailBox.top - workbenchBox.top) <= 8;
|
|
})()', true)
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs();
|
|
|
|
$page
|
|
->click('[data-testid="operations-hub-diagnostics"] summary')
|
|
->assertScript('document.querySelector("[data-testid=\"operations-hub-diagnostics\"]")?.open === true', true)
|
|
->assertSee('Raw context, provider payloads, stack traces, debug metadata, and support diagnostics stay on authorized operation detail surfaces.')
|
|
->click('[data-testid="operations-hub-diagnostics"] summary')
|
|
->assertScript('document.querySelector("[data-testid=\"operations-hub-diagnostics\"]")?.open === false', true)
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs();
|
|
|
|
$page->script('window.scrollTo(0, 0);');
|
|
|
|
$page
|
|
->screenshot(false, spec328OperationsHubScreenshot('operations-hub-premium-summary-cards'))
|
|
->screenshot(true, spec328OperationsHubScreenshot('operations-hub--clean'));
|
|
|
|
spec328CopyBrowserScreenshot('operations-hub--clean');
|
|
spec328CopyBrowserScreenshot('operations-hub--clean', 'operations-hub-decision-workbench.png');
|
|
spec328CopyBrowserScreenshot('operations-hub-premium-summary-cards');
|
|
});
|
|
|
|
it('Spec328 smokes filtered operations hub clear and reload behavior', function (): void {
|
|
[$user, $environmentA, $environmentB] = spec328OperationsHubFixture();
|
|
$cleanPath = json_encode((string) parse_url(OperationRunLinks::index(workspace: $environmentA->workspace), PHP_URL_PATH), JSON_THROW_ON_ERROR);
|
|
spec328AuthenticateOperationsHubBrowser($this, $user, $environmentA);
|
|
|
|
$page = visit(OperationRunLinks::index($environmentA))
|
|
->waitForText('Environment filter:')
|
|
->assertSee('Environment filter: '.$environmentA->name)
|
|
->assertSee('Which operation needs attention now?')
|
|
->assertSee($environmentA->name)
|
|
->assertDontSee('Policy sync')
|
|
->assertScript('document.querySelector("[data-testid=\"operations-hub-diagnostics\"]")?.open === false', true)
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs()
|
|
->screenshot(true, spec328OperationsHubScreenshot('operations-hub--filtered'));
|
|
|
|
spec328CopyBrowserScreenshot('operations-hub--filtered');
|
|
|
|
spec328ClearEnvironmentFilter($page)
|
|
->waitForText('Policy sync')
|
|
->assertDontSee('Environment filter:')
|
|
->assertSee('Policy sync')
|
|
->assertScript("window.location.pathname === {$cleanPath}", true)
|
|
->assertScript('! window.location.search.includes("environment_id=")', true)
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs()
|
|
->screenshot(true, spec328OperationsHubScreenshot('operations-hub--after-clear'));
|
|
|
|
spec328CopyBrowserScreenshot('operations-hub--after-clear');
|
|
|
|
$page->script('window.location.reload();');
|
|
|
|
$page
|
|
->waitForText('Policy sync')
|
|
->assertDontSee('Environment filter:')
|
|
->assertSee('Policy sync')
|
|
->assertScript("window.location.pathname === {$cleanPath}", true)
|
|
->assertScript('! window.location.search.includes("environment_id=")', true)
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs()
|
|
->screenshot(true, spec328OperationsHubScreenshot('operations-hub--after-reload'));
|
|
|
|
spec328CopyBrowserScreenshot('operations-hub--after-reload');
|
|
});
|
|
|
|
it('Spec328 smokes no-attention operations hub state', function (): void {
|
|
$environment = ManagedEnvironment::factory()->active()->create([
|
|
'name' => 'Spec328 Browser Empty Environment',
|
|
'external_id' => 'spec328-browser-empty-environment',
|
|
]);
|
|
[$user, $environment] = createUserWithTenant(
|
|
tenant: $environment,
|
|
role: 'owner',
|
|
workspaceRole: 'owner',
|
|
);
|
|
|
|
OperationRun::factory()->forTenant($environment)->create([
|
|
'type' => 'policy.sync',
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
'completed_at' => now()->subMinutes(5),
|
|
]);
|
|
|
|
spec328AuthenticateOperationsHubBrowser($this, $user, $environment);
|
|
|
|
visit(OperationRunLinks::index(workspace: $environment->workspace))
|
|
->waitForText('Operations Hub')
|
|
->assertSee('No operations need follow-up')
|
|
->assertSee('No failed, blocked, partial, or stale OperationRuns are visible in this scope.')
|
|
->assertSee('Operation detail available')
|
|
->assertDontSee('environment is healthy')
|
|
->assertDontSee('governance health is complete')
|
|
->assertNoJavaScriptErrors()
|
|
->assertNoConsoleLogs()
|
|
->screenshot(true, spec328OperationsHubScreenshot('operations-hub--empty'));
|
|
|
|
spec328CopyBrowserScreenshot('operations-hub--empty');
|
|
});
|
|
|
|
/**
|
|
* @return array{0: User, 1: ManagedEnvironment, 2: ManagedEnvironment}
|
|
*/
|
|
function spec328OperationsHubFixture(): array
|
|
{
|
|
$environmentA = ManagedEnvironment::factory()->active()->create([
|
|
'name' => 'Spec328 Browser Environment A',
|
|
'external_id' => 'spec328-browser-environment-a',
|
|
]);
|
|
|
|
[$user, $environmentA] = createUserWithTenant(
|
|
tenant: $environmentA,
|
|
role: 'owner',
|
|
workspaceRole: 'owner',
|
|
);
|
|
|
|
$environmentB = ManagedEnvironment::factory()->active()->create([
|
|
'workspace_id' => (int) $environmentA->workspace_id,
|
|
'name' => 'Spec328 Browser Environment B',
|
|
'external_id' => 'spec328-browser-environment-b',
|
|
]);
|
|
|
|
createUserWithTenant(
|
|
tenant: $environmentB,
|
|
user: $user,
|
|
role: 'owner',
|
|
workspaceRole: 'owner',
|
|
);
|
|
|
|
OperationRun::factory()->forTenant($environmentA)->create([
|
|
'type' => 'inventory_sync',
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Blocked->value,
|
|
'context' => [
|
|
'reason_code' => 'write_gate_blocked',
|
|
'raw_payload' => 'raw payload should stay hidden',
|
|
'stack_trace' => 'stack trace should stay hidden',
|
|
'provider_secret' => 'provider secret should stay hidden',
|
|
'debug_metadata' => 'debug metadata should stay hidden',
|
|
'internal_exception' => 'internal exception should stay hidden',
|
|
],
|
|
'completed_at' => null,
|
|
]);
|
|
|
|
foreach (range(1, 4) as $index) {
|
|
OperationRun::factory()->forTenant($environmentA)->create([
|
|
'type' => 'backup.schedule.execute',
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => $index % 2 === 0
|
|
? OperationRunOutcome::Blocked->value
|
|
: OperationRunOutcome::Failed->value,
|
|
'completed_at' => null,
|
|
]);
|
|
}
|
|
|
|
OperationRun::factory()->forTenant($environmentB)->create([
|
|
'type' => 'policy.sync',
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
'completed_at' => now()->subDays(2),
|
|
]);
|
|
|
|
return [$user, $environmentA, $environmentB];
|
|
}
|
|
|
|
function spec328AuthenticateOperationsHubBrowser(
|
|
mixed $test,
|
|
User $user,
|
|
ManagedEnvironment $rememberedEnvironment,
|
|
): void {
|
|
$workspaceId = (int) $rememberedEnvironment->workspace_id;
|
|
|
|
$session = [
|
|
WorkspaceContext::SESSION_KEY => $workspaceId,
|
|
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
|
|
(string) $workspaceId => (int) $rememberedEnvironment->getKey(),
|
|
],
|
|
];
|
|
|
|
$test->actingAs($user)->withSession($session);
|
|
|
|
foreach ($session as $key => $value) {
|
|
session()->put($key, $value);
|
|
}
|
|
|
|
setAdminPanelContext($rememberedEnvironment);
|
|
}
|
|
|
|
function spec328ClearEnvironmentFilter(mixed $page): mixed
|
|
{
|
|
$page->assertScript('document.querySelector(\'[data-testid="workspace-hub-environment-filter-clear"]\') instanceof HTMLAnchorElement', true);
|
|
$page->script('window.location.assign(document.querySelector(\'[data-testid="workspace-hub-environment-filter-clear"]\').href);');
|
|
|
|
return $page;
|
|
}
|
|
|
|
function spec328OperationsHubScreenshot(string $name): string
|
|
{
|
|
return 'spec328-'.$name;
|
|
}
|
|
|
|
function spec328CopyBrowserScreenshot(string $name, ?string $targetFilename = null): void
|
|
{
|
|
$filename = spec328OperationsHubScreenshot($name).'.png';
|
|
$source = base_path('tests/Browser/Screenshots/'.$filename);
|
|
$targetDirectory = repo_path('specs/328-operations-hub-decision-first-workbench-productization/artifacts/screenshots');
|
|
$targetFilename ??= $filename;
|
|
|
|
if (! is_dir($targetDirectory)) {
|
|
@mkdir($targetDirectory, 0755, true);
|
|
}
|
|
|
|
if (! is_dir($targetDirectory) || ! is_writable($targetDirectory)) {
|
|
return;
|
|
}
|
|
|
|
if (! is_file($source)) {
|
|
$source = \Pest\Browser\Support\Screenshot::path($filename);
|
|
}
|
|
|
|
if (is_file($source)) {
|
|
@copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$targetFilename);
|
|
}
|
|
}
|