Compare commits
1 Commits
dev
...
198-monito
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88a65678c6 |
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -190,8 +190,6 @@ ## Active Technologies
|
|||||||
- PostgreSQL unchanged; no new persistence, cache store, or durable UI artifact (197-shared-detail-contract)
|
- PostgreSQL unchanged; no new persistence, cache store, or durable UI artifact (197-shared-detail-contract)
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages (198-monitoring-page-state)
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages (198-monitoring-page-state)
|
||||||
- PostgreSQL plus existing Laravel session-backed table filter, search, and sort persistence; no schema change planned (198-monitoring-page-state)
|
- PostgreSQL plus existing Laravel session-backed table filter, search, and sort persistence; no schema change planned (198-monitoring-page-state)
|
||||||
- PHP 8.4.15 + Laravel 12, Pest v4, PHPUnit 12, Pest Browser plugin, Filament v5, Livewire v4, Laravel Sail (206-test-suite-governance)
|
|
||||||
- SQLite `:memory:` for the default test configuration, dedicated PostgreSQL config for the schema-level `Pgsql` suite, and local runner artifacts under `apps/platform/storage/logs/test-lanes` (206-test-suite-governance)
|
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -226,8 +224,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 206-test-suite-governance: Added PHP 8.4.15 + Laravel 12, Pest v4, PHPUnit 12, Pest Browser plugin, Filament v5, Livewire v4, Laravel Sail
|
|
||||||
- 198-monitoring-page-state: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages
|
- 198-monitoring-page-state: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `OperateHubShell`, Filament `InteractsWithTable`, and page-local Livewire state on the affected Filament pages
|
||||||
- 197-shared-detail-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable`
|
- 197-shared-detail-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `VerificationReportViewer`, `VerificationReportChangeIndicator`, `PolicyNormalizer`, `VersionDiff`, `DriftFindingDiffBuilder`, and `SettingsCatalogSettingsTable`
|
||||||
|
- 205-compare-job-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -37,10 +37,6 @@ coverage/
|
|||||||
/apps/platform/storage/framework
|
/apps/platform/storage/framework
|
||||||
/storage/logs
|
/storage/logs
|
||||||
/apps/platform/storage/logs
|
/apps/platform/storage/logs
|
||||||
/apps/platform/storage/logs/*
|
|
||||||
!/apps/platform/storage/logs/test-lanes/
|
|
||||||
/apps/platform/storage/logs/test-lanes/*
|
|
||||||
!/apps/platform/storage/logs/test-lanes/.gitignore
|
|
||||||
/storage/debugbar
|
/storage/debugbar
|
||||||
/apps/platform/storage/debugbar
|
/apps/platform/storage/debugbar
|
||||||
/vendor
|
/vendor
|
||||||
|
|||||||
64
README.md
64
README.md
@ -37,70 +37,6 @@ ### Website
|
|||||||
- Start the dev server: `cd apps/website && pnpm dev`
|
- Start the dev server: `cd apps/website && pnpm dev`
|
||||||
- Build the static site: `cd apps/website && pnpm build`
|
- Build the static site: `cd apps/website && pnpm build`
|
||||||
|
|
||||||
## Test Suite Governance
|
|
||||||
|
|
||||||
### Canonical Lane Commands
|
|
||||||
|
|
||||||
- Preferred repo-root wrappers:
|
|
||||||
- `./scripts/platform-test-lane fast-feedback`
|
|
||||||
- `./scripts/platform-test-lane confidence`
|
|
||||||
- `./scripts/platform-test-lane heavy-governance`
|
|
||||||
- `./scripts/platform-test-lane browser`
|
|
||||||
- `./scripts/platform-test-lane profiling`
|
|
||||||
- `./scripts/platform-test-lane junit`
|
|
||||||
- Regenerate the latest report artifacts without re-running the lane:
|
|
||||||
- `./scripts/platform-test-report fast-feedback`
|
|
||||||
- `./scripts/platform-test-report confidence`
|
|
||||||
- `./scripts/platform-test-report heavy-governance`
|
|
||||||
- `./scripts/platform-test-report browser`
|
|
||||||
- `./scripts/platform-test-report profiling`
|
|
||||||
- `./scripts/platform-test-report junit`
|
|
||||||
- App-local equivalents remain available through Sail Composer scripts:
|
|
||||||
- `cd apps/platform && ./vendor/bin/sail composer run test`
|
|
||||||
- `cd apps/platform && ./vendor/bin/sail composer run test:confidence`
|
|
||||||
- `cd apps/platform && ./vendor/bin/sail composer run test:heavy`
|
|
||||||
- `cd apps/platform && ./vendor/bin/sail composer run test:browser`
|
|
||||||
- `cd apps/platform && ./vendor/bin/sail composer run test:profile`
|
|
||||||
- `cd apps/platform && ./vendor/bin/sail composer run test:junit`
|
|
||||||
- The root wrapper is the safer default for long lanes because it pins Composer to `--timeout=0`.
|
|
||||||
|
|
||||||
### Recorded Baselines
|
|
||||||
|
|
||||||
| Scope | Wall clock | Budget | Notes |
|
|
||||||
|-------|------------|--------|-------|
|
|
||||||
| Full suite baseline | `2624.60s` | reference only | Current broad-suite measurement used as the budget anchor |
|
|
||||||
| `fast-feedback` | `176.74s` | `200s` | More than 50% below the current full-suite baseline |
|
|
||||||
| `confidence` | `394.38s` | `450s` | Broader non-browser pre-merge lane |
|
|
||||||
| `heavy-governance` | `83.66s` | `120s` | Seed heavy family lane for architecture, deprecation, ops UX, and action-surface scans |
|
|
||||||
| `browser` | `128.87s` | `150s` | Dedicated browser smoke and workflow lane |
|
|
||||||
| `junit` | `380.14s` | `450s` | Parallel machine-readable report lane for the confidence scope |
|
|
||||||
| `profiling` | `2701.51s` | `3000s` | Serial slow-test drift lane with profile output |
|
|
||||||
|
|
||||||
Artifacts are written under `apps/platform/storage/logs/test-lanes` and kept out of git except for the checked-in skeleton `.gitignore`.
|
|
||||||
|
|
||||||
### Honest Taxonomy Rules
|
|
||||||
|
|
||||||
- `Unit`: isolated logic, helpers, and low-cost domain behavior.
|
|
||||||
- `Feature`: HTTP, Livewire, Filament, jobs, and non-browser integration slices.
|
|
||||||
- `Browser`: only end-to-end browser smoke and workflow coverage under `tests/Browser`.
|
|
||||||
- `heavy-governance`: intentionally expensive architecture, deprecation, ops UX, and wide contract scans. The first seeded batch is `tests/Architecture`, `tests/Deprecation`, `tests/Feature/078`, `tests/Feature/090`, `tests/Feature/144`, `tests/Feature/OpsUx`, `tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php`, `tests/Feature/Guards/ActionSurfaceContractTest.php`, `tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, and `tests/Feature/ProviderConnections/CredentialLeakGuardTest.php`.
|
|
||||||
|
|
||||||
### Fixture Cost Guidance
|
|
||||||
|
|
||||||
- `createUserWithTenant()` now defaults to the explicit cheap `minimal` profile.
|
|
||||||
- Use `createMinimalUserWithTenant()` in high-usage callers that only need tenant membership and workspace/session wiring.
|
|
||||||
- Use `fixtureProfile: 'provider-enabled'` when a test needs a default Microsoft provider connection.
|
|
||||||
- Use `fixtureProfile: 'credential-enabled'` when provider identity resolution, admin-consent URLs, or Graph option construction needs dedicated credentials.
|
|
||||||
- Use `fixtureProfile: 'ui-context'` when the helper should also seed current tenant UI context and clear capability caches.
|
|
||||||
- Use `fixtureProfile: 'heavy'` only when the full side-effect bundle is intentionally required.
|
|
||||||
|
|
||||||
### DB Reset and Seed Rules
|
|
||||||
|
|
||||||
- Default lanes use SQLite `:memory:` with `RefreshDatabase` as the reset strategy.
|
|
||||||
- The isolated PostgreSQL coverage remains the `Pgsql` suite and is reserved for schema or foreign-key assertions.
|
|
||||||
- Keep seeds out of default lanes. Opt into seeded fixtures only inside the test that needs business-truth seed data.
|
|
||||||
- Schema-baseline or dump-based acceleration remains a follow-up investigation, not a default requirement for the current lane model.
|
|
||||||
|
|
||||||
## Port Overrides
|
## Port Overrides
|
||||||
|
|
||||||
- Platform HTTP and Vite ports: set `APP_PORT` and or `VITE_PORT` before `corepack pnpm dev:platform` or `cd apps/platform && ./vendor/bin/sail up -d`
|
- Platform HTTP and Vite ports: set `APP_PORT` and or `VITE_PORT` before `corepack pnpm dev:platform` or `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
|
|||||||
@ -22,8 +22,6 @@ class Tenant extends Model implements HasName
|
|||||||
use HasFactory;
|
use HasFactory;
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
protected static bool $skipTestWorkspaceProvisioning = false;
|
|
||||||
|
|
||||||
public const STATUS_DRAFT = 'draft';
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
|
||||||
public const STATUS_ONBOARDING = 'onboarding';
|
public const STATUS_ONBOARDING = 'onboarding';
|
||||||
@ -83,7 +81,7 @@ protected static function booted(): void
|
|||||||
$tenant->status = self::STATUS_ACTIVE;
|
$tenant->status = self::STATUS_ACTIVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenant->workspace_id === null && app()->runningUnitTests() && ! static::$skipTestWorkspaceProvisioning) {
|
if ($tenant->workspace_id === null && app()->runningUnitTests()) {
|
||||||
$workspace = Workspace::query()->create([
|
$workspace = Workspace::query()->create([
|
||||||
'name' => 'Test Workspace',
|
'name' => 'Test Workspace',
|
||||||
'slug' => 'test-'.Str::lower(Str::random(10)),
|
'slug' => 'test-'.Str::lower(Str::random(10)),
|
||||||
@ -120,11 +118,6 @@ public static function activeQuery(): Builder
|
|||||||
->where('status', TenantLifecycle::Active->value);
|
->where('status', TenantLifecycle::Active->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function skipTestWorkspaceProvisioning(bool $skip = true): void
|
|
||||||
{
|
|
||||||
static::$skipTestWorkspaceProvisioning = $skip;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function makeCurrent(): void
|
public function makeCurrent(): void
|
||||||
{
|
{
|
||||||
if (! $this->isSelectableAsContext()) {
|
if (! $this->isSelectableAsContext()) {
|
||||||
|
|||||||
@ -54,31 +54,7 @@
|
|||||||
],
|
],
|
||||||
"test": [
|
"test": [
|
||||||
"@php artisan config:clear --ansi",
|
"@php artisan config:clear --ansi",
|
||||||
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('fast-feedback'));\""
|
"@php artisan test"
|
||||||
],
|
|
||||||
"test:fast": [
|
|
||||||
"@php artisan config:clear --ansi",
|
|
||||||
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('fast-feedback'));\""
|
|
||||||
],
|
|
||||||
"test:confidence": [
|
|
||||||
"@php artisan config:clear --ansi",
|
|
||||||
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('confidence'));\""
|
|
||||||
],
|
|
||||||
"test:browser": [
|
|
||||||
"@php artisan config:clear --ansi",
|
|
||||||
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('browser'));\""
|
|
||||||
],
|
|
||||||
"test:heavy": [
|
|
||||||
"@php artisan config:clear --ansi",
|
|
||||||
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('heavy-governance'));\""
|
|
||||||
],
|
|
||||||
"test:profile": [
|
|
||||||
"@php artisan config:clear --ansi",
|
|
||||||
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('profiling'));\""
|
|
||||||
],
|
|
||||||
"test:junit": [
|
|
||||||
"@php artisan config:clear --ansi",
|
|
||||||
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('junit'));\""
|
|
||||||
],
|
],
|
||||||
"test:pgsql": [
|
"test:pgsql": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
@ -91,7 +67,7 @@
|
|||||||
"./vendor/bin/sail stop"
|
"./vendor/bin/sail stop"
|
||||||
],
|
],
|
||||||
"sail:test": [
|
"sail:test": [
|
||||||
"./vendor/bin/sail composer run test"
|
"./vendor/bin/sail artisan test --compact"
|
||||||
],
|
],
|
||||||
"sail:test:pgsql": [
|
"sail:test:pgsql": [
|
||||||
"./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml"
|
"./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml"
|
||||||
|
|||||||
@ -3,13 +3,10 @@
|
|||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\ProviderCredential;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
use App\Support\Providers\ProviderCredentialKind;
|
|
||||||
use App\Support\Providers\ProviderCredentialSource;
|
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
@ -75,14 +72,6 @@ public function platform(): static
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function minimal(): static
|
|
||||||
{
|
|
||||||
return $this->platform()->state(fn (): array => [
|
|
||||||
'is_default' => false,
|
|
||||||
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function dedicated(): static
|
public function dedicated(): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (): array => [
|
return $this->state(fn (): array => [
|
||||||
@ -118,33 +107,4 @@ public function disabled(): static
|
|||||||
'is_enabled' => false,
|
'is_enabled' => false,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function withCredential(): static
|
|
||||||
{
|
|
||||||
return $this->dedicated()
|
|
||||||
->verifiedHealthy()
|
|
||||||
->state(fn (): array => [
|
|
||||||
'is_default' => true,
|
|
||||||
])
|
|
||||||
->afterCreating(function (ProviderConnection $connection): void {
|
|
||||||
if ($connection->credential()->exists()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ProviderCredential::factory()->create([
|
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
'type' => ProviderCredentialKind::ClientSecret->value,
|
|
||||||
'credential_kind' => ProviderCredentialKind::ClientSecret->value,
|
|
||||||
'source' => ProviderCredentialSource::DedicatedManual->value,
|
|
||||||
'last_rotated_at' => now(),
|
|
||||||
'expires_at' => now()->addYear(),
|
|
||||||
'payload' => [
|
|
||||||
'client_id' => fake()->uuid(),
|
|
||||||
'client_secret' => fake()->sha1(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$connection->refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,12 +11,10 @@
|
|||||||
*/
|
*/
|
||||||
class TenantFactory extends Factory
|
class TenantFactory extends Factory
|
||||||
{
|
{
|
||||||
protected bool $provisionsWorkspace = true;
|
|
||||||
|
|
||||||
public function configure(): static
|
public function configure(): static
|
||||||
{
|
{
|
||||||
return $this->afterCreating(function (Tenant $tenant): void {
|
return $this->afterCreating(function (Tenant $tenant): void {
|
||||||
if (! $this->provisionsWorkspace || $tenant->workspace_id !== null) {
|
if ($tenant->workspace_id !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,24 +83,4 @@ public function archived(): static
|
|||||||
'is_current' => false,
|
'is_current' => false,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function minimal(): static
|
|
||||||
{
|
|
||||||
Tenant::skipTestWorkspaceProvisioning();
|
|
||||||
|
|
||||||
$factory = clone $this;
|
|
||||||
$factory->provisionsWorkspace = false;
|
|
||||||
|
|
||||||
return $factory
|
|
||||||
->afterCreating(function (Tenant $tenant): void {
|
|
||||||
if ($tenant->workspace_id !== null) {
|
|
||||||
$workspaceId = (int) $tenant->workspace_id;
|
|
||||||
|
|
||||||
$tenant->forceFill(['workspace_id' => null])->saveQuietly();
|
|
||||||
Workspace::query()->whereKey($workspaceId)->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
Tenant::skipTestWorkspaceProvisioning(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,7 +39,6 @@
|
|||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
<env name="TEST_LANE_ARTIFACT_DIRECTORY" value="storage/logs/test-lanes"/>
|
|
||||||
|
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
|
|||||||
@ -34,7 +34,6 @@
|
|||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
<env name="TEST_LANE_ARTIFACT_DIRECTORY" value="storage/logs/test-lanes"/>
|
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('cached groups can be listed, searched, and filtered (DB-only)', function () {
|
test('cached groups can be listed, searched, and filtered (DB-only)', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$otherTenant = Tenant::factory()->create();
|
$otherTenant = Tenant::factory()->create();
|
||||||
@ -117,7 +117,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner', fixtureProfile: 'credential-enabled');
|
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA))
|
->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA))
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->user = User::factory()->create();
|
$this->user = User::factory()->create();
|
||||||
[$this->user, $this->tenant] = createUserWithTenant(tenant: $this->tenant, user: $this->user, role: 'owner', fixtureProfile: 'credential-enabled');
|
[$this->user, $this->tenant] = createUserWithTenant(tenant: $this->tenant, user: $this->user, role: 'owner');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders policy version view without any Graph calls during render', function () {
|
it('renders policy version view without any Graph calls during render', function () {
|
||||||
|
|||||||
@ -20,7 +20,7 @@
|
|||||||
$mock->shouldReceive('request')->never();
|
$mock->shouldReceive('request')->never();
|
||||||
});
|
});
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
@ -51,7 +51,7 @@
|
|||||||
it('disables group sync start action for readonly users', function () {
|
it('disables group sync start action for readonly users', function () {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
it('starts a manual group sync by creating a run and dispatching a job', function () {
|
it('starts a manual group sync by creating a run and dispatching a job', function () {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$service = app(EntraGroupSyncService::class);
|
$service = app(EntraGroupSyncService::class);
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
it('sync job upserts groups and updates run counters', function () {
|
it('sync job upserts groups and updates run counters', function () {
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
EntraGroup::factory()->create([
|
EntraGroup::factory()->create([
|
||||||
'tenant_id' => $tenant->getKey(),
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
|
|
||||||
it('purges cached groups older than the retention window', function () {
|
it('purges cached groups older than the retention window', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
Config::set('directory_groups.retention_days', 90);
|
Config::set('directory_groups.retention_days', 90);
|
||||||
|
|
||||||
|
|||||||
@ -86,7 +86,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
it('creates findings for high-privilege assignments with correct attributes', function (): void {
|
it('creates findings for high-privilege assignments with correct attributes', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$measuredAt = '2026-02-24T10:00:00Z';
|
$measuredAt = '2026-02-24T10:00:00Z';
|
||||||
$payload = buildPayload(
|
$payload = buildPayload(
|
||||||
@ -132,7 +132,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('maps severity: GA is critical, others are high', function (): void {
|
it('maps severity: GA is critical, others are high', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$payload = buildPayload(
|
$payload = buildPayload(
|
||||||
[gaRoleDef(), secAdminRoleDef()],
|
[gaRoleDef(), secAdminRoleDef()],
|
||||||
@ -157,7 +157,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is idempotent — same data produces no duplicates', function (): void {
|
it('is idempotent — same data produces no duplicates', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$payload1 = buildPayload(
|
$payload1 = buildPayload(
|
||||||
[gaRoleDef()],
|
[gaRoleDef()],
|
||||||
@ -196,7 +196,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('auto-resolves when assignment is removed', function (): void {
|
it('auto-resolves when assignment is removed', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$generator = makeGenerator();
|
$generator = makeGenerator();
|
||||||
|
|
||||||
@ -224,7 +224,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('re-opens resolved finding when role is re-assigned', function (): void {
|
it('re-opens resolved finding when role is re-assigned', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$generator = makeGenerator();
|
$generator = makeGenerator();
|
||||||
|
|
||||||
@ -264,7 +264,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('creates aggregate finding when GA count exceeds threshold', function (): void {
|
it('creates aggregate finding when GA count exceeds threshold', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$assignments = [];
|
$assignments = [];
|
||||||
|
|
||||||
@ -293,7 +293,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('auto-resolves aggregate finding when GA count drops within threshold', function (): void {
|
it('auto-resolves aggregate finding when GA count drops within threshold', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$generator = makeGenerator();
|
$generator = makeGenerator();
|
||||||
|
|
||||||
@ -322,7 +322,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('produces alert events for new and re-opened findings with severity >= high', function (): void {
|
it('produces alert events for new and re-opened findings with severity >= high', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$generator = makeGenerator();
|
$generator = makeGenerator();
|
||||||
|
|
||||||
@ -342,7 +342,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('produces no alert events for unchanged or resolved findings', function (): void {
|
it('produces no alert events for unchanged or resolved findings', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$generator = makeGenerator();
|
$generator = makeGenerator();
|
||||||
|
|
||||||
@ -363,7 +363,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('evidence contains all required fields', function (): void {
|
it('evidence contains all required fields', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$payload = buildPayload(
|
$payload = buildPayload(
|
||||||
[gaRoleDef()],
|
[gaRoleDef()],
|
||||||
@ -400,7 +400,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles all principal types correctly', function (): void {
|
it('handles all principal types correctly', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$payload = buildPayload(
|
$payload = buildPayload(
|
||||||
[gaRoleDef()],
|
[gaRoleDef()],
|
||||||
@ -426,7 +426,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('subject_type and subject_external_id set on every finding', function (): void {
|
it('subject_type and subject_external_id set on every finding', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$payload = buildPayload(
|
$payload = buildPayload(
|
||||||
[gaRoleDef()],
|
[gaRoleDef()],
|
||||||
@ -445,7 +445,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('auto-resolve applies to acknowledged findings too', function (): void {
|
it('auto-resolve applies to acknowledged findings too', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$generator = makeGenerator();
|
$generator = makeGenerator();
|
||||||
|
|
||||||
@ -481,7 +481,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('scoped assignments do not downgrade severity', function (): void {
|
it('scoped assignments do not downgrade severity', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$payload = buildPayload(
|
$payload = buildPayload(
|
||||||
[gaRoleDef()],
|
[gaRoleDef()],
|
||||||
@ -500,7 +500,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not create findings for non-high-privilege roles', function (): void {
|
it('does not create findings for non-high-privilege roles', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$payload = buildPayload(
|
$payload = buildPayload(
|
||||||
[readerRoleDef()],
|
[readerRoleDef()],
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('readonly users may switch current tenant via ChooseTenant', function () {
|
test('readonly users may switch current tenant via ChooseTenant', function () {
|
||||||
[$user, $tenantA] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
|
[$user, $tenantA] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$tenantB = Tenant::factory()->create([
|
$tenantB = Tenant::factory()->create([
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
@ -41,7 +41,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('users cannot switch to a tenant they are not a member of', function () {
|
test('users cannot switch to a tenant they are not a member of', function () {
|
||||||
[$user] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
|
[$user] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$tenant = Tenant::factory()->create([
|
$tenant = Tenant::factory()->create([
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
@ -54,7 +54,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('readonly users cannot archive tenants', function () {
|
test('readonly users cannot archive tenants', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
@ -72,7 +72,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('readonly users cannot force delete tenants', function () {
|
test('readonly users cannot force delete tenants', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$tenant->delete();
|
$tenant->delete();
|
||||||
|
|
||||||
@ -90,7 +90,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('readonly users cannot verify tenant configuration', function () {
|
test('readonly users cannot verify tenant configuration', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
@ -104,7 +104,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('readonly users cannot setup intune rbac', function () {
|
test('readonly users cannot setup intune rbac', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
@ -116,7 +116,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('readonly users cannot edit tenants', function () {
|
test('readonly users cannot edit tenants', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
@ -129,7 +129,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('readonly users cannot open admin consent', function () {
|
test('readonly users cannot open admin consent', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
@ -143,7 +143,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('readonly users cannot start tenant sync from tenant menu', function () {
|
test('readonly users cannot start tenant sync from tenant menu', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
|||||||
@ -19,10 +19,10 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
it('shows pending, active, and expired exceptions in the tenant register with lifecycle filters', function (): void {
|
it('shows pending, active, and expired exceptions in the tenant register with lifecycle filters', function (): void {
|
||||||
[$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
[$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$approver = User::factory()->create();
|
$approver = User::factory()->create();
|
||||||
createMinimalUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
|
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
|
||||||
|
|
||||||
$createException = function (array $attributes) use ($tenant, $requester, $approver): FindingException {
|
$createException = function (array $attributes) use ($tenant, $requester, $approver): FindingException {
|
||||||
$finding = Finding::factory()->for($tenant)->create();
|
$finding = Finding::factory()->for($tenant)->create();
|
||||||
@ -79,10 +79,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders exception detail with owner, approver, and validity context for tenant viewers', function (): void {
|
it('renders exception detail with owner, approver, and validity context for tenant viewers', function (): void {
|
||||||
[$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
[$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
$approver = User::factory()->create();
|
$approver = User::factory()->create();
|
||||||
createMinimalUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
|
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create();
|
$finding = Finding::factory()->for($tenant)->create();
|
||||||
|
|
||||||
@ -118,7 +118,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows a single clear empty-state action when no tenant exceptions match', function (): void {
|
it('shows a single clear empty-state action when no tenant exceptions match', function (): void {
|
||||||
[$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$this->actingAs($viewer);
|
$this->actingAs($viewer);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
@ -130,7 +130,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('bridges tenant approval queue links into the admin workspace context', function (): void {
|
it('bridges tenant approval queue links into the admin workspace context', function (): void {
|
||||||
[$viewer, $tenant] = createMinimalUserWithTenant(role: 'owner', workspaceRole: 'owner');
|
[$viewer, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'owner');
|
||||||
|
|
||||||
$otherWorkspace = Workspace::factory()->create();
|
$otherWorkspace = Workspace::factory()->create();
|
||||||
|
|
||||||
@ -151,8 +151,8 @@
|
|||||||
// --- Enterprise UX Hardening (Spec 166 Phase 6b) ---
|
// --- Enterprise UX Hardening (Spec 166 Phase 6b) ---
|
||||||
|
|
||||||
it('shows finding severity badge in exception register table', function (): void {
|
it('shows finding severity badge in exception register table', function (): void {
|
||||||
[$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
[$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create(['severity' => Finding::SEVERITY_HIGH]);
|
$finding = Finding::factory()->for($tenant)->create(['severity' => Finding::SEVERITY_HIGH]);
|
||||||
|
|
||||||
@ -181,8 +181,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows descriptive finding title instead of bare Finding #ID', function (): void {
|
it('shows descriptive finding title instead of bare Finding #ID', function (): void {
|
||||||
[$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
[$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
@ -213,8 +213,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows expires_at column with relative time description', function (): void {
|
it('shows expires_at column with relative time description', function (): void {
|
||||||
[$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
[$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create();
|
$finding = Finding::factory()->for($tenant)->create();
|
||||||
|
|
||||||
@ -250,7 +250,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders stats overview widget above exception register table', function (): void {
|
it('renders stats overview widget above exception register table', function (): void {
|
||||||
[$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$this->actingAs($viewer);
|
$this->actingAs($viewer);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
@ -262,8 +262,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct stats counts for current tenant', function (): void {
|
it('returns correct stats counts for current tenant', function (): void {
|
||||||
[$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
[$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$createException = fn (string $status, string $validity) => FindingException::query()->create([
|
$createException = fn (string $status, string $validity) => FindingException::query()->create([
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
@ -301,8 +301,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('segments exception register with quick-tabs for needs-action, active, and historical', function (): void {
|
it('segments exception register with quick-tabs for needs-action, active, and historical', function (): void {
|
||||||
[$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
[$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$createException = fn (string $status, string $validity) => FindingException::query()->create([
|
$createException = fn (string $status, string $validity) => FindingException::query()->create([
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
|||||||
@ -230,7 +230,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps compare-assigned-tenants visibly disabled with helper text on the matrix for readonly members', function (): void {
|
it('keeps compare-assigned-tenants visibly disabled with helper text on the matrix for readonly members', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->active()->create([
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
@ -261,7 +261,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void {
|
it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->active()->create([
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
@ -310,7 +310,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps backup schedules on clickable-row edit without duplicate Edit actions in More', function (): void {
|
it('keeps backup schedules on clickable-row edit without duplicate Edit actions in More', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$schedule = BackupSchedule::query()->create([
|
$schedule = BackupSchedule::query()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -374,7 +374,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows without extra row actions on backup schedule executions', function (): void {
|
it('uses clickable rows without extra row actions on backup schedule executions', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$schedule = BackupSchedule::query()->create([
|
$schedule = BackupSchedule::query()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -412,7 +412,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows while keeping remove grouped under More on backup items', function (): void {
|
it('uses clickable rows while keeping remove grouped under More on backup items', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$policy = Policy::factory()->create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -479,7 +479,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps tenant memberships inline without a separate inspect affordance', function (): void {
|
it('keeps tenant memberships inline without a separate inspect affordance', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$member = User::factory()->create();
|
$member = User::factory()->create();
|
||||||
$member->tenants()->syncWithoutDetaching([
|
$member->tenants()->syncWithoutDetaching([
|
||||||
@ -564,7 +564,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders the policy versions relation manager on the policy detail page', function (): void {
|
it('renders the policy versions relation manager on the policy detail page', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$policy = Policy::factory()->create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -587,7 +587,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders tenant memberships only on the dedicated memberships page', function (): void {
|
it('renders tenant memberships only on the dedicated memberships page', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$member = User::factory()->create([
|
$member = User::factory()->create([
|
||||||
'email' => 'tenant-members-surface@example.test',
|
'email' => 'tenant-members-surface@example.test',
|
||||||
@ -620,7 +620,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the tenant registry action surface on row inspect plus one safe dashboard shortcut for active tenants', function (): void {
|
it('keeps the tenant registry action surface on row inspect plus one safe dashboard shortcut for active tenants', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setCurrentPanel('admin');
|
Filament::setCurrentPanel('admin');
|
||||||
@ -640,7 +640,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
|
|
||||||
it('keeps tenant detail header actions aligned with the shared administrative family while preserving workflow-heavy exceptions', function (): void {
|
it('keeps tenant detail header actions aligned with the shared administrative family while preserving workflow-heavy exceptions', function (): void {
|
||||||
$tenant = Tenant::factory()->active()->create();
|
$tenant = Tenant::factory()->active()->create();
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(
|
[$user, $tenant] = createUserWithTenant(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
ensureDefaultMicrosoftProviderConnection: false,
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
@ -725,7 +725,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders the backup items relation manager on the backup set detail page', function (): void {
|
it('renders the backup items relation manager on the backup set detail page', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$policy = Policy::factory()->create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -779,7 +779,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps inventory coverage as derived metadata without inspect or row action affordances', function (): void {
|
it('keeps inventory coverage as derived metadata without inspect or row action affordances', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$conditionalAccessKey = 'policy:conditionalAccessPolicy';
|
$conditionalAccessKey = 'policy:conditionalAccessPolicy';
|
||||||
|
|
||||||
@ -1166,7 +1166,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps finding exception v1 list exemptions explicit and omits grouped or bulk mutations', function (): void {
|
it('keeps finding exception v1 list exemptions explicit and omits grouped or bulk mutations', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$declaration = FindingExceptionResource::actionSurfaceDeclaration();
|
$declaration = FindingExceptionResource::actionSurfaceDeclaration();
|
||||||
|
|
||||||
@ -1199,7 +1199,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses More grouping conventions and exposes empty-state CTA on representative CRUD list', function (): void {
|
it('uses More grouping conventions and exposes empty-state CTA on representative CRUD list', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
@ -1230,7 +1230,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps evidence snapshots on the declared clickable-row, two-action surface', function (): void {
|
it('keeps evidence snapshots on the declared clickable-row, two-action surface', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
@ -1276,7 +1276,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows without a duplicate View action on the tenant reviews list', function (): void {
|
it('uses clickable rows without a duplicate View action on the tenant reviews list', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$review = composeTenantReviewForTest($tenant, $user);
|
$review = composeTenantReviewForTest($tenant, $user);
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -1298,7 +1298,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows while keeping direct download and expire shortcuts on the review packs list', function (): void {
|
it('uses clickable rows while keeping direct download and expire shortcuts on the review packs list', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$pack = ReviewPack::factory()->ready()->create([
|
$pack = ReviewPack::factory()->ready()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -1326,7 +1326,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows while grouping restore-run maintenance actions under More', function (): void {
|
it('uses clickable rows while grouping restore-run maintenance actions under More', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$backupSet = BackupSet::factory()->create([
|
$backupSet = BackupSet::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -1382,7 +1382,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps findings on clickable-row inspection with a single related drill-down and grouped workflow actions', function (): void {
|
it('keeps findings on clickable-row inspection with a single related drill-down and grouped workflow actions', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
'status' => Finding::STATUS_NEW,
|
'status' => Finding::STATUS_NEW,
|
||||||
@ -1445,7 +1445,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows with restore as the only inline shortcut on the policy versions relation manager', function (): void {
|
it('uses clickable rows with restore as the only inline shortcut on the policy versions relation manager', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$policy = Policy::factory()->create([
|
$policy = Policy::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -1500,7 +1500,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows without a lone View action on the monitoring operations list', function (): void {
|
it('uses clickable rows without a lone View action on the monitoring operations list', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -1530,7 +1530,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps review and evidence references on clickable-row open without duplicate inspect actions', function (): void {
|
it('keeps review and evidence references on clickable-row open without duplicate inspect actions', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$review = composeTenantReviewForTest($tenant, $user)->load('evidenceSnapshot');
|
$review = composeTenantReviewForTest($tenant, $user)->load('evidenceSnapshot');
|
||||||
$snapshot = $review->evidenceSnapshot;
|
$snapshot = $review->evidenceSnapshot;
|
||||||
@ -1567,7 +1567,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps audit and queue references on explicit inspect without row-click navigation', function (): void {
|
it('keeps audit and queue references on explicit inspect without row-click navigation', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
[$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
$audit = AuditLog::query()->create([
|
$audit = AuditLog::query()->create([
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
@ -1670,7 +1670,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps tenant diagnostics as a singleton repair surface with header actions only', function (): void {
|
it('keeps tenant diagnostics as a singleton repair surface with header actions only', function (): void {
|
||||||
[$manager, $tenant] = createMinimalUserWithTenant(role: 'manager');
|
[$manager, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
$this->actingAs($manager);
|
$this->actingAs($manager);
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
@ -1705,7 +1705,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps required permissions as a guided diagnostic page with inline filters and empty-state guidance', function (): void {
|
it('keeps required permissions as a guided diagnostic page with inline filters and empty-state guidance', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions");
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions");
|
||||||
@ -1889,7 +1889,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('removes lone View buttons and uses clickable rows on the inventory items list', function (): void {
|
it('removes lone View buttons and uses clickable rows on the inventory items list', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
@ -1911,7 +1911,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows without a lone View action on the workspaces list', function (): void {
|
it('uses clickable rows without a lone View action on the workspaces list', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
@ -1946,7 +1946,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows without a lone View action on the policies list', function (): void {
|
it('uses clickable rows without a lone View action on the policies list', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$policy = Policy::query()->create([
|
$policy = Policy::query()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -1997,7 +1997,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows without a duplicate Edit action on the alert rules list', function (): void {
|
it('uses clickable rows without a duplicate Edit action on the alert rules list', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$workspaceId = (int) $tenant->workspace_id;
|
$workspaceId = (int) $tenant->workspace_id;
|
||||||
$rule = AlertRule::factory()->create([
|
$rule = AlertRule::factory()->create([
|
||||||
@ -2036,7 +2036,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows without a duplicate Edit action on the alert destinations list', function (): void {
|
it('uses clickable rows without a duplicate Edit action on the alert destinations list', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$workspaceId = (int) $tenant->workspace_id;
|
$workspaceId = (int) $tenant->workspace_id;
|
||||||
$destination = AlertDestination::factory()->create([
|
$destination = AlertDestination::factory()->create([
|
||||||
@ -2075,7 +2075,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable-row view with all secondary provider connection actions grouped under More', function (): void {
|
it('uses clickable-row view with all secondary provider connection actions grouped under More', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -2129,7 +2129,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps provider connection detail secondary actions aligned under More', function (): void {
|
it('keeps provider connection detail secondary actions aligned under More', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -2186,7 +2186,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses clickable rows without extra row actions on the alert deliveries list', function (): void {
|
it('uses clickable rows without extra row actions on the alert deliveries list', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$workspaceId = (int) $tenant->workspace_id;
|
$workspaceId = (int) $tenant->workspace_id;
|
||||||
$rule = AlertRule::factory()->create([
|
$rule = AlertRule::factory()->create([
|
||||||
@ -2219,7 +2219,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
Queue::fake();
|
Queue::fake();
|
||||||
bindFailHardGraphClient();
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
@ -2314,7 +2314,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps spec 193 hierarchy work from expanding confirmation, reason capture, or compare-start semantics', function (): void {
|
it('keeps spec 193 hierarchy work from expanding confirmation, reason capture, or compare-start semantics', function (): void {
|
||||||
[$approver, $tenant] = createMinimalUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
[$approver, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
$mountedActionFieldNames = static function (mixed $component): array {
|
$mountedActionFieldNames = static function (mixed $component): array {
|
||||||
$method = new \ReflectionMethod($component->instance(), 'getMountedActionForm');
|
$method = new \ReflectionMethod($component->instance(), 'getMountedActionForm');
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Tests\Support\TestLaneManifest;
|
|
||||||
|
|
||||||
it('keeps browser tests isolated behind their dedicated lane', function (): void {
|
|
||||||
$lane = TestLaneManifest::lane('browser');
|
|
||||||
$files = new Collection(TestLaneManifest::discoverFiles('browser'));
|
|
||||||
|
|
||||||
expect($lane['includedFamilies'])->toContain('browser')
|
|
||||||
->and($lane['defaultEntryPoint'])->toBeFalse()
|
|
||||||
->and($files)->not->toBeEmpty()
|
|
||||||
->and($files->every(static fn (string $path): bool => str_starts_with($path, 'tests/Browser/')))->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the browser lane routed to the browser suite and out of the default loops', function (): void {
|
|
||||||
expect(TestLaneManifest::buildCommand('browser'))->toContain('--group=browser')
|
|
||||||
->and(TestLaneManifest::buildCommand('browser'))->toContain('--testsuite=Browser');
|
|
||||||
|
|
||||||
foreach (TestLaneManifest::discoverFiles('fast-feedback') as $path) {
|
|
||||||
expect($path)->not->toStartWith('tests/Browser/');
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (TestLaneManifest::discoverFiles('confidence') as $path) {
|
|
||||||
expect($path)->not->toStartWith('tests/Browser/');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Tests\Support\TestLaneManifest;
|
|
||||||
|
|
||||||
it('keeps confidence broader than fast-feedback while excluding browser and heavy-governance', function (): void {
|
|
||||||
$lane = TestLaneManifest::lane('confidence');
|
|
||||||
$command = TestLaneManifest::buildCommand('confidence');
|
|
||||||
|
|
||||||
expect($lane['parallelMode'])->toBe('required')
|
|
||||||
->and($lane['includedFamilies'])->toContain('unit', 'non-browser-feature-integration')
|
|
||||||
->and($lane['excludedFamilies'])->toContain('browser', 'heavy-governance')
|
|
||||||
->and($lane['budget']['thresholdSeconds'])->toBeLessThan(TestLaneManifest::fullSuiteBaselineSeconds())
|
|
||||||
->and($command)->toContain('--parallel')
|
|
||||||
->and($command)->toContain('--testsuite=Unit,Feature')
|
|
||||||
->and(implode(' ', $command))->toContain('--exclude-group=browser,heavy-governance');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps confidence discovery free of the initial heavy-governance batch', function (): void {
|
|
||||||
$files = TestLaneManifest::discoverFiles('confidence');
|
|
||||||
|
|
||||||
expect($files)->not->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php')
|
|
||||||
->and($files)->not->toContain('tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php')
|
|
||||||
->and($files)->not->toContain('tests/Feature/ProviderConnections/CredentialLeakGuardTest.php')
|
|
||||||
->and($files)->not->toContain('tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php');
|
|
||||||
});
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Tests\Support\TestLaneManifest;
|
|
||||||
|
|
||||||
it('keeps fast-feedback as the default parallel contributor loop', function (): void {
|
|
||||||
$lane = TestLaneManifest::lane('fast-feedback');
|
|
||||||
$command = TestLaneManifest::buildCommand('fast-feedback');
|
|
||||||
|
|
||||||
expect($lane['defaultEntryPoint'])->toBeTrue()
|
|
||||||
->and($lane['parallelMode'])->toBe('required')
|
|
||||||
->and($lane['includedFamilies'])->toContain('unit')
|
|
||||||
->and($lane['excludedFamilies'])->toContain('browser', 'heavy-governance')
|
|
||||||
->and($lane['budget']['baselineDeltaTargetPercent'])->toBe(50)
|
|
||||||
->and(TestLaneManifest::commandRef('fast-feedback'))->toBe('test')
|
|
||||||
->and($command)->toContain('--group=fast-feedback')
|
|
||||||
->and($command)->toContain('--parallel')
|
|
||||||
->and($command)->toContain('--testsuite=Unit,Feature')
|
|
||||||
->and(implode(' ', $command))->toContain('--exclude-group=browser,heavy-governance');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps fast-feedback narrower than the broader confidence lane', function (): void {
|
|
||||||
$fastTargets = TestLaneManifest::discoverFiles('fast-feedback');
|
|
||||||
$confidenceTargets = TestLaneManifest::discoverFiles('confidence');
|
|
||||||
|
|
||||||
expect($fastTargets)->not->toBeEmpty()
|
|
||||||
->and(count($fastTargets))->toBeLessThan(count($confidenceTargets));
|
|
||||||
});
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Tests\Support\TestLaneManifest;
|
|
||||||
|
|
||||||
it('excludes browser and initial heavy-governance families from fast-feedback discovery', function (): void {
|
|
||||||
$files = collect(TestLaneManifest::discoverFiles('fast-feedback'));
|
|
||||||
|
|
||||||
expect($files->contains(static fn (string $path): bool => str_starts_with($path, 'tests/Browser/')))->toBeFalse()
|
|
||||||
->and($files->contains(static fn (string $path): bool => str_starts_with($path, 'tests/Feature/OpsUx/')))->toBeFalse()
|
|
||||||
->and($files)->not->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php')
|
|
||||||
->and($files->contains(static fn (string $path): bool => str_starts_with($path, 'tests/Architecture/')))->toBeFalse()
|
|
||||||
->and($files->contains(static fn (string $path): bool => str_starts_with($path, 'tests/Deprecation/')))->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps fast-feedback focused on the quick-edit families the manifest declares', function (): void {
|
|
||||||
$files = new Collection(TestLaneManifest::discoverFiles('fast-feedback'));
|
|
||||||
|
|
||||||
expect($files->contains(static fn (string $path): bool => str_starts_with($path, 'tests/Unit/')))->toBeTrue()
|
|
||||||
->and($files->contains(static fn (string $path): bool => str_starts_with($path, 'tests/Feature/Guards/')))->toBeTrue();
|
|
||||||
});
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
it('keeps the shared tenant helper profile matrix explicit and reviewable', function (): void {
|
|
||||||
$profiles = createUserWithTenantProfiles();
|
|
||||||
|
|
||||||
expect($profiles)->toHaveKeys([
|
|
||||||
'minimal',
|
|
||||||
'provider-enabled',
|
|
||||||
'credential-enabled',
|
|
||||||
'ui-context',
|
|
||||||
'heavy',
|
|
||||||
])
|
|
||||||
->and($profiles['minimal'])->toMatchArray([
|
|
||||||
'workspace' => true,
|
|
||||||
'membership' => true,
|
|
||||||
'session' => true,
|
|
||||||
'provider' => false,
|
|
||||||
'credential' => false,
|
|
||||||
'cache' => false,
|
|
||||||
'uiContext' => false,
|
|
||||||
])
|
|
||||||
->and($profiles['provider-enabled']['provider'])->toBeTrue()
|
|
||||||
->and($profiles['credential-enabled']['credential'])->toBeTrue()
|
|
||||||
->and($profiles['ui-context']['uiContext'])->toBeTrue()
|
|
||||||
->and($profiles['heavy']['cache'])->toBeTrue();
|
|
||||||
});
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Tests\Support\TestLaneManifest;
|
|
||||||
|
|
||||||
it('routes the initial architecture, deprecation, ops-ux, and action-surface batch into heavy-governance', function (): void {
|
|
||||||
$lane = TestLaneManifest::lane('heavy-governance');
|
|
||||||
$files = new Collection(TestLaneManifest::discoverFiles('heavy-governance'));
|
|
||||||
|
|
||||||
expect($lane['includedFamilies'])->toContain('architecture-governance', 'ops-ux')
|
|
||||||
->and($lane['selectors']['includeGroups'])->toContain('heavy-governance')
|
|
||||||
->and($files)->toContain('tests/Architecture/PlatformVocabularyBoundaryGuardTest.php')
|
|
||||||
->and($files)->toContain('tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php')
|
|
||||||
->and($files)->toContain('tests/Deprecation/IsPlatformSuperadminDeprecationTest.php')
|
|
||||||
->and($files)->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the heavy-governance command group-driven for intentionally expensive families', function (): void {
|
|
||||||
$command = TestLaneManifest::buildCommand('heavy-governance');
|
|
||||||
|
|
||||||
expect($command)->toContain('--group=heavy-governance')
|
|
||||||
->and(TestLaneManifest::commandRef('heavy-governance'))->toBe('test:heavy');
|
|
||||||
});
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Tests\Support\TestLaneManifest;
|
|
||||||
|
|
||||||
it('keeps profiling serial and artifact-rich for slow-test drift analysis', function (): void {
|
|
||||||
$lane = TestLaneManifest::lane('profiling');
|
|
||||||
$command = TestLaneManifest::buildCommand('profiling');
|
|
||||||
|
|
||||||
expect($lane['governanceClass'])->toBe('support')
|
|
||||||
->and($lane['parallelMode'])->toBe('forbidden')
|
|
||||||
->and($lane['artifacts'])->toContain('profile-top', 'junit-xml', 'summary', 'budget-report')
|
|
||||||
->and($command)->toContain('--profile')
|
|
||||||
->and($command)->not->toContain('--parallel')
|
|
||||||
->and(implode(' ', $command))->toContain('--exclude-group=browser');
|
|
||||||
});
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Tests\Support\TestLaneReport;
|
|
||||||
|
|
||||||
it('keeps lane artifact paths app-root relative under storage/logs/test-lanes', function (): void {
|
|
||||||
$artifacts = TestLaneReport::artifactPaths('fast-feedback');
|
|
||||||
|
|
||||||
expect($artifacts)->toHaveKeys(['junit', 'summary', 'budget', 'report', 'profile']);
|
|
||||||
|
|
||||||
foreach (array_values($artifacts) as $relativePath) {
|
|
||||||
expect($relativePath)->toStartWith('storage/logs/test-lanes/');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps only the skeleton file checked into the lane artifact directory', function (): void {
|
|
||||||
$gitignore = base_path('storage/logs/test-lanes/.gitignore');
|
|
||||||
|
|
||||||
expect(file_exists($gitignore))->toBeTrue()
|
|
||||||
->and((string) file_get_contents($gitignore))->toContain('*')
|
|
||||||
->and((string) file_get_contents($gitignore))->toContain('!.gitignore');
|
|
||||||
});
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Tests\Support\TestLaneManifest;
|
|
||||||
|
|
||||||
it('keeps composer lane commands wired to the checked-in lane inventory', function (): void {
|
|
||||||
$composer = json_decode((string) file_get_contents(base_path('composer.json')), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
$scripts = $composer['scripts'] ?? [];
|
|
||||||
|
|
||||||
expect($scripts)->toHaveKeys([
|
|
||||||
'test',
|
|
||||||
'test:fast',
|
|
||||||
'test:confidence',
|
|
||||||
'test:browser',
|
|
||||||
'test:heavy',
|
|
||||||
'test:profile',
|
|
||||||
'test:junit',
|
|
||||||
'sail:test',
|
|
||||||
])
|
|
||||||
->and(TestLaneManifest::commandRef('fast-feedback'))->toBe('test')
|
|
||||||
->and(TestLaneManifest::commandRef('confidence'))->toBe('test:confidence')
|
|
||||||
->and(TestLaneManifest::commandRef('browser'))->toBe('test:browser')
|
|
||||||
->and(TestLaneManifest::commandRef('heavy-governance'))->toBe('test:heavy')
|
|
||||||
->and(TestLaneManifest::commandRef('profiling'))->toBe('test:profile')
|
|
||||||
->and(TestLaneManifest::commandRef('junit'))->toBe('test:junit');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the host-side lane runner and report scripts checked in at repo root', function (): void {
|
|
||||||
expect(file_exists(repo_path('scripts/platform-test-lane')))->toBeTrue()
|
|
||||||
->and(file_exists(repo_path('scripts/platform-test-report')))->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('routes the foundational lane commands through stable artisan arguments', function (): void {
|
|
||||||
expect(TestLaneManifest::buildCommand('fast-feedback'))->toContain('--parallel')
|
|
||||||
->and(TestLaneManifest::buildCommand('fast-feedback'))->toContain('--group=fast-feedback')
|
|
||||||
->and(TestLaneManifest::buildCommand('fast-feedback'))->toContain('--testsuite=Unit,Feature')
|
|
||||||
->and(TestLaneManifest::buildCommand('confidence'))->toContain('--testsuite=Unit,Feature')
|
|
||||||
->and(TestLaneManifest::buildCommand('browser'))->toContain('--group=browser')
|
|
||||||
->and(TestLaneManifest::buildCommand('browser'))->toContain('--testsuite=Browser')
|
|
||||||
->and(TestLaneManifest::buildCommand('heavy-governance'))->toContain('--group=heavy-governance')
|
|
||||||
->and(TestLaneManifest::buildCommand('junit'))->toContain('--parallel');
|
|
||||||
});
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Tests\Support\TestLaneManifest;
|
|
||||||
|
|
||||||
it('declares the six checked-in lanes with a single fast-feedback default', function (): void {
|
|
||||||
$manifest = TestLaneManifest::manifest();
|
|
||||||
$laneIds = array_column($manifest['lanes'], 'id');
|
|
||||||
$defaultLanes = array_values(array_filter(
|
|
||||||
$manifest['lanes'],
|
|
||||||
static fn (array $lane): bool => $lane['defaultEntryPoint'] === true,
|
|
||||||
));
|
|
||||||
|
|
||||||
expect($manifest['version'])->toBe(1)
|
|
||||||
->and($manifest['artifactDirectory'])->toBe('storage/logs/test-lanes')
|
|
||||||
->and($laneIds)->toEqualCanonicalizing([
|
|
||||||
'fast-feedback',
|
|
||||||
'confidence',
|
|
||||||
'browser',
|
|
||||||
'heavy-governance',
|
|
||||||
'profiling',
|
|
||||||
'junit',
|
|
||||||
])
|
|
||||||
->and($defaultLanes)->toHaveCount(1)
|
|
||||||
->and($defaultLanes[0]['id'])->toBe('fast-feedback');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps every lane declaration populated with governance metadata, selectors, and budgets', function (): void {
|
|
||||||
foreach (TestLaneManifest::manifest()['lanes'] as $lane) {
|
|
||||||
expect(trim($lane['description']))->not->toBe('')
|
|
||||||
->and(trim($lane['intendedAudience']))->not->toBe('')
|
|
||||||
->and($lane['includedFamilies'])->not->toBeEmpty()
|
|
||||||
->and($lane['ownershipExpectations'])->not->toBe('')
|
|
||||||
->and($lane['artifacts'])->not->toBeEmpty()
|
|
||||||
->and($lane['budget']['thresholdSeconds'])->toBeGreaterThan(0)
|
|
||||||
->and($lane['budget']['baselineSource'])->toBeString()
|
|
||||||
->and($lane['dbStrategy']['connectionMode'])->toBeString();
|
|
||||||
|
|
||||||
$selectors = $lane['selectors'];
|
|
||||||
|
|
||||||
foreach ([
|
|
||||||
'includeSuites',
|
|
||||||
'includePaths',
|
|
||||||
'includeGroups',
|
|
||||||
'includeFiles',
|
|
||||||
'excludeSuites',
|
|
||||||
'excludePaths',
|
|
||||||
'excludeGroups',
|
|
||||||
'excludeFiles',
|
|
||||||
] as $selectorKey) {
|
|
||||||
expect($selectors)->toHaveKey($selectorKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('seeds at least one initial heavy family budget beside the lane-level budgets', function (): void {
|
|
||||||
$familyBudgets = TestLaneManifest::familyBudgets();
|
|
||||||
|
|
||||||
expect($familyBudgets)->not->toBeEmpty()
|
|
||||||
->and($familyBudgets[0]['familyId'])->toBeString()
|
|
||||||
->and($familyBudgets[0]['selectors'])->not->toBeEmpty()
|
|
||||||
->and($familyBudgets[0]['thresholdSeconds'])->toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Tests\Support\TestLaneManifest;
|
|
||||||
|
|
||||||
it('keeps the first audited heavy batch out of the confidence lane and inside heavy-governance', function (): void {
|
|
||||||
$confidenceFiles = TestLaneManifest::discoverFiles('confidence');
|
|
||||||
$heavyFiles = TestLaneManifest::discoverFiles('heavy-governance');
|
|
||||||
|
|
||||||
expect($confidenceFiles)->not->toContain('tests/Architecture/PlatformVocabularyBoundaryGuardTest.php')
|
|
||||||
->and($confidenceFiles)->not->toContain('tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php')
|
|
||||||
->and($confidenceFiles)->not->toContain('tests/Deprecation/IsPlatformSuperadminDeprecationTest.php')
|
|
||||||
->and($confidenceFiles)->not->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php')
|
|
||||||
->and($heavyFiles)->toContain('tests/Architecture/PlatformVocabularyBoundaryGuardTest.php')
|
|
||||||
->and($heavyFiles)->toContain('tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php')
|
|
||||||
->and($heavyFiles)->toContain('tests/Deprecation/IsPlatformSuperadminDeprecationTest.php')
|
|
||||||
->and($heavyFiles)->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php');
|
|
||||||
});
|
|
||||||
@ -20,7 +20,7 @@
|
|||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
|
||||||
it('renders operate hub pages DB-only with no outbound HTTP and no queued jobs', function (): void {
|
it('renders operate hub pages DB-only with no outbound HTTP and no queued jobs', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -75,7 +75,7 @@
|
|||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('shows back to tenant on run detail when tenant context is active and entitled', function (): void {
|
it('shows back to tenant on run detail when tenant context is active and entitled', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -109,7 +109,7 @@
|
|||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('shows back to tenant when filament tenant is absent but last tenant memory exists', function (): void {
|
it('shows back to tenant when filament tenant is absent but last tenant memory exists', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -139,7 +139,7 @@
|
|||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('shows no tenant return affordance when active and last tenant contexts are not entitled', function (): void {
|
it('shows no tenant return affordance when active and last tenant contexts are not entitled', function (): void {
|
||||||
[$user, $entitledTenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $entitledTenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$nonEntitledTenant = Tenant::factory()->create([
|
$nonEntitledTenant = Tenant::factory()->create([
|
||||||
'workspace_id' => (int) $entitledTenant->workspace_id,
|
'workspace_id' => (int) $entitledTenant->workspace_id,
|
||||||
@ -171,7 +171,7 @@
|
|||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('renders shared scope and return copy as secondary monitoring context on operations surfaces', function (): void {
|
it('renders shared scope and return copy as secondary monitoring context on operations surfaces', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -229,7 +229,7 @@
|
|||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('keeps member-without-capability workflow start denial as 403 with no run side effects', function (): void {
|
it('keeps member-without-capability workflow start denial as 403 with no run side effects', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'operator');
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
@ -244,7 +244,7 @@
|
|||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('does not mutate workspace or last-tenant session memory on /admin/operations', function (): void {
|
it('does not mutate workspace or last-tenant session memory on /admin/operations', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
@ -264,13 +264,13 @@
|
|||||||
|
|
||||||
it('prefers the filament tenant over remembered workspace tenant state when both are entitled', function (): void {
|
it('prefers the filament tenant over remembered workspace tenant state when both are entitled', function (): void {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
[$user, $tenantA] = createMinimalUserWithTenant(tenant: $tenantA, role: 'owner');
|
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||||
|
|
||||||
$tenantB = Tenant::factory()->create([
|
$tenantB = Tenant::factory()->create([
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
createMinimalUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant($tenantB, true);
|
Filament::setTenant($tenantB, true);
|
||||||
@ -289,13 +289,13 @@
|
|||||||
$runTenant = Tenant::factory()->create([
|
$runTenant = Tenant::factory()->create([
|
||||||
'workspace_id' => null,
|
'workspace_id' => null,
|
||||||
]);
|
]);
|
||||||
[$user, $runTenant] = createMinimalUserWithTenant(tenant: $runTenant, role: 'owner');
|
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
|
||||||
|
|
||||||
$currentTenant = Tenant::factory()->create([
|
$currentTenant = Tenant::factory()->create([
|
||||||
'workspace_id' => (int) $runTenant->workspace_id,
|
'workspace_id' => (int) $runTenant->workspace_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
createMinimalUserWithTenant(tenant: $currentTenant, user: $user, role: 'owner');
|
createUserWithTenant(tenant: $currentTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
'workspace_id' => (int) $runTenant->workspace_id,
|
'workspace_id' => (int) $runTenant->workspace_id,
|
||||||
@ -328,14 +328,14 @@
|
|||||||
$runTenant = Tenant::factory()->active()->create([
|
$runTenant = Tenant::factory()->active()->create([
|
||||||
'name' => 'Canonical Run Tenant',
|
'name' => 'Canonical Run Tenant',
|
||||||
]);
|
]);
|
||||||
[$user, $runTenant] = createMinimalUserWithTenant(tenant: $runTenant, role: 'owner');
|
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
|
||||||
|
|
||||||
$currentTenant = Tenant::factory()->onboarding()->create([
|
$currentTenant = Tenant::factory()->onboarding()->create([
|
||||||
'workspace_id' => (int) $runTenant->workspace_id,
|
'workspace_id' => (int) $runTenant->workspace_id,
|
||||||
'name' => 'Current Onboarding Tenant',
|
'name' => 'Current Onboarding Tenant',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
createMinimalUserWithTenant(
|
createUserWithTenant(
|
||||||
tenant: $currentTenant,
|
tenant: $currentTenant,
|
||||||
user: $user,
|
user: $user,
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
@ -371,7 +371,7 @@
|
|||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('clears stale remembered tenant ids when the remembered tenant is no longer entitled', function (): void {
|
it('clears stale remembered tenant ids when the remembered tenant is no longer entitled', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$staleTenant = Tenant::factory()->create([
|
$staleTenant = Tenant::factory()->create([
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
@ -400,7 +400,7 @@
|
|||||||
'environment' => 'dev',
|
'environment' => 'dev',
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
[$user, $rememberedTenant] = createMinimalUserWithTenant(tenant: $rememberedTenant, role: 'owner');
|
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
|
||||||
|
|
||||||
$routedTenant = Tenant::factory()->create([
|
$routedTenant = Tenant::factory()->create([
|
||||||
'workspace_id' => (int) $rememberedTenant->workspace_id,
|
'workspace_id' => (int) $rememberedTenant->workspace_id,
|
||||||
@ -409,7 +409,7 @@
|
|||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
createMinimalUserWithTenant(tenant: $routedTenant, user: $user, role: 'owner');
|
createUserWithTenant(tenant: $routedTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
@ -438,7 +438,7 @@
|
|||||||
'environment' => 'dev',
|
'environment' => 'dev',
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
[$user, $rememberedTenant] = createMinimalUserWithTenant(tenant: $rememberedTenant, role: 'owner');
|
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
|
||||||
|
|
||||||
$routedTenant = Tenant::factory()->create([
|
$routedTenant = Tenant::factory()->create([
|
||||||
'workspace_id' => (int) $rememberedTenant->workspace_id,
|
'workspace_id' => (int) $rememberedTenant->workspace_id,
|
||||||
@ -447,7 +447,7 @@
|
|||||||
'status' => Tenant::STATUS_ONBOARDING,
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
createMinimalUserWithTenant(tenant: $routedTenant, user: $user, role: 'owner');
|
createUserWithTenant(tenant: $routedTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant($rememberedTenant, true);
|
Filament::setTenant($rememberedTenant, true);
|
||||||
@ -471,7 +471,7 @@
|
|||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('shows tenant filter label when tenant context is active', function (): void {
|
it('shows tenant filter label when tenant context is active', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
@ -487,7 +487,7 @@
|
|||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('does not create audit entries when viewing operate hub pages', function (): void {
|
it('does not create audit entries when viewing operate hub pages', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
@ -527,7 +527,7 @@
|
|||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('suppresses tenant indicator on alert rules list page (manage page)', function (): void {
|
it('suppresses tenant indicator on alert rules list page (manage page)', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
@ -542,7 +542,7 @@
|
|||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('suppresses tenant indicator on alert destinations list page (manage page)', function (): void {
|
it('suppresses tenant indicator on alert destinations list page (manage page)', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
@ -557,7 +557,7 @@
|
|||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('suppresses tenant indicator on alert rules list with lastTenantId fallback', function (): void {
|
it('suppresses tenant indicator on alert rules list with lastTenantId fallback', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
@ -578,14 +578,14 @@
|
|||||||
$runTenant = Tenant::factory()->active()->create([
|
$runTenant = Tenant::factory()->active()->create([
|
||||||
'name' => 'Canonical Run Tenant',
|
'name' => 'Canonical Run Tenant',
|
||||||
]);
|
]);
|
||||||
[$user, $runTenant] = createMinimalUserWithTenant(tenant: $runTenant, role: 'owner');
|
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
|
||||||
|
|
||||||
$rememberedTenant = Tenant::factory()->onboarding()->create([
|
$rememberedTenant = Tenant::factory()->onboarding()->create([
|
||||||
'workspace_id' => (int) $runTenant->workspace_id,
|
'workspace_id' => (int) $runTenant->workspace_id,
|
||||||
'name' => 'Stale Onboarding Tenant',
|
'name' => 'Stale Onboarding Tenant',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
createMinimalUserWithTenant(
|
createUserWithTenant(
|
||||||
tenant: $rememberedTenant,
|
tenant: $rememberedTenant,
|
||||||
user: $user,
|
user: $user,
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
|
|||||||
@ -29,7 +29,7 @@ function buildJobComparison(array $permissions = [], string $overallStatus = 'mi
|
|||||||
|
|
||||||
// (1) Successful run creates OperationRun with correct type and outcome
|
// (1) Successful run creates OperationRun with correct type and outcome
|
||||||
it('creates OperationRun with correct type and outcome on success', function (): void {
|
it('creates OperationRun with correct type and outcome on success', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$comparison = buildJobComparison([
|
$comparison = buildJobComparison([
|
||||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']],
|
['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']],
|
||||||
@ -79,7 +79,7 @@ function buildJobComparison(array $permissions = [], string $overallStatus = 'mi
|
|||||||
|
|
||||||
// (3) Records summary counts on OperationRun
|
// (3) Records summary counts on OperationRun
|
||||||
it('records summary counts on OperationRun', function (): void {
|
it('records summary counts on OperationRun', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$comparison = buildJobComparison([
|
$comparison = buildJobComparison([
|
||||||
['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']],
|
['key' => 'Perm.A', 'type' => 'application', 'status' => 'missing', 'features' => ['a']],
|
||||||
@ -107,7 +107,7 @@ function buildJobComparison(array $permissions = [], string $overallStatus = 'mi
|
|||||||
|
|
||||||
// (4) Handles generator exceptions gracefully
|
// (4) Handles generator exceptions gracefully
|
||||||
it('marks OperationRun as failed on exception', function (): void {
|
it('marks OperationRun as failed on exception', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$this->mock(FindingGeneratorContract::class, function (MockInterface $mock): void {
|
$this->mock(FindingGeneratorContract::class, function (MockInterface $mock): void {
|
||||||
$mock->shouldReceive('generate')->andThrow(new RuntimeException('Test error'));
|
$mock->shouldReceive('generate')->andThrow(new RuntimeException('Test error'));
|
||||||
@ -140,7 +140,7 @@ function buildJobComparison(array $permissions = [], string $overallStatus = 'mi
|
|||||||
it('dispatches posture job from health check job', function (): void {
|
it('dispatches posture job from health check job', function (): void {
|
||||||
Queue::fake([GeneratePermissionPostureFindingsJob::class]);
|
Queue::fake([GeneratePermissionPostureFindingsJob::class]);
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
// The actual dispatch is tested by verifying the hook exists in the source
|
// The actual dispatch is tested by verifying the hook exists in the source
|
||||||
// (integration test will cover the full flow in T033)
|
// (integration test will cover the full flow in T033)
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
it('full posture check flow: findings, report, score, operations', function (): void {
|
it('full posture check flow: findings, report, score, operations', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$comparison = [
|
$comparison = [
|
||||||
'overall_status' => 'missing',
|
'overall_status' => 'missing',
|
||||||
@ -77,7 +77,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('second run auto-resolves findings for newly granted permissions', function (): void {
|
it('second run auto-resolves findings for newly granted permissions', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$generator = app(FindingGeneratorContract::class);
|
$generator = app(FindingGeneratorContract::class);
|
||||||
|
|
||||||
@ -124,7 +124,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('alert events are produced for new missing permission findings', function (): void {
|
it('alert events are produced for new missing permission findings', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|
||||||
$generator = app(FindingGeneratorContract::class);
|
$generator = app(FindingGeneratorContract::class);
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
describe('Provider connections create action UI enforcement', function () {
|
describe('Provider connections create action UI enforcement', function () {
|
||||||
it('shows create action as visible but disabled for readonly members', function () {
|
it('shows create action as visible but disabled for readonly members', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'provider-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
@ -22,7 +22,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows create action as enabled for owner members', function () {
|
it('shows create action as enabled for owner members', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'provider-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
@ -34,7 +34,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('hides create action after membership is revoked mid-session', function () {
|
it('hides create action after membership is revoked mid-session', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'provider-enabled');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use App\Models\TenantPermission;
|
use App\Models\TenantPermission;
|
||||||
|
|
||||||
it('renders required permissions overview with missing-first ordering and feature cards', function (): void {
|
it('renders required permissions overview with missing-first ordering and clickable feature cards', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$configured = config('intune_permissions.permissions', []);
|
$configured = config('intune_permissions.permissions', []);
|
||||||
@ -29,5 +29,6 @@
|
|||||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=all")
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=all")
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Blocked', false)
|
->assertSee('Blocked', false)
|
||||||
|
->assertSee('applyFeatureFilter', false)
|
||||||
->assertSeeInOrder([$missingKey, $grantedKey], false);
|
->assertSeeInOrder([$missingKey, $grantedKey], false);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -75,41 +75,6 @@
|
|||||||
pest()->extend(Tests\TestCase::class)
|
pest()->extend(Tests\TestCase::class)
|
||||||
->in('Deprecation');
|
->in('Deprecation');
|
||||||
|
|
||||||
pest()->group('browser')
|
|
||||||
->in('Browser');
|
|
||||||
|
|
||||||
pest()->group('fast-feedback')
|
|
||||||
->in(
|
|
||||||
'Unit',
|
|
||||||
'Feature/Auth',
|
|
||||||
'Feature/Authorization',
|
|
||||||
'Feature/EntraAdminRoles',
|
|
||||||
'Feature/Findings',
|
|
||||||
'Feature/Guards',
|
|
||||||
'Feature/Monitoring',
|
|
||||||
'Feature/Navigation',
|
|
||||||
'Feature/Onboarding',
|
|
||||||
'Feature/RequiredPermissions',
|
|
||||||
'Feature/Tenants',
|
|
||||||
'Feature/Workspaces',
|
|
||||||
'Feature/AdminConsentCallbackTest.php',
|
|
||||||
'Feature/AdminNewRedirectTest.php',
|
|
||||||
);
|
|
||||||
|
|
||||||
pest()->group('heavy-governance')
|
|
||||||
->in(
|
|
||||||
'Architecture',
|
|
||||||
'Deprecation',
|
|
||||||
'Feature/078',
|
|
||||||
'Feature/090',
|
|
||||||
'Feature/144',
|
|
||||||
'Feature/OpsUx',
|
|
||||||
'Feature/Filament/Alerts/AlertsKpiHeaderTest.php',
|
|
||||||
'Feature/Guards/ActionSurfaceContractTest.php',
|
|
||||||
'Feature/Guards/OperationLifecycleOpsUxGuardTest.php',
|
|
||||||
'Feature/ProviderConnections/CredentialLeakGuardTest.php',
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
putenv('INTUNE_TENANT_ID');
|
putenv('INTUNE_TENANT_ID');
|
||||||
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||||
@ -368,88 +333,6 @@ function createInventorySyncOperationRunWithCoverage(
|
|||||||
return createInventorySyncOperationRun($tenant, $attributes);
|
return createInventorySyncOperationRun($tenant, $attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array{workspace: bool, membership: bool, session: bool, provider: bool, credential: bool, cache: bool, uiContext: bool}>
|
|
||||||
*/
|
|
||||||
function createUserWithTenantProfiles(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'minimal' => [
|
|
||||||
'workspace' => true,
|
|
||||||
'membership' => true,
|
|
||||||
'session' => true,
|
|
||||||
'provider' => false,
|
|
||||||
'credential' => false,
|
|
||||||
'cache' => false,
|
|
||||||
'uiContext' => false,
|
|
||||||
],
|
|
||||||
'provider-enabled' => [
|
|
||||||
'workspace' => true,
|
|
||||||
'membership' => true,
|
|
||||||
'session' => true,
|
|
||||||
'provider' => true,
|
|
||||||
'credential' => false,
|
|
||||||
'cache' => false,
|
|
||||||
'uiContext' => false,
|
|
||||||
],
|
|
||||||
'credential-enabled' => [
|
|
||||||
'workspace' => true,
|
|
||||||
'membership' => true,
|
|
||||||
'session' => true,
|
|
||||||
'provider' => true,
|
|
||||||
'credential' => true,
|
|
||||||
'cache' => false,
|
|
||||||
'uiContext' => false,
|
|
||||||
],
|
|
||||||
'ui-context' => [
|
|
||||||
'workspace' => true,
|
|
||||||
'membership' => true,
|
|
||||||
'session' => true,
|
|
||||||
'provider' => false,
|
|
||||||
'credential' => false,
|
|
||||||
'cache' => true,
|
|
||||||
'uiContext' => true,
|
|
||||||
],
|
|
||||||
'heavy' => [
|
|
||||||
'workspace' => true,
|
|
||||||
'membership' => true,
|
|
||||||
'session' => true,
|
|
||||||
'provider' => true,
|
|
||||||
'credential' => true,
|
|
||||||
'cache' => true,
|
|
||||||
'uiContext' => true,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: User, 1: Tenant}
|
|
||||||
*/
|
|
||||||
function createMinimalUserWithTenant(
|
|
||||||
?Tenant $tenant = null,
|
|
||||||
?User $user = null,
|
|
||||||
string $role = 'owner',
|
|
||||||
?string $workspaceRole = null,
|
|
||||||
bool $ensureDefaultMicrosoftProviderConnection = false,
|
|
||||||
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
|
|
||||||
bool $ensureDefaultCredential = false,
|
|
||||||
?bool $clearCapabilityCaches = null,
|
|
||||||
?bool $setUiContext = null,
|
|
||||||
): array {
|
|
||||||
return createUserWithTenant(
|
|
||||||
tenant: $tenant,
|
|
||||||
user: $user,
|
|
||||||
role: $role,
|
|
||||||
workspaceRole: $workspaceRole,
|
|
||||||
ensureDefaultMicrosoftProviderConnection: $ensureDefaultMicrosoftProviderConnection,
|
|
||||||
defaultProviderConnectionType: $defaultProviderConnectionType,
|
|
||||||
fixtureProfile: 'minimal',
|
|
||||||
ensureDefaultCredential: $ensureDefaultCredential,
|
|
||||||
clearCapabilityCaches: $clearCapabilityCaches,
|
|
||||||
setUiContext: $setUiContext,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{0: User, 1: Tenant}
|
* @return array{0: User, 1: Tenant}
|
||||||
*/
|
*/
|
||||||
@ -458,20 +341,9 @@ function createUserWithTenant(
|
|||||||
?User $user = null,
|
?User $user = null,
|
||||||
string $role = 'owner',
|
string $role = 'owner',
|
||||||
?string $workspaceRole = null,
|
?string $workspaceRole = null,
|
||||||
bool $ensureDefaultMicrosoftProviderConnection = false,
|
bool $ensureDefaultMicrosoftProviderConnection = true,
|
||||||
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
|
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
|
||||||
string $fixtureProfile = 'minimal',
|
|
||||||
bool $ensureDefaultCredential = false,
|
|
||||||
?bool $clearCapabilityCaches = null,
|
|
||||||
?bool $setUiContext = null,
|
|
||||||
): array {
|
): array {
|
||||||
$profiles = createUserWithTenantProfiles();
|
|
||||||
|
|
||||||
if (! array_key_exists($fixtureProfile, $profiles)) {
|
|
||||||
throw new \InvalidArgumentException(sprintf('Unknown fixture profile [%s].', $fixtureProfile));
|
|
||||||
}
|
|
||||||
|
|
||||||
$profile = $profiles[$fixtureProfile];
|
|
||||||
$user ??= User::factory()->create();
|
$user ??= User::factory()->create();
|
||||||
$tenant ??= Tenant::factory()->create();
|
$tenant ??= Tenant::factory()->create();
|
||||||
|
|
||||||
@ -500,48 +372,25 @@ function createUserWithTenant(
|
|||||||
])->save();
|
])->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($profile['membership']) {
|
WorkspaceMembership::query()->updateOrCreate([
|
||||||
WorkspaceMembership::query()->updateOrCreate([
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
'user_id' => (int) $user->getKey(),
|
||||||
'user_id' => (int) $user->getKey(),
|
], [
|
||||||
], [
|
'role' => $workspaceRole,
|
||||||
'role' => $workspaceRole,
|
]);
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($profile['session']) {
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
||||||
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$tenant->getKey() => ['role' => $role],
|
$tenant->getKey() => ['role' => $role],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$shouldClearCapabilityCaches = $clearCapabilityCaches ?? $profile['cache'];
|
app(CapabilityResolver::class)->clearCache();
|
||||||
|
app(WorkspaceCapabilityResolver::class)->clearCache();
|
||||||
|
|
||||||
if ($shouldClearCapabilityCaches) {
|
if ($ensureDefaultMicrosoftProviderConnection) {
|
||||||
app(CapabilityResolver::class)->clearCache();
|
ensureDefaultProviderConnection($tenant, 'microsoft', $defaultProviderConnectionType);
|
||||||
app(WorkspaceCapabilityResolver::class)->clearCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
$shouldEnsureProviderConnection = $ensureDefaultMicrosoftProviderConnection || $profile['provider'];
|
|
||||||
$shouldEnsureCredential = $ensureDefaultCredential || $profile['credential'];
|
|
||||||
|
|
||||||
if ($shouldEnsureProviderConnection) {
|
|
||||||
ensureDefaultProviderConnection(
|
|
||||||
$tenant,
|
|
||||||
'microsoft',
|
|
||||||
$defaultProviderConnectionType,
|
|
||||||
$shouldEnsureCredential,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$shouldSetUiContext = $setUiContext ?? $profile['uiContext'];
|
|
||||||
|
|
||||||
if ($shouldSetUiContext) {
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$user, $tenant];
|
return [$user, $tenant];
|
||||||
@ -694,7 +543,6 @@ function ensureDefaultProviderConnection(
|
|||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
string $provider = 'microsoft',
|
string $provider = 'microsoft',
|
||||||
string $connectionType = ProviderConnectionType::Dedicated->value,
|
string $connectionType = ProviderConnectionType::Dedicated->value,
|
||||||
bool $ensureCredential = true,
|
|
||||||
): ProviderConnection {
|
): ProviderConnection {
|
||||||
$resolvedConnectionType = ProviderConnectionType::tryFrom($connectionType) ?? ProviderConnectionType::Dedicated;
|
$resolvedConnectionType = ProviderConnectionType::tryFrom($connectionType) ?? ProviderConnectionType::Dedicated;
|
||||||
|
|
||||||
@ -790,15 +638,6 @@ function ensureDefaultProviderConnection(
|
|||||||
return $connection;
|
return $connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $ensureCredential) {
|
|
||||||
if ($credential instanceof ProviderCredential) {
|
|
||||||
$credential->delete();
|
|
||||||
$connection->refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $credential instanceof ProviderCredential) {
|
if (! $credential instanceof ProviderCredential) {
|
||||||
ProviderCredential::factory()->create([
|
ProviderCredential::factory()->create([
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
|||||||
@ -1,116 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Support;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final class TestLaneBudget
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly int $thresholdSeconds,
|
|
||||||
public readonly string $baselineSource,
|
|
||||||
public readonly string $enforcement,
|
|
||||||
public readonly string $lifecycleState,
|
|
||||||
public readonly ?int $baselineDeltaTargetPercent = null,
|
|
||||||
public readonly ?string $notes = null,
|
|
||||||
public readonly ?string $reviewCadence = null,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $budget
|
|
||||||
*/
|
|
||||||
public static function fromArray(array $budget): self
|
|
||||||
{
|
|
||||||
if (! isset($budget['thresholdSeconds'], $budget['baselineSource'], $budget['enforcement'], $budget['lifecycleState'])) {
|
|
||||||
throw new InvalidArgumentException('Budget declarations must define thresholdSeconds, baselineSource, enforcement, and lifecycleState.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new self(
|
|
||||||
thresholdSeconds: (int) $budget['thresholdSeconds'],
|
|
||||||
baselineSource: (string) $budget['baselineSource'],
|
|
||||||
enforcement: (string) $budget['enforcement'],
|
|
||||||
lifecycleState: (string) $budget['lifecycleState'],
|
|
||||||
baselineDeltaTargetPercent: isset($budget['baselineDeltaTargetPercent']) ? (int) $budget['baselineDeltaTargetPercent'] : null,
|
|
||||||
notes: isset($budget['notes']) ? (string) $budget['notes'] : null,
|
|
||||||
reviewCadence: isset($budget['reviewCadence']) ? (string) $budget['reviewCadence'] : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, int|float|string>
|
|
||||||
*/
|
|
||||||
public function evaluate(float $measuredSeconds): array
|
|
||||||
{
|
|
||||||
$budgetStatus = 'within-budget';
|
|
||||||
|
|
||||||
if ($measuredSeconds > $this->thresholdSeconds) {
|
|
||||||
$budgetStatus = $this->enforcement === 'warn' ? 'warning' : 'over-budget';
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_filter([
|
|
||||||
'thresholdSeconds' => $this->thresholdSeconds,
|
|
||||||
'baselineSource' => $this->baselineSource,
|
|
||||||
'enforcement' => $this->enforcement,
|
|
||||||
'lifecycleState' => $this->lifecycleState,
|
|
||||||
'baselineDeltaTargetPercent' => $this->baselineDeltaTargetPercent,
|
|
||||||
'measuredSeconds' => round($measuredSeconds, 6),
|
|
||||||
'budgetStatus' => $budgetStatus,
|
|
||||||
], static fn (mixed $value): bool => $value !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<array<string, mixed>> $familyBudgets
|
|
||||||
* @param array<string, float|int> $durationsByFile
|
|
||||||
* @return list<array<string, int|float|string|array<int, string>>>
|
|
||||||
*/
|
|
||||||
public static function evaluateFamilyBudgets(array $familyBudgets, array $durationsByFile): array
|
|
||||||
{
|
|
||||||
$evaluations = [];
|
|
||||||
|
|
||||||
foreach ($familyBudgets as $familyBudget) {
|
|
||||||
$matchedSelectors = [];
|
|
||||||
$measuredSeconds = 0.0;
|
|
||||||
$selectorType = (string) ($familyBudget['selectorType'] ?? 'path');
|
|
||||||
$selectors = array_values(array_filter(
|
|
||||||
$familyBudget['selectors'] ?? [],
|
|
||||||
static fn (mixed $selector): bool => is_string($selector) && $selector !== '',
|
|
||||||
));
|
|
||||||
|
|
||||||
foreach ($durationsByFile as $filePath => $duration) {
|
|
||||||
foreach ($selectors as $selector) {
|
|
||||||
$matches = match ($selectorType) {
|
|
||||||
'file' => $filePath === $selector,
|
|
||||||
default => str_starts_with($filePath, rtrim($selector, '/')),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (! $matches) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$matchedSelectors[] = $selector;
|
|
||||||
$measuredSeconds += (float) $duration;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$budget = self::fromArray([
|
|
||||||
'thresholdSeconds' => (int) $familyBudget['thresholdSeconds'],
|
|
||||||
'baselineSource' => (string) $familyBudget['baselineSource'],
|
|
||||||
'enforcement' => (string) $familyBudget['enforcement'],
|
|
||||||
'lifecycleState' => (string) $familyBudget['lifecycleState'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$evaluations[] = array_merge([
|
|
||||||
'familyId' => (string) $familyBudget['familyId'],
|
|
||||||
], $budget->evaluate($measuredSeconds), [
|
|
||||||
'matchedSelectors' => array_values(array_unique($matchedSelectors)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $evaluations;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,665 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Support;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use RecursiveDirectoryIterator;
|
|
||||||
use RecursiveIteratorIterator;
|
|
||||||
use SplFileInfo;
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
|
|
||||||
final class TestLaneManifest
|
|
||||||
{
|
|
||||||
private const ARTIFACT_DIRECTORY = 'storage/logs/test-lanes';
|
|
||||||
|
|
||||||
private const FULL_SUITE_BASELINE_SECONDS = 2625;
|
|
||||||
|
|
||||||
private const COMMAND_REFS = [
|
|
||||||
'fast-feedback' => 'test',
|
|
||||||
'confidence' => 'test:confidence',
|
|
||||||
'browser' => 'test:browser',
|
|
||||||
'heavy-governance' => 'test:heavy',
|
|
||||||
'profiling' => 'test:profile',
|
|
||||||
'junit' => 'test:junit',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function manifest(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'version' => 1,
|
|
||||||
'artifactDirectory' => self::artifactDirectory(),
|
|
||||||
'lanes' => [
|
|
||||||
[
|
|
||||||
'id' => 'fast-feedback',
|
|
||||||
'governanceClass' => 'fast',
|
|
||||||
'description' => 'Quick representative feedback for normal local edits.',
|
|
||||||
'intendedAudience' => 'Contributors working in the default authoring loop.',
|
|
||||||
'includedFamilies' => ['unit', 'core-feature-safety'],
|
|
||||||
'excludedFamilies' => ['browser', 'heavy-governance'],
|
|
||||||
'ownershipExpectations' => 'Run on normal edits and escalate to confidence or heavy-governance when the touched surface expands.',
|
|
||||||
'defaultEntryPoint' => true,
|
|
||||||
'parallelMode' => 'required',
|
|
||||||
'selectors' => [
|
|
||||||
'includeSuites' => ['Unit'],
|
|
||||||
'includePaths' => [
|
|
||||||
'tests/Feature/Auth',
|
|
||||||
'tests/Feature/Authorization',
|
|
||||||
'tests/Feature/EntraAdminRoles',
|
|
||||||
'tests/Feature/Findings',
|
|
||||||
'tests/Feature/Guards',
|
|
||||||
'tests/Feature/Monitoring',
|
|
||||||
'tests/Feature/Navigation',
|
|
||||||
'tests/Feature/Onboarding',
|
|
||||||
'tests/Feature/RequiredPermissions',
|
|
||||||
'tests/Feature/Tenants',
|
|
||||||
'tests/Feature/Workspaces',
|
|
||||||
],
|
|
||||||
'includeGroups' => ['fast-feedback'],
|
|
||||||
'includeFiles' => [
|
|
||||||
'tests/Feature/AdminConsentCallbackTest.php',
|
|
||||||
'tests/Feature/AdminNewRedirectTest.php',
|
|
||||||
],
|
|
||||||
'excludeSuites' => ['Browser'],
|
|
||||||
'excludePaths' => [
|
|
||||||
'tests/Architecture',
|
|
||||||
'tests/Browser',
|
|
||||||
'tests/Deprecation',
|
|
||||||
'tests/Feature/078',
|
|
||||||
'tests/Feature/090',
|
|
||||||
'tests/Feature/144',
|
|
||||||
'tests/Feature/OpsUx',
|
|
||||||
],
|
|
||||||
'excludeGroups' => ['browser', 'heavy-governance'],
|
|
||||||
'excludeFiles' => [
|
|
||||||
'tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php',
|
|
||||||
'tests/Feature/Guards/ActionSurfaceContractTest.php',
|
|
||||||
'tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php',
|
|
||||||
'tests/Feature/ProviderConnections/CredentialLeakGuardTest.php',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
|
|
||||||
'budget' => [
|
|
||||||
'thresholdSeconds' => 200,
|
|
||||||
'baselineSource' => 'measured-current-suite',
|
|
||||||
'enforcement' => 'warn',
|
|
||||||
'lifecycleState' => 'documented',
|
|
||||||
'baselineDeltaTargetPercent' => 50,
|
|
||||||
'reviewCadence' => 'tighten after two stable runs',
|
|
||||||
],
|
|
||||||
'dbStrategy' => [
|
|
||||||
'connectionMode' => 'sqlite-memory',
|
|
||||||
'resetStrategy' => 'refresh-database',
|
|
||||||
'seedsPolicy' => 'restricted',
|
|
||||||
'schemaBaselineCandidate' => false,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'confidence',
|
|
||||||
'governanceClass' => 'confidence',
|
|
||||||
'description' => 'Broader pre-merge validation for non-browser feature and integration work.',
|
|
||||||
'intendedAudience' => 'Contributors and reviewers preparing a higher-confidence run before merge.',
|
|
||||||
'includedFamilies' => ['unit', 'non-browser-feature-integration'],
|
|
||||||
'excludedFamilies' => ['browser', 'heavy-governance'],
|
|
||||||
'ownershipExpectations' => 'Run before merge when a change touches multiple feature surfaces or shared infrastructure.',
|
|
||||||
'defaultEntryPoint' => false,
|
|
||||||
'parallelMode' => 'required',
|
|
||||||
'selectors' => [
|
|
||||||
'includeSuites' => ['Unit'],
|
|
||||||
'includePaths' => ['tests/Feature'],
|
|
||||||
'includeGroups' => [],
|
|
||||||
'includeFiles' => [],
|
|
||||||
'excludeSuites' => ['Browser'],
|
|
||||||
'excludePaths' => [
|
|
||||||
'tests/Architecture',
|
|
||||||
'tests/Browser',
|
|
||||||
'tests/Deprecation',
|
|
||||||
'tests/Feature/078',
|
|
||||||
'tests/Feature/090',
|
|
||||||
'tests/Feature/144',
|
|
||||||
'tests/Feature/OpsUx',
|
|
||||||
],
|
|
||||||
'excludeGroups' => ['browser', 'heavy-governance'],
|
|
||||||
'excludeFiles' => [
|
|
||||||
'tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php',
|
|
||||||
'tests/Feature/Guards/ActionSurfaceContractTest.php',
|
|
||||||
'tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php',
|
|
||||||
'tests/Feature/ProviderConnections/CredentialLeakGuardTest.php',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
|
|
||||||
'budget' => [
|
|
||||||
'thresholdSeconds' => 450,
|
|
||||||
'baselineSource' => 'measured-current-suite',
|
|
||||||
'enforcement' => 'warn',
|
|
||||||
'lifecycleState' => 'documented',
|
|
||||||
'reviewCadence' => 'tighten after two stable runs',
|
|
||||||
],
|
|
||||||
'dbStrategy' => [
|
|
||||||
'connectionMode' => 'sqlite-memory',
|
|
||||||
'resetStrategy' => 'refresh-database',
|
|
||||||
'seedsPolicy' => 'restricted',
|
|
||||||
'schemaBaselineCandidate' => false,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'browser',
|
|
||||||
'governanceClass' => 'heavy',
|
|
||||||
'description' => 'Dedicated browser runtime lane for smoke and workflow validation.',
|
|
||||||
'intendedAudience' => 'Contributors validating browser-specific behavior and smoke coverage.',
|
|
||||||
'includedFamilies' => ['browser'],
|
|
||||||
'excludedFamilies' => ['fast-feedback', 'confidence'],
|
|
||||||
'ownershipExpectations' => 'Run when a change touches browser behavior or before promoting browser-sensitive work.',
|
|
||||||
'defaultEntryPoint' => false,
|
|
||||||
'parallelMode' => 'forbidden',
|
|
||||||
'selectors' => [
|
|
||||||
'includeSuites' => ['Browser'],
|
|
||||||
'includePaths' => ['tests/Browser'],
|
|
||||||
'includeGroups' => ['browser'],
|
|
||||||
'includeFiles' => [],
|
|
||||||
'excludeSuites' => [],
|
|
||||||
'excludePaths' => [],
|
|
||||||
'excludeGroups' => [],
|
|
||||||
'excludeFiles' => [],
|
|
||||||
],
|
|
||||||
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
|
|
||||||
'budget' => [
|
|
||||||
'thresholdSeconds' => 150,
|
|
||||||
'baselineSource' => 'measured-lane',
|
|
||||||
'enforcement' => 'warn',
|
|
||||||
'lifecycleState' => 'documented',
|
|
||||||
'reviewCadence' => 'tighten after two stable runs',
|
|
||||||
],
|
|
||||||
'dbStrategy' => [
|
|
||||||
'connectionMode' => 'sqlite-memory',
|
|
||||||
'resetStrategy' => 'refresh-database',
|
|
||||||
'seedsPolicy' => 'restricted',
|
|
||||||
'schemaBaselineCandidate' => false,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'heavy-governance',
|
|
||||||
'governanceClass' => 'heavy',
|
|
||||||
'description' => 'Intentionally expensive governance scans, architecture guards, and high-fan-out operational checks.',
|
|
||||||
'intendedAudience' => 'Maintainers and reviewers validating the heaviest governance families on purpose.',
|
|
||||||
'includedFamilies' => ['architecture-governance', 'ops-ux', 'surface-scan'],
|
|
||||||
'excludedFamilies' => ['browser'],
|
|
||||||
'ownershipExpectations' => 'Run intentionally when touching governance scans, high-fan-out guards, or operational UX surfaces.',
|
|
||||||
'defaultEntryPoint' => false,
|
|
||||||
'parallelMode' => 'optional',
|
|
||||||
'selectors' => [
|
|
||||||
'includeSuites' => [],
|
|
||||||
'includePaths' => [
|
|
||||||
'tests/Architecture',
|
|
||||||
'tests/Deprecation',
|
|
||||||
'tests/Feature/078',
|
|
||||||
'tests/Feature/090',
|
|
||||||
'tests/Feature/144',
|
|
||||||
'tests/Feature/OpsUx',
|
|
||||||
],
|
|
||||||
'includeGroups' => ['heavy-governance'],
|
|
||||||
'includeFiles' => [
|
|
||||||
'tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php',
|
|
||||||
'tests/Feature/Guards/ActionSurfaceContractTest.php',
|
|
||||||
'tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php',
|
|
||||||
'tests/Feature/ProviderConnections/CredentialLeakGuardTest.php',
|
|
||||||
],
|
|
||||||
'excludeSuites' => ['Browser'],
|
|
||||||
'excludePaths' => [],
|
|
||||||
'excludeGroups' => ['browser'],
|
|
||||||
'excludeFiles' => [],
|
|
||||||
],
|
|
||||||
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
|
|
||||||
'budget' => [
|
|
||||||
'thresholdSeconds' => 120,
|
|
||||||
'baselineSource' => 'measured-lane',
|
|
||||||
'enforcement' => 'warn',
|
|
||||||
'lifecycleState' => 'documented',
|
|
||||||
'reviewCadence' => 'tighten after two stable runs',
|
|
||||||
],
|
|
||||||
'dbStrategy' => [
|
|
||||||
'connectionMode' => 'mixed',
|
|
||||||
'resetStrategy' => 'refresh-database',
|
|
||||||
'seedsPolicy' => 'restricted',
|
|
||||||
'schemaBaselineCandidate' => false,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'profiling',
|
|
||||||
'governanceClass' => 'support',
|
|
||||||
'description' => 'Serial non-browser profiling support run that exposes the slowest tests and families.',
|
|
||||||
'intendedAudience' => 'Maintainers investigating slow-test drift before it becomes baseline behavior.',
|
|
||||||
'includedFamilies' => ['unit', 'non-browser-feature-integration', 'heavy-governance'],
|
|
||||||
'excludedFamilies' => ['browser'],
|
|
||||||
'ownershipExpectations' => 'Run intentionally when refreshing baselines or refining the heavy-governance split from evidence.',
|
|
||||||
'defaultEntryPoint' => false,
|
|
||||||
'parallelMode' => 'forbidden',
|
|
||||||
'selectors' => [
|
|
||||||
'includeSuites' => ['Unit'],
|
|
||||||
'includePaths' => ['tests/Feature'],
|
|
||||||
'includeGroups' => [],
|
|
||||||
'includeFiles' => [],
|
|
||||||
'excludeSuites' => ['Browser'],
|
|
||||||
'excludePaths' => ['tests/Browser'],
|
|
||||||
'excludeGroups' => ['browser'],
|
|
||||||
'excludeFiles' => [],
|
|
||||||
],
|
|
||||||
'artifacts' => ['summary', 'junit-xml', 'profile-top', 'budget-report'],
|
|
||||||
'budget' => [
|
|
||||||
'thresholdSeconds' => 3000,
|
|
||||||
'baselineSource' => 'measured-current-suite',
|
|
||||||
'enforcement' => 'warn',
|
|
||||||
'lifecycleState' => 'documented',
|
|
||||||
'reviewCadence' => 'tighten after two stable runs',
|
|
||||||
],
|
|
||||||
'dbStrategy' => [
|
|
||||||
'connectionMode' => 'sqlite-memory',
|
|
||||||
'resetStrategy' => 'refresh-database',
|
|
||||||
'seedsPolicy' => 'restricted',
|
|
||||||
'schemaBaselineCandidate' => false,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'junit',
|
|
||||||
'governanceClass' => 'support',
|
|
||||||
'description' => 'Machine-readable report run for the broader confidence scope.',
|
|
||||||
'intendedAudience' => 'Contributors and reviewers who need durable artifacts instead of ad-hoc terminal output.',
|
|
||||||
'includedFamilies' => ['unit', 'non-browser-feature-integration'],
|
|
||||||
'excludedFamilies' => ['browser', 'heavy-governance'],
|
|
||||||
'ownershipExpectations' => 'Run when the latest broad report needs to be shared or attached to follow-up work.',
|
|
||||||
'defaultEntryPoint' => false,
|
|
||||||
'parallelMode' => 'required',
|
|
||||||
'selectors' => [
|
|
||||||
'includeSuites' => ['Unit'],
|
|
||||||
'includePaths' => ['tests/Feature'],
|
|
||||||
'includeGroups' => [],
|
|
||||||
'includeFiles' => [],
|
|
||||||
'excludeSuites' => ['Browser'],
|
|
||||||
'excludePaths' => [
|
|
||||||
'tests/Architecture',
|
|
||||||
'tests/Browser',
|
|
||||||
'tests/Deprecation',
|
|
||||||
'tests/Feature/078',
|
|
||||||
'tests/Feature/090',
|
|
||||||
'tests/Feature/144',
|
|
||||||
'tests/Feature/OpsUx',
|
|
||||||
],
|
|
||||||
'excludeGroups' => ['browser', 'heavy-governance'],
|
|
||||||
'excludeFiles' => [
|
|
||||||
'tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php',
|
|
||||||
'tests/Feature/Guards/ActionSurfaceContractTest.php',
|
|
||||||
'tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php',
|
|
||||||
'tests/Feature/ProviderConnections/CredentialLeakGuardTest.php',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
|
|
||||||
'budget' => [
|
|
||||||
'thresholdSeconds' => 450,
|
|
||||||
'baselineSource' => 'measured-lane',
|
|
||||||
'enforcement' => 'warn',
|
|
||||||
'lifecycleState' => 'documented',
|
|
||||||
'reviewCadence' => 'tighten after two stable runs',
|
|
||||||
],
|
|
||||||
'dbStrategy' => [
|
|
||||||
'connectionMode' => 'sqlite-memory',
|
|
||||||
'resetStrategy' => 'refresh-database',
|
|
||||||
'seedsPolicy' => 'restricted',
|
|
||||||
'schemaBaselineCandidate' => false,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'familyBudgets' => [
|
|
||||||
[
|
|
||||||
'familyId' => 'ops-ux-governance',
|
|
||||||
'selectorType' => 'path',
|
|
||||||
'selectors' => [
|
|
||||||
'tests/Feature/OpsUx',
|
|
||||||
'tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php',
|
|
||||||
'tests/Feature/Guards/ActionSurfaceContractTest.php',
|
|
||||||
'tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php',
|
|
||||||
'tests/Feature/ProviderConnections/CredentialLeakGuardTest.php',
|
|
||||||
],
|
|
||||||
'thresholdSeconds' => 120,
|
|
||||||
'baselineSource' => 'measured-current-suite',
|
|
||||||
'enforcement' => 'warn',
|
|
||||||
'lifecycleState' => 'documented',
|
|
||||||
'reviewCadence' => 'tighten after two stable runs',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'familyId' => 'browser-smoke',
|
|
||||||
'selectorType' => 'path',
|
|
||||||
'selectors' => ['tests/Browser'],
|
|
||||||
'thresholdSeconds' => 150,
|
|
||||||
'baselineSource' => 'measured-lane',
|
|
||||||
'enforcement' => 'warn',
|
|
||||||
'lifecycleState' => 'documented',
|
|
||||||
'reviewCadence' => 'tighten after two stable runs',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public static function familyBudgets(): array
|
|
||||||
{
|
|
||||||
return self::manifest()['familyBudgets'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function lane(string $id): array
|
|
||||||
{
|
|
||||||
foreach (self::manifest()['lanes'] as $lane) {
|
|
||||||
if ($lane['id'] === $id) {
|
|
||||||
return $lane;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidArgumentException(sprintf('Unknown test lane [%s].', $id));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function commandRef(string $laneId): string
|
|
||||||
{
|
|
||||||
if (! array_key_exists($laneId, self::COMMAND_REFS)) {
|
|
||||||
throw new InvalidArgumentException(sprintf('Unknown lane command reference [%s].', $laneId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::COMMAND_REFS[$laneId];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function artifactDirectory(): string
|
|
||||||
{
|
|
||||||
return self::ARTIFACT_DIRECTORY;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function fullSuiteBaselineSeconds(): int
|
|
||||||
{
|
|
||||||
return self::FULL_SUITE_BASELINE_SECONDS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public static function buildCommand(string $laneId): array
|
|
||||||
{
|
|
||||||
$lane = self::lane($laneId);
|
|
||||||
$command = ['php', 'artisan', 'test', '--without-tty', '--no-ansi'];
|
|
||||||
$commandSuites = self::commandSuites($lane);
|
|
||||||
|
|
||||||
if (! in_array('profile-top', $lane['artifacts'], true)) {
|
|
||||||
$command[] = '--compact';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($lane['parallelMode'] === 'required') {
|
|
||||||
$command[] = '--parallel';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($commandSuites !== []) {
|
|
||||||
$command[] = '--testsuite='.implode(',', $commandSuites);
|
|
||||||
}
|
|
||||||
|
|
||||||
$command[] = '--log-junit='.TestLaneReport::artifactPaths($laneId)['junit'];
|
|
||||||
|
|
||||||
if (in_array('profile-top', $lane['artifacts'], true)) {
|
|
||||||
$command[] = '--profile';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($lane['selectors']['includeGroups'] !== []) {
|
|
||||||
$command[] = '--group='.implode(',', $lane['selectors']['includeGroups']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($lane['selectors']['excludeGroups'] !== []) {
|
|
||||||
$command[] = '--exclude-group='.implode(',', $lane['selectors']['excludeGroups']);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (self::commandTargets($lane) as $target) {
|
|
||||||
$command[] = $target;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $command;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function runLane(string $laneId): int
|
|
||||||
{
|
|
||||||
self::ensureArtifactDirectory();
|
|
||||||
|
|
||||||
$command = self::buildCommand($laneId);
|
|
||||||
$process = new Process($command, self::appRoot());
|
|
||||||
$process->setTimeout(null);
|
|
||||||
|
|
||||||
$capturedOutput = '';
|
|
||||||
$startedAt = microtime(true);
|
|
||||||
|
|
||||||
$process->run(function (string $type, string $buffer) use (&$capturedOutput): void {
|
|
||||||
echo $buffer;
|
|
||||||
|
|
||||||
$capturedOutput .= $buffer;
|
|
||||||
});
|
|
||||||
|
|
||||||
TestLaneReport::finalizeLane($laneId, microtime(true) - $startedAt, $capturedOutput);
|
|
||||||
|
|
||||||
return $process->getExitCode() ?? 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function renderLatestReport(string $laneId): int
|
|
||||||
{
|
|
||||||
$artifactPaths = TestLaneReport::artifactPaths($laneId);
|
|
||||||
$reportPath = self::absolutePath($artifactPaths['report']);
|
|
||||||
$wallClockSeconds = 0.0;
|
|
||||||
|
|
||||||
if (is_file($reportPath)) {
|
|
||||||
$existingReport = json_decode((string) file_get_contents($reportPath), true);
|
|
||||||
$wallClockSeconds = (float) ($existingReport['wallClockSeconds'] ?? 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
$parsed = TestLaneReport::parseJUnit(self::absolutePath($artifactPaths['junit']), $laneId);
|
|
||||||
$profileOutputPath = self::absolutePath($artifactPaths['profile']);
|
|
||||||
|
|
||||||
$report = TestLaneReport::buildReport(
|
|
||||||
laneId: $laneId,
|
|
||||||
wallClockSeconds: $wallClockSeconds,
|
|
||||||
slowestEntries: $parsed['slowestEntries'],
|
|
||||||
durationsByFile: $parsed['durationsByFile'],
|
|
||||||
);
|
|
||||||
|
|
||||||
TestLaneReport::writeArtifacts(
|
|
||||||
laneId: $laneId,
|
|
||||||
report: $report,
|
|
||||||
profileOutput: is_file($profileOutputPath) ? (string) file_get_contents($profileOutputPath) : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public static function discoverFiles(string $laneId): array
|
|
||||||
{
|
|
||||||
$lane = self::lane($laneId);
|
|
||||||
$selectors = $lane['selectors'];
|
|
||||||
$files = [];
|
|
||||||
|
|
||||||
foreach (array_merge(self::suiteTargets($selectors['includeSuites']), $selectors['includePaths'], $selectors['includeFiles']) as $target) {
|
|
||||||
foreach (self::discoverTarget($target) as $resolvedFile) {
|
|
||||||
if (self::isExcluded($resolvedFile, $selectors)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$files[] = $resolvedFile;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$files = array_values(array_unique($files));
|
|
||||||
sort($files);
|
|
||||||
|
|
||||||
return $files;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function absolutePath(string $relativePath): string
|
|
||||||
{
|
|
||||||
return self::appRoot().DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, ltrim($relativePath, '/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function appRoot(): string
|
|
||||||
{
|
|
||||||
return dirname(__DIR__, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function ensureArtifactDirectory(): void
|
|
||||||
{
|
|
||||||
$directory = self::absolutePath(self::artifactDirectory());
|
|
||||||
|
|
||||||
if (is_dir($directory)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdir($directory, 0777, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $suites
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function suiteTargets(array $suites): array
|
|
||||||
{
|
|
||||||
$targets = [];
|
|
||||||
|
|
||||||
foreach ($suites as $suite) {
|
|
||||||
$targets = array_merge($targets, match ($suite) {
|
|
||||||
'Unit' => ['tests/Unit'],
|
|
||||||
'Feature' => ['tests/Feature'],
|
|
||||||
'Browser' => ['tests/Browser'],
|
|
||||||
'Pgsql' => ['tests/Feature/WorkspaceIsolation/WorkspaceIdForeignKeyConstraintTest.php'],
|
|
||||||
default => [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($targets));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $lane
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function commandTargets(array $lane): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $lane
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function commandSuites(array $lane): array
|
|
||||||
{
|
|
||||||
$selectors = $lane['selectors'];
|
|
||||||
$suites = [];
|
|
||||||
|
|
||||||
foreach ($selectors['includeSuites'] as $suite) {
|
|
||||||
$suites[] = $suite;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (array_merge($selectors['includePaths'], $selectors['includeFiles']) as $target) {
|
|
||||||
$suite = self::inferSuiteFromTarget($target);
|
|
||||||
|
|
||||||
if ($suite !== null) {
|
|
||||||
$suites[] = $suite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$suites = array_values(array_unique(array_filter($suites, static fn (mixed $suite): bool => is_string($suite) && $suite !== '')));
|
|
||||||
|
|
||||||
if ($selectors['excludeSuites'] !== []) {
|
|
||||||
$suites = array_values(array_filter(
|
|
||||||
$suites,
|
|
||||||
static fn (string $suite): bool => ! in_array($suite, $selectors['excludeSuites'], true),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$suiteOrder = [
|
|
||||||
'Unit' => 10,
|
|
||||||
'Feature' => 20,
|
|
||||||
'Browser' => 30,
|
|
||||||
'Architecture' => 40,
|
|
||||||
'Deprecation' => 50,
|
|
||||||
'Pgsql' => 60,
|
|
||||||
];
|
|
||||||
|
|
||||||
usort($suites, static function (string $left, string $right) use ($suiteOrder): int {
|
|
||||||
return ($suiteOrder[$left] ?? 999) <=> ($suiteOrder[$right] ?? 999);
|
|
||||||
});
|
|
||||||
|
|
||||||
return $suites;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function inferSuiteFromTarget(string $target): ?string
|
|
||||||
{
|
|
||||||
return match (true) {
|
|
||||||
str_starts_with($target, 'tests/Unit') => 'Unit',
|
|
||||||
str_starts_with($target, 'tests/Feature') => 'Feature',
|
|
||||||
str_starts_with($target, 'tests/Browser') => 'Browser',
|
|
||||||
str_starts_with($target, 'tests/Architecture') => 'Architecture',
|
|
||||||
str_starts_with($target, 'tests/Deprecation') => 'Deprecation',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function discoverTarget(string $target): array
|
|
||||||
{
|
|
||||||
$absoluteTarget = self::absolutePath($target);
|
|
||||||
|
|
||||||
if (is_file($absoluteTarget)) {
|
|
||||||
return [str_replace(DIRECTORY_SEPARATOR, '/', $target)];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_dir($absoluteTarget)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$files = [];
|
|
||||||
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($absoluteTarget));
|
|
||||||
|
|
||||||
/** @var SplFileInfo $file */
|
|
||||||
foreach ($iterator as $file) {
|
|
||||||
if (! $file->isFile() || ! str_ends_with($file->getFilename(), 'Test.php')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolvedPath = str_replace(self::appRoot().DIRECTORY_SEPARATOR, '', $file->getPathname());
|
|
||||||
$files[] = str_replace(DIRECTORY_SEPARATOR, '/', $resolvedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $files;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $selectors
|
|
||||||
*/
|
|
||||||
private static function isExcluded(string $filePath, array $selectors): bool
|
|
||||||
{
|
|
||||||
if (in_array($filePath, $selectors['excludeFiles'], true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($selectors['excludePaths'] as $excludedPath) {
|
|
||||||
if (str_starts_with($filePath, rtrim($excludedPath, '/'))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,231 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Support;
|
|
||||||
|
|
||||||
use SimpleXMLElement;
|
|
||||||
|
|
||||||
final class TestLaneReport
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array{junit: string, summary: string, budget: string, report: string, profile: string}
|
|
||||||
*/
|
|
||||||
public static function artifactPaths(string $laneId, ?string $artifactDirectory = null): array
|
|
||||||
{
|
|
||||||
$directory = trim($artifactDirectory ?? TestLaneManifest::artifactDirectory(), '/');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'junit' => sprintf('%s/%s-latest.junit.xml', $directory, $laneId),
|
|
||||||
'summary' => sprintf('%s/%s-latest.summary.md', $directory, $laneId),
|
|
||||||
'budget' => sprintf('%s/%s-latest.budget.json', $directory, $laneId),
|
|
||||||
'report' => sprintf('%s/%s-latest.report.json', $directory, $laneId),
|
|
||||||
'profile' => sprintf('%s/%s-latest.profile.txt', $directory, $laneId),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{slowestEntries: list<array{subject: string, durationSeconds: float, laneId: string}>, durationsByFile: array<string, float>}
|
|
||||||
*/
|
|
||||||
public static function parseJUnit(string $filePath, string $laneId): array
|
|
||||||
{
|
|
||||||
if (! is_file($filePath)) {
|
|
||||||
return [
|
|
||||||
'slowestEntries' => [],
|
|
||||||
'durationsByFile' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$useInternalErrors = libxml_use_internal_errors(true);
|
|
||||||
$xml = simplexml_load_file($filePath);
|
|
||||||
libxml_clear_errors();
|
|
||||||
libxml_use_internal_errors($useInternalErrors);
|
|
||||||
|
|
||||||
if (! $xml instanceof SimpleXMLElement) {
|
|
||||||
return [
|
|
||||||
'slowestEntries' => [],
|
|
||||||
'durationsByFile' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$slowestEntries = [];
|
|
||||||
$durationsByFile = [];
|
|
||||||
|
|
||||||
foreach ($xml->xpath('//testcase') ?: [] as $testcase) {
|
|
||||||
$rawSubject = trim((string) ($testcase['file'] ?? ''));
|
|
||||||
$subject = $rawSubject !== '' ? $rawSubject : trim((string) ($testcase['name'] ?? 'unknown-testcase'));
|
|
||||||
$duration = (float) ($testcase['time'] ?? 0.0);
|
|
||||||
$normalizedFile = explode('::', $subject)[0];
|
|
||||||
|
|
||||||
$slowestEntries[] = [
|
|
||||||
'subject' => $subject,
|
|
||||||
'durationSeconds' => round($duration, 6),
|
|
||||||
'laneId' => $laneId,
|
|
||||||
];
|
|
||||||
|
|
||||||
$durationsByFile[$normalizedFile] = round(($durationsByFile[$normalizedFile] ?? 0.0) + $duration, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
usort($slowestEntries, static fn (array $left, array $right): int => $right['durationSeconds'] <=> $left['durationSeconds']);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'slowestEntries' => array_slice($slowestEntries, 0, 10),
|
|
||||||
'durationsByFile' => $durationsByFile,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<array{subject: string, durationSeconds: float, laneId: string}> $slowestEntries
|
|
||||||
* @param array<string, float> $durationsByFile
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function buildReport(
|
|
||||||
string $laneId,
|
|
||||||
float $wallClockSeconds,
|
|
||||||
array $slowestEntries,
|
|
||||||
array $durationsByFile,
|
|
||||||
?string $artifactDirectory = null,
|
|
||||||
): array {
|
|
||||||
$lane = TestLaneManifest::lane($laneId);
|
|
||||||
$budget = TestLaneBudget::fromArray($lane['budget']);
|
|
||||||
$budgetEvaluation = $budget->evaluate($wallClockSeconds);
|
|
||||||
$artifactPaths = self::artifactPaths($laneId, $artifactDirectory);
|
|
||||||
$artifacts = [];
|
|
||||||
|
|
||||||
foreach ($lane['artifacts'] as $artifactMode) {
|
|
||||||
$relativePath = match ($artifactMode) {
|
|
||||||
'summary' => $artifactPaths['summary'],
|
|
||||||
'junit-xml' => $artifactPaths['junit'],
|
|
||||||
'profile-top' => $artifactPaths['profile'],
|
|
||||||
'budget-report' => $artifactPaths['budget'],
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (! is_string($relativePath)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$artifacts[] = [
|
|
||||||
'artifactMode' => $artifactMode,
|
|
||||||
'relativePath' => $relativePath,
|
|
||||||
'machineReadable' => in_array($artifactMode, ['junit-xml', 'budget-report'], true),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$report = [
|
|
||||||
'laneId' => $laneId,
|
|
||||||
'finishedAt' => gmdate('c'),
|
|
||||||
'wallClockSeconds' => round($wallClockSeconds, 6),
|
|
||||||
'budgetThresholdSeconds' => $budget->thresholdSeconds,
|
|
||||||
'budgetBaselineSource' => $budget->baselineSource,
|
|
||||||
'budgetEnforcement' => $budget->enforcement,
|
|
||||||
'budgetLifecycleState' => $budget->lifecycleState,
|
|
||||||
'budgetStatus' => $budgetEvaluation['budgetStatus'],
|
|
||||||
'slowestEntries' => array_values($slowestEntries),
|
|
||||||
'familyBudgetEvaluations' => TestLaneBudget::evaluateFamilyBudgets(TestLaneManifest::familyBudgets(), $durationsByFile),
|
|
||||||
'artifacts' => $artifacts,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($budget->baselineDeltaTargetPercent !== null) {
|
|
||||||
$report['baselineDeltaTargetPercent'] = $budget->baselineDeltaTargetPercent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $report;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $report
|
|
||||||
* @return array{summary: string, budget: string, report: string, profile: string}
|
|
||||||
*/
|
|
||||||
public static function writeArtifacts(
|
|
||||||
string $laneId,
|
|
||||||
array $report,
|
|
||||||
?string $profileOutput = null,
|
|
||||||
?string $artifactDirectory = null,
|
|
||||||
): array {
|
|
||||||
$artifactPaths = self::artifactPaths($laneId, $artifactDirectory);
|
|
||||||
|
|
||||||
self::ensureDirectory(dirname(TestLaneManifest::absolutePath($artifactPaths['summary'])));
|
|
||||||
|
|
||||||
file_put_contents(
|
|
||||||
TestLaneManifest::absolutePath($artifactPaths['summary']),
|
|
||||||
self::buildSummaryMarkdown($report),
|
|
||||||
);
|
|
||||||
|
|
||||||
file_put_contents(
|
|
||||||
TestLaneManifest::absolutePath($artifactPaths['budget']),
|
|
||||||
json_encode([
|
|
||||||
'laneId' => $report['laneId'],
|
|
||||||
'wallClockSeconds' => $report['wallClockSeconds'],
|
|
||||||
'budgetThresholdSeconds' => $report['budgetThresholdSeconds'],
|
|
||||||
'budgetBaselineSource' => $report['budgetBaselineSource'],
|
|
||||||
'budgetEnforcement' => $report['budgetEnforcement'],
|
|
||||||
'budgetLifecycleState' => $report['budgetLifecycleState'],
|
|
||||||
'budgetStatus' => $report['budgetStatus'],
|
|
||||||
'familyBudgetEvaluations' => $report['familyBudgetEvaluations'],
|
|
||||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
|
||||||
);
|
|
||||||
|
|
||||||
file_put_contents(
|
|
||||||
TestLaneManifest::absolutePath($artifactPaths['report']),
|
|
||||||
json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (is_string($profileOutput) && trim($profileOutput) !== '') {
|
|
||||||
file_put_contents(TestLaneManifest::absolutePath($artifactPaths['profile']), $profileOutput);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $artifactPaths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function finalizeLane(string $laneId, float $wallClockSeconds, string $capturedOutput = ''): array
|
|
||||||
{
|
|
||||||
$artifactPaths = self::artifactPaths($laneId);
|
|
||||||
$parsed = self::parseJUnit(TestLaneManifest::absolutePath($artifactPaths['junit']), $laneId);
|
|
||||||
$report = self::buildReport(
|
|
||||||
laneId: $laneId,
|
|
||||||
wallClockSeconds: $wallClockSeconds,
|
|
||||||
slowestEntries: $parsed['slowestEntries'],
|
|
||||||
durationsByFile: $parsed['durationsByFile'],
|
|
||||||
);
|
|
||||||
|
|
||||||
self::writeArtifacts($laneId, $report, $capturedOutput);
|
|
||||||
|
|
||||||
return $report;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $report
|
|
||||||
*/
|
|
||||||
private static function buildSummaryMarkdown(array $report): string
|
|
||||||
{
|
|
||||||
$lines = [
|
|
||||||
'# Test Lane Summary',
|
|
||||||
'',
|
|
||||||
sprintf('- Lane: %s', $report['laneId']),
|
|
||||||
sprintf('- Finished: %s', $report['finishedAt']),
|
|
||||||
sprintf('- Wall clock: %.2f seconds', (float) $report['wallClockSeconds']),
|
|
||||||
sprintf('- Budget: %d seconds (%s)', (int) $report['budgetThresholdSeconds'], $report['budgetStatus']),
|
|
||||||
'',
|
|
||||||
'## Slowest entries',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($report['slowestEntries'] as $entry) {
|
|
||||||
$lines[] = sprintf('- %s (%.2fs)', $entry['subject'], (float) $entry['durationSeconds']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode(PHP_EOL, $lines).PHP_EOL;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function ensureDirectory(string $directory): void
|
|
||||||
{
|
|
||||||
if (is_dir($directory)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdir($directory, 0777, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('keeps the explicit minimal provider connection state free of credential side effects', function (): void {
|
|
||||||
$connection = ProviderConnection::factory()->minimal()->create();
|
|
||||||
|
|
||||||
expect($connection->credential()->exists())->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can opt into a heavier provider graph with a checked-in factory state', function (): void {
|
|
||||||
$connection = ProviderConnection::factory()->withCredential()->create();
|
|
||||||
|
|
||||||
expect($connection->credential()->exists())->toBeTrue()
|
|
||||||
->and($connection->refresh()->is_default)->toBeTrue();
|
|
||||||
});
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('can create tenants without provisioning a workspace graph when the minimal state is explicit', function (): void {
|
|
||||||
$tenant = Tenant::factory()->minimal()->create();
|
|
||||||
|
|
||||||
expect($tenant->workspace_id)->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the default tenant factory path workspace-ready for existing callers', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
|
|
||||||
expect($tenant->workspace_id)->not->toBeNull();
|
|
||||||
});
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\ProviderCredential;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('keeps the default tenant helper profile cheap by skipping provider setup and cache clears', function (): void {
|
|
||||||
$capabilities = \Mockery::mock(CapabilityResolver::class);
|
|
||||||
$workspaceCapabilities = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
|
||||||
|
|
||||||
$capabilities->shouldNotReceive('clearCache');
|
|
||||||
$workspaceCapabilities->shouldNotReceive('clearCache');
|
|
||||||
|
|
||||||
app()->instance(CapabilityResolver::class, $capabilities);
|
|
||||||
app()->instance(WorkspaceCapabilityResolver::class, $workspaceCapabilities);
|
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant();
|
|
||||||
|
|
||||||
expect(WorkspaceMembership::query()
|
|
||||||
->where('workspace_id', (int) $tenant->workspace_id)
|
|
||||||
->where('user_id', (int) $user->getKey())
|
|
||||||
->exists())->toBeTrue()
|
|
||||||
->and(session(WorkspaceContext::SESSION_KEY))->toBe((int) $tenant->workspace_id)
|
|
||||||
->and(ProviderConnection::query()->where('tenant_id', (int) $tenant->getKey())->exists())->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opt-ins provider, credential, ui-context, and cache resets only when the fixture profile asks for them', function (): void {
|
|
||||||
$capabilities = \Mockery::mock(CapabilityResolver::class);
|
|
||||||
$workspaceCapabilities = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
|
||||||
|
|
||||||
$capabilities->shouldReceive('clearCache')->once();
|
|
||||||
$workspaceCapabilities->shouldReceive('clearCache')->once();
|
|
||||||
|
|
||||||
app()->instance(CapabilityResolver::class, $capabilities);
|
|
||||||
app()->instance(WorkspaceCapabilityResolver::class, $workspaceCapabilities);
|
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(fixtureProfile: 'heavy');
|
|
||||||
|
|
||||||
$connection = ProviderConnection::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('is_default', true)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($connection)->not->toBeNull()
|
|
||||||
->and($connection?->credential()->exists())->toBeTrue()
|
|
||||||
->and(ProviderCredential::query()->where('provider_connection_id', (int) $connection?->getKey())->exists())->toBeTrue()
|
|
||||||
->and(Filament::getTenant()?->is($tenant))->toBeTrue();
|
|
||||||
});
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Tests\Support\TestLaneBudget;
|
|
||||||
use Tests\Support\TestLaneManifest;
|
|
||||||
|
|
||||||
it('evaluates lane budgets into within-budget, warning, and over-budget states', function (): void {
|
|
||||||
$warnBudget = TestLaneBudget::fromArray([
|
|
||||||
'thresholdSeconds' => 30,
|
|
||||||
'baselineSource' => 'measured-current-suite',
|
|
||||||
'enforcement' => 'warn',
|
|
||||||
'lifecycleState' => 'documented',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$hardFailBudget = TestLaneBudget::fromArray([
|
|
||||||
'thresholdSeconds' => 30,
|
|
||||||
'baselineSource' => 'measured-current-suite',
|
|
||||||
'enforcement' => 'hard-fail',
|
|
||||||
'lifecycleState' => 'documented',
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect($warnBudget->evaluate(24.5)['budgetStatus'])->toBe('within-budget')
|
|
||||||
->and($warnBudget->evaluate(31.2)['budgetStatus'])->toBe('warning')
|
|
||||||
->and($hardFailBudget->evaluate(31.2)['budgetStatus'])->toBe('over-budget');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('evaluates initial family thresholds from matched file durations', function (): void {
|
|
||||||
$evaluations = TestLaneBudget::evaluateFamilyBudgets(
|
|
||||||
TestLaneManifest::familyBudgets(),
|
|
||||||
[
|
|
||||||
'tests/Feature/OpsUx/OperateHubShellTest.php' => 18.4,
|
|
||||||
'tests/Feature/Guards/ActionSurfaceContractTest.php' => 7.8,
|
|
||||||
'tests/Browser/Spec198MonitoringPageStateSmokeTest.php' => 14.2,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($evaluations)->not->toBeEmpty()
|
|
||||||
->and($evaluations[0])->toHaveKeys([
|
|
||||||
'familyId',
|
|
||||||
'thresholdSeconds',
|
|
||||||
'baselineSource',
|
|
||||||
'enforcement',
|
|
||||||
'lifecycleState',
|
|
||||||
'measuredSeconds',
|
|
||||||
'budgetStatus',
|
|
||||||
'matchedSelectors',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Tests\Support\TestLaneReport;
|
|
||||||
|
|
||||||
it('extracts at least the top ten slowest entries from a junit artifact', function (): void {
|
|
||||||
$junitPath = sys_get_temp_dir().'/tenantatlas-test-lane-junit.xml';
|
|
||||||
|
|
||||||
$testcases = [];
|
|
||||||
|
|
||||||
for ($index = 1; $index <= 12; $index++) {
|
|
||||||
$duration = number_format(0.5 + ($index / 10), 6, '.', '');
|
|
||||||
$testcases[] = sprintf(
|
|
||||||
'<testsuite name="Suite%d" file="tests/Feature/Fake/Fake%dTest.php" tests="1" assertions="1" errors="0" failures="0" skipped="0" time="%s"><testcase name="fake %d" file="tests/Feature/Fake/Fake%dTest.php::fake %d" class="Tests\\Feature\\Fake\\Fake%dTest" classname="Tests.Feature.Fake.Fake%dTest" assertions="1" time="%s"/></testsuite>',
|
|
||||||
$index,
|
|
||||||
$index,
|
|
||||||
$duration,
|
|
||||||
$index,
|
|
||||||
$index,
|
|
||||||
$index,
|
|
||||||
$index,
|
|
||||||
$index,
|
|
||||||
$duration,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
file_put_contents($junitPath, "<?xml version=\"1.0\" encoding=\"UTF-8\"?><testsuites>".implode('', $testcases).'</testsuites>');
|
|
||||||
|
|
||||||
$parsed = TestLaneReport::parseJUnit($junitPath, 'junit');
|
|
||||||
|
|
||||||
expect($parsed['slowestEntries'])->toHaveCount(10)
|
|
||||||
->and($parsed['slowestEntries'][0]['durationSeconds'])->toBeGreaterThanOrEqual($parsed['slowestEntries'][9]['durationSeconds'])
|
|
||||||
->and($parsed['durationsByFile'])->toHaveCount(12);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds a report payload and writes the summary plus budget artifacts under a target directory', function (): void {
|
|
||||||
$artifactDirectory = 'storage/logs/test-lanes-test';
|
|
||||||
$report = TestLaneReport::buildReport(
|
|
||||||
laneId: 'junit',
|
|
||||||
wallClockSeconds: 24.5,
|
|
||||||
slowestEntries: array_map(
|
|
||||||
static fn (int $index): array => [
|
|
||||||
'subject' => sprintf('tests/Feature/Fake/Fake%dTest.php', $index),
|
|
||||||
'durationSeconds' => 1 + ($index / 10),
|
|
||||||
'laneId' => 'junit',
|
|
||||||
],
|
|
||||||
range(1, 10),
|
|
||||||
),
|
|
||||||
durationsByFile: [
|
|
||||||
'tests/Feature/OpsUx/OperateHubShellTest.php' => 9.8,
|
|
||||||
'tests/Feature/Guards/ActionSurfaceContractTest.php' => 4.4,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$written = TestLaneReport::writeArtifacts('junit', $report, null, $artifactDirectory);
|
|
||||||
|
|
||||||
expect($report)->toHaveKeys([
|
|
||||||
'laneId',
|
|
||||||
'finishedAt',
|
|
||||||
'wallClockSeconds',
|
|
||||||
'budgetThresholdSeconds',
|
|
||||||
'budgetBaselineSource',
|
|
||||||
'budgetEnforcement',
|
|
||||||
'budgetLifecycleState',
|
|
||||||
'budgetStatus',
|
|
||||||
'slowestEntries',
|
|
||||||
'familyBudgetEvaluations',
|
|
||||||
'artifacts',
|
|
||||||
])
|
|
||||||
->and($report['slowestEntries'])->toHaveCount(10)
|
|
||||||
->and($written['summary'])->toStartWith($artifactDirectory.'/')
|
|
||||||
->and($written['budget'])->toStartWith($artifactDirectory.'/')
|
|
||||||
->and(file_exists(base_path($written['summary'])))->toBeTrue()
|
|
||||||
->and(file_exists(base_path($written['budget'])))->toBeTrue();
|
|
||||||
});
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
||||||
APP_DIR="${ROOT_DIR}/apps/platform"
|
|
||||||
LANE="${1:-fast-feedback}"
|
|
||||||
|
|
||||||
case "${LANE}" in
|
|
||||||
fast-feedback|fast|default)
|
|
||||||
COMPOSER_SCRIPT="test"
|
|
||||||
;;
|
|
||||||
confidence)
|
|
||||||
COMPOSER_SCRIPT="test:confidence"
|
|
||||||
;;
|
|
||||||
browser)
|
|
||||||
COMPOSER_SCRIPT="test:browser"
|
|
||||||
;;
|
|
||||||
heavy-governance|heavy)
|
|
||||||
COMPOSER_SCRIPT="test:heavy"
|
|
||||||
;;
|
|
||||||
profiling|profile)
|
|
||||||
COMPOSER_SCRIPT="test:profile"
|
|
||||||
;;
|
|
||||||
junit)
|
|
||||||
COMPOSER_SCRIPT="test:junit"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown test lane: ${LANE}" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
shift || true
|
|
||||||
|
|
||||||
cd "${APP_DIR}"
|
|
||||||
|
|
||||||
exec ./vendor/bin/sail composer run --timeout=0 "${COMPOSER_SCRIPT}" -- "$@"
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
||||||
APP_DIR="${ROOT_DIR}/apps/platform"
|
|
||||||
LANE="${1:-fast-feedback}"
|
|
||||||
|
|
||||||
cd "${APP_DIR}"
|
|
||||||
|
|
||||||
exec ./vendor/bin/sail php -r 'require "vendor/autoload.php"; exit(\Tests\Support\TestLaneManifest::renderLatestReport((string) ($argv[1] ?? "")));' "${LANE}"
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
# Specification Quality Checklist: Test Suite Governance & Performance Foundation
|
|
||||||
|
|
||||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
||||||
**Created**: 2026-04-16
|
|
||||||
**Feature**: [spec.md](../spec.md)
|
|
||||||
|
|
||||||
## Content Quality
|
|
||||||
|
|
||||||
- [x] No implementation details (languages, frameworks, APIs)
|
|
||||||
- [x] Focused on user value and business needs
|
|
||||||
- [x] Written for non-technical stakeholders
|
|
||||||
- [x] All mandatory sections completed
|
|
||||||
|
|
||||||
## Requirement Completeness
|
|
||||||
|
|
||||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
|
||||||
- [x] Requirements are testable and unambiguous
|
|
||||||
- [x] Success criteria are measurable
|
|
||||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
|
||||||
- [x] All acceptance scenarios are defined
|
|
||||||
- [x] Edge cases are identified
|
|
||||||
- [x] Scope is clearly bounded
|
|
||||||
- [x] Dependencies and assumptions identified
|
|
||||||
|
|
||||||
## Feature Readiness
|
|
||||||
|
|
||||||
- [x] All functional requirements have clear acceptance criteria
|
|
||||||
- [x] User scenarios cover primary flows
|
|
||||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
|
||||||
- [x] No implementation details leak into specification
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Validation run: 2026-04-16
|
|
||||||
- The spec stays focused on repository governance outcomes: lane definitions, honest taxonomy, cheap shared defaults, slow-test visibility, and runtime budgets.
|
|
||||||
- Runtime budgets are intentionally expressed as measurable outcomes and documented governance expectations rather than hardwired implementation details.
|
|
||||||
- No clarification markers were needed; the user description supplied the required scope, non-goals, and rollout boundaries.
|
|
||||||
@ -1,694 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"$id": "https://tenantatlas.local/schemas/test-lane-manifest.schema.json",
|
|
||||||
"title": "TestLaneManifest",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"version",
|
|
||||||
"lanes",
|
|
||||||
"artifactDirectory",
|
|
||||||
"familyBudgets"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"version": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1
|
|
||||||
},
|
|
||||||
"artifactDirectory": {
|
|
||||||
"type": "string",
|
|
||||||
"const": "storage/logs/test-lanes"
|
|
||||||
},
|
|
||||||
"lanes": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 6,
|
|
||||||
"maxItems": 6,
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/$defs/lane"
|
|
||||||
},
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"contains": {
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "fast-feedback"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"contains": {
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "confidence"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"contains": {
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "browser"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"contains": {
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "heavy-governance"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"contains": {
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "profiling"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"contains": {
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "junit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"familyBudgets": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/$defs/familyBudget"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"$defs": {
|
|
||||||
"lane": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"id",
|
|
||||||
"governanceClass",
|
|
||||||
"description",
|
|
||||||
"intendedAudience",
|
|
||||||
"includedFamilies",
|
|
||||||
"excludedFamilies",
|
|
||||||
"ownershipExpectations",
|
|
||||||
"defaultEntryPoint",
|
|
||||||
"parallelMode",
|
|
||||||
"selectors",
|
|
||||||
"artifacts",
|
|
||||||
"budget",
|
|
||||||
"dbStrategy"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
||||||
},
|
|
||||||
"governanceClass": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"fast",
|
|
||||||
"confidence",
|
|
||||||
"heavy",
|
|
||||||
"support"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"intendedAudience": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"includedFamilies": {
|
|
||||||
"$ref": "#/$defs/stringArray"
|
|
||||||
},
|
|
||||||
"excludedFamilies": {
|
|
||||||
"$ref": "#/$defs/stringArray"
|
|
||||||
},
|
|
||||||
"ownershipExpectations": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"defaultEntryPoint": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"parallelMode": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"required",
|
|
||||||
"optional",
|
|
||||||
"forbidden"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"selectors": {
|
|
||||||
"$ref": "#/$defs/selectors"
|
|
||||||
},
|
|
||||||
"artifacts": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/$defs/artifactType"
|
|
||||||
},
|
|
||||||
"uniqueItems": true
|
|
||||||
},
|
|
||||||
"budget": {
|
|
||||||
"$ref": "#/$defs/budget"
|
|
||||||
},
|
|
||||||
"dbStrategy": {
|
|
||||||
"$ref": "#/$defs/dbStrategy"
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"artifacts": {
|
|
||||||
"contains": {
|
|
||||||
"const": "profile-top"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"parallelMode": {
|
|
||||||
"const": "forbidden"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "fast-feedback"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"defaultEntryPoint": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"parallelMode": {
|
|
||||||
"const": "required"
|
|
||||||
},
|
|
||||||
"budget": {
|
|
||||||
"required": [
|
|
||||||
"baselineDeltaTargetPercent"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"baselineDeltaTargetPercent": {
|
|
||||||
"const": 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"excludedFamilies": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"contains": {
|
|
||||||
"const": "browser"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"contains": {
|
|
||||||
"const": "heavy-governance"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "confidence"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"parallelMode": {
|
|
||||||
"const": "required"
|
|
||||||
},
|
|
||||||
"includedFamilies": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"contains": {
|
|
||||||
"const": "unit"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"contains": {
|
|
||||||
"const": "non-browser-feature-integration"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"excludedFamilies": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"contains": {
|
|
||||||
"const": "browser"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"contains": {
|
|
||||||
"const": "heavy-governance"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"selectors": {
|
|
||||||
"properties": {
|
|
||||||
"includeSuites": {
|
|
||||||
"contains": {
|
|
||||||
"const": "Unit"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"excludeSuites": {
|
|
||||||
"contains": {
|
|
||||||
"const": "Browser"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"includePaths": {
|
|
||||||
"minItems": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"includeGroups": {
|
|
||||||
"minItems": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"includeFiles": {
|
|
||||||
"minItems": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"defaultEntryPoint": {
|
|
||||||
"const": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "browser"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"governanceClass": {
|
|
||||||
"const": "heavy"
|
|
||||||
},
|
|
||||||
"includedFamilies": {
|
|
||||||
"contains": {
|
|
||||||
"const": "browser"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"selectors": {
|
|
||||||
"properties": {
|
|
||||||
"includeSuites": {
|
|
||||||
"contains": {
|
|
||||||
"const": "Browser"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"includeSuites"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"defaultEntryPoint": {
|
|
||||||
"const": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "heavy-governance"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"defaultEntryPoint": {
|
|
||||||
"const": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "profiling"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"governanceClass": {
|
|
||||||
"const": "support"
|
|
||||||
},
|
|
||||||
"defaultEntryPoint": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"artifacts": {
|
|
||||||
"contains": {
|
|
||||||
"const": "profile-top"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"if": {
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"const": "junit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"then": {
|
|
||||||
"properties": {
|
|
||||||
"governanceClass": {
|
|
||||||
"const": "support"
|
|
||||||
},
|
|
||||||
"defaultEntryPoint": {
|
|
||||||
"const": false
|
|
||||||
},
|
|
||||||
"artifacts": {
|
|
||||||
"contains": {
|
|
||||||
"const": "junit-xml"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"selectors": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"includeSuites",
|
|
||||||
"includePaths",
|
|
||||||
"includeGroups",
|
|
||||||
"includeFiles",
|
|
||||||
"excludeSuites",
|
|
||||||
"excludePaths",
|
|
||||||
"excludeGroups",
|
|
||||||
"excludeFiles"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"includeSuites": {
|
|
||||||
"$ref": "#/$defs/stringArray"
|
|
||||||
},
|
|
||||||
"includePaths": {
|
|
||||||
"$ref": "#/$defs/stringArray"
|
|
||||||
},
|
|
||||||
"includeGroups": {
|
|
||||||
"$ref": "#/$defs/stringArray"
|
|
||||||
},
|
|
||||||
"includeFiles": {
|
|
||||||
"$ref": "#/$defs/stringArray"
|
|
||||||
},
|
|
||||||
"excludeSuites": {
|
|
||||||
"$ref": "#/$defs/stringArray"
|
|
||||||
},
|
|
||||||
"excludePaths": {
|
|
||||||
"$ref": "#/$defs/stringArray"
|
|
||||||
},
|
|
||||||
"excludeGroups": {
|
|
||||||
"$ref": "#/$defs/stringArray"
|
|
||||||
},
|
|
||||||
"excludeFiles": {
|
|
||||||
"$ref": "#/$defs/stringArray"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"includeSuites": {
|
|
||||||
"minItems": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"includePaths": {
|
|
||||||
"minItems": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"includeGroups": {
|
|
||||||
"minItems": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"includeFiles": {
|
|
||||||
"minItems": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"budget": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"thresholdSeconds",
|
|
||||||
"baselineSource",
|
|
||||||
"enforcement",
|
|
||||||
"lifecycleState"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"thresholdSeconds": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1
|
|
||||||
},
|
|
||||||
"baselineSource": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"measured-current-suite",
|
|
||||||
"measured-lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"enforcement": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"report-only",
|
|
||||||
"warn",
|
|
||||||
"hard-fail"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"lifecycleState": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"draft",
|
|
||||||
"measured",
|
|
||||||
"documented",
|
|
||||||
"enforced"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"baselineDeltaTargetPercent": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 100
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"reviewCadence": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"familyBudget": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"familyId",
|
|
||||||
"selectorType",
|
|
||||||
"selectors",
|
|
||||||
"thresholdSeconds",
|
|
||||||
"baselineSource",
|
|
||||||
"enforcement",
|
|
||||||
"lifecycleState"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"familyId": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"selectorType": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"testsuite",
|
|
||||||
"path",
|
|
||||||
"group",
|
|
||||||
"file"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"selectors": {
|
|
||||||
"$ref": "#/$defs/stringArray",
|
|
||||||
"minItems": 1
|
|
||||||
},
|
|
||||||
"thresholdSeconds": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1
|
|
||||||
},
|
|
||||||
"baselineSource": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"measured-current-suite",
|
|
||||||
"measured-lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"enforcement": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"report-only",
|
|
||||||
"warn",
|
|
||||||
"hard-fail"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"lifecycleState": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"draft",
|
|
||||||
"measured",
|
|
||||||
"documented",
|
|
||||||
"enforced"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"reviewCadence": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dbStrategy": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"connectionMode",
|
|
||||||
"resetStrategy",
|
|
||||||
"seedsPolicy"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"connectionMode": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"sqlite-memory",
|
|
||||||
"pgsql",
|
|
||||||
"mixed"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"resetStrategy": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"none",
|
|
||||||
"refresh-database",
|
|
||||||
"dedicated-suite"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"seedsPolicy": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"forbidden",
|
|
||||||
"restricted",
|
|
||||||
"allowed-by-exception"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"schemaBaselineCandidate": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"artifactType": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"summary",
|
|
||||||
"junit-xml",
|
|
||||||
"profile-top",
|
|
||||||
"budget-report"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"stringArray": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"uniqueItems": true,
|
|
||||||
"default": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,396 +0,0 @@
|
|||||||
openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Test Suite Governance Logical Contract
|
|
||||||
version: 1.0.0
|
|
||||||
summary: Logical run and reporting contract for checked-in test lanes.
|
|
||||||
description: |
|
|
||||||
This is a logical contract for repository tooling, not a promise that a new
|
|
||||||
HTTP service will be introduced. It documents the expected semantics of lane
|
|
||||||
execution and lane reporting so commands, tasks, or wrappers can remain
|
|
||||||
consistent as the implementation evolves.
|
|
||||||
x-logical-contract: true
|
|
||||||
servers:
|
|
||||||
- url: https://tenantatlas.local/logical
|
|
||||||
paths:
|
|
||||||
/test-lanes/{laneId}/runs:
|
|
||||||
post:
|
|
||||||
summary: Start a lane run through a checked-in command entry point.
|
|
||||||
operationId: startTestLaneRun
|
|
||||||
parameters:
|
|
||||||
- name: laneId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/LaneId'
|
|
||||||
responses:
|
|
||||||
'202':
|
|
||||||
description: Lane execution accepted and resolved to a checked-in command path.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/LaneRunAccepted'
|
|
||||||
/test-lanes/{laneId}/reports/latest:
|
|
||||||
get:
|
|
||||||
summary: Read the most recent report produced for a lane.
|
|
||||||
operationId: getLatestTestLaneReport
|
|
||||||
parameters:
|
|
||||||
- name: laneId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/LaneId'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Latest report summary for the requested lane.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/LaneReport'
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
LaneId:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- fast-feedback
|
|
||||||
- confidence
|
|
||||||
- heavy-governance
|
|
||||||
- browser
|
|
||||||
- profiling
|
|
||||||
- junit
|
|
||||||
GovernanceClass:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- fast
|
|
||||||
- confidence
|
|
||||||
- heavy
|
|
||||||
- support
|
|
||||||
ArtifactMode:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- summary
|
|
||||||
- junit-xml
|
|
||||||
- profile-top
|
|
||||||
- budget-report
|
|
||||||
BudgetStatus:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- within-budget
|
|
||||||
- warning
|
|
||||||
- over-budget
|
|
||||||
BudgetBaselineSource:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- measured-current-suite
|
|
||||||
- measured-lane
|
|
||||||
BudgetEnforcement:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- report-only
|
|
||||||
- warn
|
|
||||||
- hard-fail
|
|
||||||
BudgetLifecycleState:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- draft
|
|
||||||
- measured
|
|
||||||
- documented
|
|
||||||
- enforced
|
|
||||||
LaneRunAccepted:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- laneId
|
|
||||||
- governanceClass
|
|
||||||
- commandRef
|
|
||||||
- parallelMode
|
|
||||||
- artifactModes
|
|
||||||
- includedFamilies
|
|
||||||
- excludedFamilies
|
|
||||||
- budgetThresholdSeconds
|
|
||||||
- budgetBaselineSource
|
|
||||||
- budgetEnforcement
|
|
||||||
- budgetLifecycleState
|
|
||||||
properties:
|
|
||||||
laneId:
|
|
||||||
$ref: '#/components/schemas/LaneId'
|
|
||||||
governanceClass:
|
|
||||||
$ref: '#/components/schemas/GovernanceClass'
|
|
||||||
commandRef:
|
|
||||||
type: string
|
|
||||||
description: Checked-in command reference, such as a Composer script name.
|
|
||||||
parallelMode:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- required
|
|
||||||
- optional
|
|
||||||
- forbidden
|
|
||||||
artifactModes:
|
|
||||||
type: array
|
|
||||||
minItems: 1
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ArtifactMode'
|
|
||||||
includedFamilies:
|
|
||||||
type: array
|
|
||||||
minItems: 1
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
excludedFamilies:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
nonBrowserFeatureIntegrationSelectorCount:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
includedSelectors:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
excludedSelectors:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
budgetThresholdSeconds:
|
|
||||||
type: integer
|
|
||||||
minimum: 1
|
|
||||||
budgetBaselineSource:
|
|
||||||
$ref: '#/components/schemas/BudgetBaselineSource'
|
|
||||||
budgetEnforcement:
|
|
||||||
$ref: '#/components/schemas/BudgetEnforcement'
|
|
||||||
budgetLifecycleState:
|
|
||||||
$ref: '#/components/schemas/BudgetLifecycleState'
|
|
||||||
baselineDeltaTargetPercent:
|
|
||||||
type: integer
|
|
||||||
minimum: 1
|
|
||||||
maximum: 100
|
|
||||||
artifactDirectory:
|
|
||||||
type: string
|
|
||||||
const: storage/logs/test-lanes
|
|
||||||
description: App-root relative directory for emitted artifacts. The first slice fixes this to storage/logs/test-lanes.
|
|
||||||
allOf:
|
|
||||||
- if:
|
|
||||||
properties:
|
|
||||||
laneId:
|
|
||||||
const: fast-feedback
|
|
||||||
then:
|
|
||||||
required:
|
|
||||||
- baselineDeltaTargetPercent
|
|
||||||
properties:
|
|
||||||
baselineDeltaTargetPercent:
|
|
||||||
const: 50
|
|
||||||
parallelMode:
|
|
||||||
const: required
|
|
||||||
excludedFamilies:
|
|
||||||
allOf:
|
|
||||||
- contains:
|
|
||||||
const: browser
|
|
||||||
- contains:
|
|
||||||
const: heavy-governance
|
|
||||||
- if:
|
|
||||||
properties:
|
|
||||||
laneId:
|
|
||||||
const: confidence
|
|
||||||
then:
|
|
||||||
properties:
|
|
||||||
parallelMode:
|
|
||||||
const: required
|
|
||||||
includedFamilies:
|
|
||||||
allOf:
|
|
||||||
- contains:
|
|
||||||
const: unit
|
|
||||||
- contains:
|
|
||||||
const: non-browser-feature-integration
|
|
||||||
nonBrowserFeatureIntegrationSelectorCount:
|
|
||||||
minimum: 1
|
|
||||||
excludedFamilies:
|
|
||||||
allOf:
|
|
||||||
- contains:
|
|
||||||
const: browser
|
|
||||||
- contains:
|
|
||||||
const: heavy-governance
|
|
||||||
- if:
|
|
||||||
properties:
|
|
||||||
laneId:
|
|
||||||
const: profiling
|
|
||||||
then:
|
|
||||||
properties:
|
|
||||||
governanceClass:
|
|
||||||
const: support
|
|
||||||
parallelMode:
|
|
||||||
const: forbidden
|
|
||||||
artifactModes:
|
|
||||||
contains:
|
|
||||||
const: profile-top
|
|
||||||
- if:
|
|
||||||
properties:
|
|
||||||
laneId:
|
|
||||||
const: junit
|
|
||||||
then:
|
|
||||||
properties:
|
|
||||||
governanceClass:
|
|
||||||
const: support
|
|
||||||
artifactModes:
|
|
||||||
contains:
|
|
||||||
const: junit-xml
|
|
||||||
- if:
|
|
||||||
properties:
|
|
||||||
laneId:
|
|
||||||
const: browser
|
|
||||||
then:
|
|
||||||
properties:
|
|
||||||
includedFamilies:
|
|
||||||
contains:
|
|
||||||
const: browser
|
|
||||||
LaneReport:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- laneId
|
|
||||||
- finishedAt
|
|
||||||
- wallClockSeconds
|
|
||||||
- budgetThresholdSeconds
|
|
||||||
- budgetBaselineSource
|
|
||||||
- budgetEnforcement
|
|
||||||
- budgetLifecycleState
|
|
||||||
- budgetStatus
|
|
||||||
- slowestEntries
|
|
||||||
- familyBudgetEvaluations
|
|
||||||
- artifacts
|
|
||||||
properties:
|
|
||||||
laneId:
|
|
||||||
$ref: '#/components/schemas/LaneId'
|
|
||||||
finishedAt:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
wallClockSeconds:
|
|
||||||
type: number
|
|
||||||
minimum: 0
|
|
||||||
budgetThresholdSeconds:
|
|
||||||
type: integer
|
|
||||||
minimum: 1
|
|
||||||
budgetBaselineSource:
|
|
||||||
$ref: '#/components/schemas/BudgetBaselineSource'
|
|
||||||
budgetEnforcement:
|
|
||||||
$ref: '#/components/schemas/BudgetEnforcement'
|
|
||||||
budgetLifecycleState:
|
|
||||||
$ref: '#/components/schemas/BudgetLifecycleState'
|
|
||||||
baselineDeltaTargetPercent:
|
|
||||||
type: integer
|
|
||||||
minimum: 1
|
|
||||||
maximum: 100
|
|
||||||
budgetStatus:
|
|
||||||
$ref: '#/components/schemas/BudgetStatus'
|
|
||||||
slowestEntries:
|
|
||||||
type: array
|
|
||||||
minItems: 10
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/SlowEntry'
|
|
||||||
familyBudgetEvaluations:
|
|
||||||
type: array
|
|
||||||
minItems: 1
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/FamilyBudgetEvaluation'
|
|
||||||
artifacts:
|
|
||||||
type: array
|
|
||||||
minItems: 1
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ArtifactRecord'
|
|
||||||
allOf:
|
|
||||||
- if:
|
|
||||||
properties:
|
|
||||||
laneId:
|
|
||||||
const: fast-feedback
|
|
||||||
then:
|
|
||||||
properties:
|
|
||||||
baselineDeltaTargetPercent:
|
|
||||||
const: 50
|
|
||||||
- if:
|
|
||||||
properties:
|
|
||||||
laneId:
|
|
||||||
const: profiling
|
|
||||||
then:
|
|
||||||
properties:
|
|
||||||
artifacts:
|
|
||||||
contains:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- artifactMode
|
|
||||||
properties:
|
|
||||||
artifactMode:
|
|
||||||
const: profile-top
|
|
||||||
- if:
|
|
||||||
properties:
|
|
||||||
laneId:
|
|
||||||
const: junit
|
|
||||||
then:
|
|
||||||
properties:
|
|
||||||
artifacts:
|
|
||||||
contains:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- artifactMode
|
|
||||||
properties:
|
|
||||||
artifactMode:
|
|
||||||
const: junit-xml
|
|
||||||
SlowEntry:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- subject
|
|
||||||
- durationSeconds
|
|
||||||
- laneId
|
|
||||||
properties:
|
|
||||||
subject:
|
|
||||||
type: string
|
|
||||||
durationSeconds:
|
|
||||||
type: number
|
|
||||||
minimum: 0
|
|
||||||
laneId:
|
|
||||||
$ref: '#/components/schemas/LaneId'
|
|
||||||
familyId:
|
|
||||||
type: string
|
|
||||||
ArtifactRecord:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- artifactMode
|
|
||||||
- relativePath
|
|
||||||
properties:
|
|
||||||
artifactMode:
|
|
||||||
$ref: '#/components/schemas/ArtifactMode'
|
|
||||||
relativePath:
|
|
||||||
type: string
|
|
||||||
pattern: ^storage/logs/test-lanes/
|
|
||||||
machineReadable:
|
|
||||||
type: boolean
|
|
||||||
FamilyBudgetEvaluation:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- familyId
|
|
||||||
- thresholdSeconds
|
|
||||||
- baselineSource
|
|
||||||
- enforcement
|
|
||||||
- lifecycleState
|
|
||||||
- measuredSeconds
|
|
||||||
- budgetStatus
|
|
||||||
properties:
|
|
||||||
familyId:
|
|
||||||
type: string
|
|
||||||
thresholdSeconds:
|
|
||||||
type: integer
|
|
||||||
minimum: 1
|
|
||||||
baselineSource:
|
|
||||||
$ref: '#/components/schemas/BudgetBaselineSource'
|
|
||||||
enforcement:
|
|
||||||
$ref: '#/components/schemas/BudgetEnforcement'
|
|
||||||
lifecycleState:
|
|
||||||
$ref: '#/components/schemas/BudgetLifecycleState'
|
|
||||||
measuredSeconds:
|
|
||||||
type: number
|
|
||||||
minimum: 0
|
|
||||||
budgetStatus:
|
|
||||||
$ref: '#/components/schemas/BudgetStatus'
|
|
||||||
matchedSelectors:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
@ -1,238 +0,0 @@
|
|||||||
# Data Model: Test Suite Governance & Performance Foundation
|
|
||||||
|
|
||||||
This feature does not introduce new runtime database tables. The data-model work formalizes repository-level governance objects that define how tests are grouped, how heavy setup is declared, and how runtime drift is reported. The first slice uses four operational lanes (`fast-feedback`, `confidence`, `browser`, and `heavy-governance`) plus two support-lane entries (`profiling` and `junit`) that share the same manifest and reporting contract shape while keeping narrower responsibilities. Field names in this document are conceptual and may appear in camelCase form in the checked-in manifest and logical reporting contracts. In particular, `lane_id` maps to manifest `id` and report `laneId`, `artifact_modes` maps to manifest `artifacts`, accepted-run `artifactModes`, and report `artifacts[].artifactMode`, and `enforcement_level` maps to manifest-contract `enforcement`.
|
|
||||||
|
|
||||||
## 1. Test Lane
|
|
||||||
|
|
||||||
### Purpose
|
|
||||||
|
|
||||||
Represents one checked-in execution path or support-lane entry for the suite.
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
- `lane_id`: stable identifier such as `fast-feedback`, `confidence`, `heavy-governance`, `browser`, `profiling`, or `junit`
|
|
||||||
- `governance_class`: `fast`, `confidence`, `heavy`, or `support`
|
|
||||||
- `description`: contributor-facing statement of the lane's purpose
|
|
||||||
- `intended_audience`: the contributor or reviewer audience the lane is optimized for
|
|
||||||
- `included_families`: written list of the families the lane intentionally owns
|
|
||||||
- `excluded_families`: written list of the families the lane intentionally leaves out
|
|
||||||
- `ownership_expectations`: contributor-facing statement of when the lane is expected to be run and maintained
|
|
||||||
- `default_entry_point`: boolean
|
|
||||||
- `parallel_mode`: `required`, `optional`, or `forbidden`
|
|
||||||
- `selectors`: include and exclude rules for suites, directories, files, and groups
|
|
||||||
- `artifact_modes`: list of expected outputs such as `summary`, `junit-xml`, `profile-top`, or `budget-report`
|
|
||||||
- `budget`: runtime target and enforcement level
|
|
||||||
- `db_strategy`: expected connection and reset discipline
|
|
||||||
|
|
||||||
### Validation rules
|
|
||||||
|
|
||||||
- Exactly one lane may be the default contributor entry point.
|
|
||||||
- The first-slice manifest must include the four operational lanes and the two support runs.
|
|
||||||
- Support runs remain lane-shaped manifest entries with `governance_class = support` and the same reporting envelope as operational lanes.
|
|
||||||
- A lane that emits `profile-top` must use `parallel_mode = forbidden`.
|
|
||||||
- A lane with `lane_id = browser` must not be the default contributor lane.
|
|
||||||
- A lane must have at least one positive selector.
|
|
||||||
- Every lane declaration must record its purpose or description, intended audience, included families, excluded families, and ownership expectations.
|
|
||||||
- The `fast-feedback` lane budget must carry `baseline_delta_target_percent = 50` for the first slice.
|
|
||||||
|
|
||||||
## 2. Lane Selector
|
|
||||||
|
|
||||||
### Purpose
|
|
||||||
|
|
||||||
Represents one inclusion or exclusion rule that determines lane membership.
|
|
||||||
|
|
||||||
In the first-slice manifest contract, selector records are serialized into the flattened include or exclude arrays on each lane entry. The richer selector fields below remain conceptual metadata for planning, review, and future helper logic rather than required manifest fields.
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
- `selector_type`: `testsuite`, `path`, `group`, or `file`
|
|
||||||
- `selector_value`: exact suite name, directory path, group name, or file path
|
|
||||||
- `inclusion_mode`: `include` or `exclude`
|
|
||||||
- `rationale`: short explanation of why the selector belongs to the lane
|
|
||||||
- `cost_class`: optional `normal`, `heavy`, or `special-case`
|
|
||||||
|
|
||||||
### Relationships
|
|
||||||
|
|
||||||
- Many selectors belong to one test lane.
|
|
||||||
- Selectors may be shared conceptually across lanes but are evaluated per lane declaration.
|
|
||||||
|
|
||||||
## 3. Test Classification Rule
|
|
||||||
|
|
||||||
### Purpose
|
|
||||||
|
|
||||||
Defines what makes a test legitimately belong to a particular classification.
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
- `classification`: `unit`, `feature-integration`, `browser`, or `architecture-governance`
|
|
||||||
- `laravel_boot_allowed`: boolean
|
|
||||||
- `database_allowed`: boolean
|
|
||||||
- `browser_runtime_required`: boolean
|
|
||||||
- `livewire_or_filament_mount_allowed`: boolean
|
|
||||||
- `default_lane_target`: lane identifier
|
|
||||||
- `promotion_conditions`: list of conditions that force escalation into a heavier lane
|
|
||||||
|
|
||||||
### Validation rules
|
|
||||||
|
|
||||||
- `unit` classification must not require browser runtime.
|
|
||||||
- `browser` classification must require browser runtime.
|
|
||||||
- Tests that need broad file scans, whole-surface discovery, or smoke assertions across many pages must not default into the fast-feedback lane.
|
|
||||||
|
|
||||||
## 4. Shared Fixture Profile
|
|
||||||
|
|
||||||
### Purpose
|
|
||||||
|
|
||||||
Represents a named setup profile for a shared test helper.
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
- `helper_name`: example `createUserWithTenant`
|
|
||||||
- `profile_name`: `minimal`, `full`, or another explicit cost-bearing name
|
|
||||||
- `default_profile`: boolean
|
|
||||||
- `creates_workspace`: boolean
|
|
||||||
- `creates_membership`: boolean
|
|
||||||
- `creates_credentials`: boolean
|
|
||||||
- `creates_provider_connection`: boolean
|
|
||||||
- `sets_session_context`: boolean
|
|
||||||
- `creates_ui_context`: boolean
|
|
||||||
- `clears_capability_caches`: boolean
|
|
||||||
- `notes`: contributor guidance on when this profile is appropriate
|
|
||||||
|
|
||||||
### Validation rules
|
|
||||||
|
|
||||||
- Each shared helper must have exactly one default profile.
|
|
||||||
- A default profile should not create workspace, membership, credentials, provider connection state, session context, cache-clearing side effects, or UI context unless the helper's primary job is specifically to set up that heavier behavior.
|
|
||||||
- Profiles with materially heavier behavior must use explicit names rather than boolean flags only.
|
|
||||||
|
|
||||||
## 5. Factory Cost Profile
|
|
||||||
|
|
||||||
### Purpose
|
|
||||||
|
|
||||||
Describes the cost posture of a factory state used in tests.
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
- `factory_name`
|
|
||||||
- `state_name`
|
|
||||||
- `cost_class`: `minimal`, `standard`, or `heavy`
|
|
||||||
- `creates_relationships`: list of related models or graph expansions
|
|
||||||
- `intended_usage`: short explanation of the state's purpose
|
|
||||||
- `safe_default`: boolean
|
|
||||||
|
|
||||||
### Validation rules
|
|
||||||
|
|
||||||
- A heavy state must document which additional relationships it creates.
|
|
||||||
- Factories used in high-volume tests should expose at least one minimal or standard state that avoids surprising graph expansion.
|
|
||||||
|
|
||||||
## 6. Database Reset Policy
|
|
||||||
|
|
||||||
### Purpose
|
|
||||||
|
|
||||||
Formalizes the allowed DB behavior for a suite or lane.
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
- `target_scope`: `unit`, `feature`, `browser`, `pgsql`, or `lane-specific`
|
|
||||||
- `connection_mode`: `sqlite-memory`, `pgsql`, or `mixed`
|
|
||||||
- `reset_strategy`: `none`, `refresh-database`, or `dedicated-suite`
|
|
||||||
- `seeds_policy`: `forbidden`, `restricted`, or `allowed-by-exception`
|
|
||||||
- `schema_baseline_candidate`: boolean
|
|
||||||
- `notes`
|
|
||||||
|
|
||||||
### Validation rules
|
|
||||||
|
|
||||||
- A policy using `connection_mode = sqlite-memory` should treat schema-baseline work as optional evidence rather than assumed default value.
|
|
||||||
- A policy using `reset_strategy = dedicated-suite` must name the target suite or command path that justifies the exception.
|
|
||||||
|
|
||||||
## 7. Runtime Budget
|
|
||||||
|
|
||||||
### Purpose
|
|
||||||
|
|
||||||
Represents the expected wall-clock target for a lane or known heavy family.
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
- `budget_id`
|
|
||||||
- `target_type`: `lane` or `family`
|
|
||||||
- `target_id`: lane identifier or family identifier
|
|
||||||
- `threshold_seconds`
|
|
||||||
- `baseline_source`: `measured-current-suite` or `measured-lane`
|
|
||||||
- `enforcement_level`: `report-only`, `warn`, or `hard-fail`
|
|
||||||
- `baseline_delta_target_percent`: optional percentage reduction target versus the current full-suite baseline when applicable, such as the fast-feedback 50% target
|
|
||||||
- `lifecycle_state`: `draft`, `measured`, `documented`, or `enforced`
|
|
||||||
- `review_cadence`: short rule such as `tighten after two stable runs`
|
|
||||||
|
|
||||||
### State transitions
|
|
||||||
|
|
||||||
- `draft` → `measured`
|
|
||||||
- `measured` → `documented`
|
|
||||||
- `documented` → `enforced`
|
|
||||||
|
|
||||||
## 8. Lane Report Artifact
|
|
||||||
|
|
||||||
### Purpose
|
|
||||||
|
|
||||||
Represents one machine-readable or human-readable artifact emitted by a lane.
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
- `artifact_type`: `summary`, `junit-xml`, `profile-top`, or `budget-report`
|
|
||||||
- `relative_path`: app-root relative path under `storage/logs/test-lanes`
|
|
||||||
- `machine_readable`: boolean
|
|
||||||
- `generated_by_lanes`: list of lane identifiers
|
|
||||||
- `retention_scope`: `local-ephemeral`
|
|
||||||
|
|
||||||
### Validation rules
|
|
||||||
|
|
||||||
- Artifact paths must be relative to `apps/platform` because `scripts/platform-sail` changes into the app directory.
|
|
||||||
- Machine-readable budget evaluation must include the measured duration, threshold, and status.
|
|
||||||
- First-slice artifact retention is local-only under `storage/logs/test-lanes`; CI upload is later hardening work, not part of this contract.
|
|
||||||
|
|
||||||
## 8a. Family Budget Threshold
|
|
||||||
|
|
||||||
### Purpose
|
|
||||||
|
|
||||||
Represents the budget contract for a heavy file, group, path, or suite family that is tracked alongside lane-level budgets.
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
- `family_id`: stable identifier for the heavy family
|
|
||||||
- `selector_type`: `testsuite`, `path`, `group`, or `file`
|
|
||||||
- `selectors`: one or more selectors that identify the tracked family
|
|
||||||
- `threshold_seconds`: allowed wall-clock target for the family cluster
|
|
||||||
- `baseline_source`: `measured-current-suite` or `measured-lane`
|
|
||||||
- `enforcement_level`: `report-only`, `warn`, or `hard-fail`
|
|
||||||
- `notes`
|
|
||||||
- `lifecycle_state`: `draft`, `measured`, `documented`, or `enforced`
|
|
||||||
|
|
||||||
### Validation rules
|
|
||||||
|
|
||||||
- Every first-slice manifest must carry at least one family budget threshold in addition to lane-level budgets.
|
|
||||||
- Family budget thresholds must use the same baseline-backed source vocabulary as lane budgets.
|
|
||||||
|
|
||||||
## 9. First-Slice Governance Inventory
|
|
||||||
|
|
||||||
### Current suite footprint
|
|
||||||
|
|
||||||
- Approximate test file count: `1,122`
|
|
||||||
- Feature files: `873`
|
|
||||||
- Unit files: `234`
|
|
||||||
- Browser files: `12`
|
|
||||||
- Architecture files: `2`
|
|
||||||
- Deprecation files: `1`
|
|
||||||
|
|
||||||
### Current hotspots
|
|
||||||
|
|
||||||
- `createUserWithTenant()` is referenced by roughly `607` test files and currently provisions user, tenant, workspace, workspace membership, session context, capability cache clears, and provider connection state by default.
|
|
||||||
- `tests/Pest.php` applies `RefreshDatabase` broadly to `tests/Feature` and `tests/Browser`.
|
|
||||||
- Existing explicit Pest groups are sparse, with `ops-ux` as the largest visible group seam.
|
|
||||||
|
|
||||||
### First-slice target objects
|
|
||||||
|
|
||||||
- `fast-feedback` lane definition
|
|
||||||
- `confidence` lane definition
|
|
||||||
- `heavy-governance` lane definition
|
|
||||||
- `browser` execution target
|
|
||||||
- `profiling` and `junit` support targets
|
|
||||||
- `createUserWithTenant` minimal and full fixture profiles
|
|
||||||
- At least one additional factory or fixture cluster with explicit minimal and heavy cost profiles
|
|
||||||
@ -1,164 +0,0 @@
|
|||||||
# Implementation Plan: Test Suite Governance & Performance Foundation
|
|
||||||
|
|
||||||
**Branch**: `206-test-suite-governance` | **Date**: 2026-04-16 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/206-test-suite-governance/spec.md`
|
|
||||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/206-test-suite-governance/spec.md`
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Establish repo-wired test-suite governance on top of the current Laravel 12, Pest 4, and Sail stack by defining explicit fast-feedback, confidence, heavy-governance, and browser execution paths; using Sail-wrapped `artisan test` as the canonical checked-in runner; standardizing JUnit and slow-test reporting artifacts; slimming the most pervasive shared helper and factory defaults; and documenting runtime budgets and DB-reset rules without adding new runtime product surfaces.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4.15
|
|
||||||
**Primary Dependencies**: Laravel 12, Pest v4, PHPUnit 12, Pest Browser plugin, Filament v5, Livewire v4, Laravel Sail
|
|
||||||
**Storage**: SQLite `:memory:` for the default test configuration, dedicated PostgreSQL config for the schema-level `Pgsql` suite, and local runner artifacts under the repo path `apps/platform/storage/logs/test-lanes` with the app-root contract value `storage/logs/test-lanes`
|
|
||||||
**Testing**: Pest unit, feature, browser, architecture, and guard-style suites run via `artisan test`; `RefreshDatabase` is currently auto-applied to `tests/Feature` and `tests/Browser`; profiling must run serially because Pest rejects `--profile` with `--parallel`
|
|
||||||
**Target Platform**: Laravel web application in `apps/platform`, executed locally through Sail on macOS/Linux with later CI hardening on shared runners
|
|
||||||
**Project Type**: Monorepo with a Laravel platform app and separate Astro website; this feature is scoped to platform test infrastructure and repo-level command entry points
|
|
||||||
**Performance Goals**: Fast-feedback lane at least 50% below the current full-suite baseline, confidence lane below the current full-suite baseline, browser and heavy-governance runs isolated from the default loop, and reporting lanes that surface the top 10 slowest tests or files
|
|
||||||
**Constraints**: Sail-first commands only; `scripts/platform-sail` changes the working directory to `apps/platform`; no new runtime routes, panels, assets, or dependencies; artifact paths must be app-root relative; `RefreshDatabase` remains the first-slice reset default unless a targeted carve-out is explicitly justified
|
|
||||||
**Scale/Scope**: Current suite footprint is approximately 1,122 test files (`873` feature, `234` unit, `12` browser, `2` architecture, `1` deprecation), existing Pest grouping is sparse (`ops-ux`, `spec081`), and `createUserWithTenant()` is referenced by roughly `607` test files
|
|
||||||
|
|
||||||
### Filament v5 Implementation Notes
|
|
||||||
|
|
||||||
- **Livewire v4.0+ compliance**: Preserved. This feature governs tests around existing Filament and Livewire surfaces but does not alter the runtime Filament stack.
|
|
||||||
- **Provider registration location**: Unchanged. Existing panel providers remain registered in `bootstrap/providers.php`.
|
|
||||||
- **Global search rule**: No globally searchable resources are added or changed.
|
|
||||||
- **Destructive actions**: Runtime destructive behavior is unchanged. Any new tests added by this feature continue to assert existing confirmation and authorization rules rather than introducing new action behavior.
|
|
||||||
- **Asset strategy**: No new panel or shared assets are introduced. Existing `filament:assets` deployment behavior remains unchanged.
|
|
||||||
- **Testing plan**: Add Pest coverage for lane manifest validity, command selection, helper minimal/full defaults, browser-lane isolation, artifact generation, and guard regressions that prevent lane drift.
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
|
||||||
|
|
||||||
- Inventory-first: PASS. No inventory, backup, or snapshot truth is changed.
|
|
||||||
- Read/write separation: PASS. The feature governs repository test execution only and introduces no end-user write path.
|
|
||||||
- Graph contract path: PASS. No Graph calls, contract-registry changes, or provider runtime integrations are added.
|
|
||||||
- Deterministic capabilities: PASS. No capability model or authorization registry changes.
|
|
||||||
- RBAC-UX, workspace isolation, tenant isolation: PASS. No runtime route, policy, or tenant/workspace surface is changed.
|
|
||||||
- Run observability and Ops-UX: PASS. Reporting artifacts are local test-run outputs, not `OperationRun` records or operator notifications.
|
|
||||||
- Data minimization: PASS. Test artifacts stay in local log storage and contain runner metadata rather than secrets or customer payloads.
|
|
||||||
- Proportionality and bloat control: PASS WITH LIMITS. The only new semantic layer is a repo-local lane and taxonomy model bounded to test execution, reporting, and cheap-default discipline.
|
|
||||||
- TEST-TRUTH-001: PASS. The plan improves suite honesty and business-truth protection by making heavy setup and slow regressions more visible.
|
|
||||||
- Filament/UI constitutions: PASS / NOT APPLICABLE. No operator-facing UI, action surface, badge semantics, or information architecture is changed.
|
|
||||||
|
|
||||||
**Phase 0 Gate Result**: PASS
|
|
||||||
|
|
||||||
- The feature is bounded to repo test governance, reporting, and fixture discipline.
|
|
||||||
- No new runtime persistence, product routes, panels, assets, or Graph behavior is introduced.
|
|
||||||
- The new lane taxonomy is narrow enough to satisfy PROP-001 while still creating a reusable repo standard.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/206-test-suite-governance/
|
|
||||||
├── plan.md
|
|
||||||
├── research.md
|
|
||||||
├── data-model.md
|
|
||||||
├── quickstart.md
|
|
||||||
├── contracts/
|
|
||||||
│ ├── test-lane-manifest.schema.json
|
|
||||||
│ └── test-suite-governance.logical.openapi.yaml
|
|
||||||
└── tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/
|
|
||||||
├── platform/
|
|
||||||
│ ├── composer.json
|
|
||||||
│ ├── phpunit.xml
|
|
||||||
│ ├── phpunit.pgsql.xml
|
|
||||||
│ ├── tests/
|
|
||||||
│ │ ├── Pest.php
|
|
||||||
│ │ ├── TestCase.php
|
|
||||||
│ │ ├── Unit/
|
|
||||||
│ │ ├── Feature/
|
|
||||||
│ │ ├── Browser/
|
|
||||||
│ │ ├── Architecture/
|
|
||||||
│ │ ├── Deprecation/
|
|
||||||
│ │ └── Support/
|
|
||||||
│ ├── database/factories/
|
|
||||||
│ └── storage/logs/
|
|
||||||
├── website/
|
|
||||||
└── ...
|
|
||||||
package.json
|
|
||||||
scripts/
|
|
||||||
└── platform-sail
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Use the existing monorepo and keep implementation concentrated in `apps/platform` test bootstrap/configuration plus checked-in command seams. The feature is expected to touch `apps/platform/composer.json`, `apps/platform/phpunit.xml`, `apps/platform/tests/Pest.php`, selected factory/helper files under `apps/platform/tests` and `apps/platform/database/factories`, and focused guard or performance tests under `apps/platform/tests/Feature` and `apps/platform/tests/Unit`.
|
|
||||||
|
|
||||||
## Phase 0 — Research (complete)
|
|
||||||
|
|
||||||
- Output: [research.md](./research.md)
|
|
||||||
- Resolved key decisions:
|
|
||||||
- Use Sail-wrapped `artisan test` as the canonical checked-in runner for fast, confidence, browser, heavy, profile, and JUnit paths.
|
|
||||||
- Model lane membership through a hybrid of existing PHPUnit suites, directory selectors, and curated Pest groups instead of a full folder reorganization.
|
|
||||||
- Keep fast-feedback and confidence lanes parallelized, but require serial profiling because Pest forbids `--profile` with `--parallel`.
|
|
||||||
- Keep `RefreshDatabase` as the first-slice default for feature and browser tests while targeting helper and factory slimming first.
|
|
||||||
- Split the dominant shared tenant-user helper into minimal and explicit heavy profiles because it currently provisions workspace, membership, session, cache, and provider connection state by default.
|
|
||||||
- Standardize machine-readable artifacts and budget summaries under the repo path `apps/platform/storage/logs/test-lanes`, which corresponds to the app-root contract value `storage/logs/test-lanes`, because `scripts/platform-sail` already runs from the app root.
|
|
||||||
- Treat schema dumps as a follow-up evaluation, not a first-slice requirement, because the default suite uses in-memory SQLite and the PostgreSQL suite is currently isolated.
|
|
||||||
|
|
||||||
## Phase 1 — Design & Contracts (complete)
|
|
||||||
|
|
||||||
- Output: [data-model.md](./data-model.md) formalizes lane, selector, helper-profile, factory-cost, DB-reset, artifact, and budget entities.
|
|
||||||
- Output: [contracts/test-lane-manifest.schema.json](./contracts/test-lane-manifest.schema.json) defines the checked-in manifest structure for lane membership, artifacts, budgets, and DB strategy.
|
|
||||||
- Output: [contracts/test-suite-governance.logical.openapi.yaml](./contracts/test-suite-governance.logical.openapi.yaml) captures the logical run/report contract for lane execution and artifact inspection.
|
|
||||||
- Output: [quickstart.md](./quickstart.md) provides the planned implementation order, focused validation commands, and rollout checkpoints.
|
|
||||||
|
|
||||||
### Post-design Constitution Re-check
|
|
||||||
|
|
||||||
- PASS: No runtime routes, panels, authorization planes, or Graph seams are introduced.
|
|
||||||
- PASS: The only new taxonomy is repo-local and directly justified by current suite cost and contributor workflow.
|
|
||||||
- PASS: The design prefers local selectors, helper splits, and manifest-backed commands over new generalized platform abstractions.
|
|
||||||
- PASS WITH WORK: Initial heavy-family selection must remain evidence-driven and should start with obviously separate seed families such as architecture, deprecation, browser-adjacent governance scans, and wide scan or guard suites, while browser itself remains its own dedicated lane, then be refined after the first profiling pass.
|
|
||||||
- PASS WITH WORK: Budget enforcement starts as documented reporting thresholds and can harden later once baseline measurements stabilize.
|
|
||||||
|
|
||||||
## Phase 2 — Implementation Planning
|
|
||||||
|
|
||||||
`tasks.md` should cover:
|
|
||||||
|
|
||||||
- Adding the checked-in lane command entry points for fast-feedback, confidence, browser, heavy-governance, profiling, and JUnit/reporting.
|
|
||||||
- Introducing a lane manifest or equivalent checked-in selector map that combines suites, directories, files, and groups without broad test relocation.
|
|
||||||
- Keeping the default contributor run aligned to fast-feedback rather than the current broad serial behavior.
|
|
||||||
- Creating artifact output conventions and summary generation under the repo path `apps/platform/storage/logs/test-lanes` and the app-root contract value `storage/logs/test-lanes`.
|
|
||||||
- Adding guard coverage that verifies browser exclusion from the fast lane, valid lane manifest shape, and stable budget/report semantics.
|
|
||||||
- Capturing the current full-suite baseline and the first measured lane baselines before final budget publication.
|
|
||||||
- Refactoring `createUserWithTenant()` into minimal and explicit heavy/provider-enabled paths, then migrating the first high-value callers.
|
|
||||||
- Introducing explicit minimal versus heavy factory states for at least one additional cascading fixture cluster touched in this slice, plus guidance that future touched heavy clusters must follow the same pattern.
|
|
||||||
- Documenting honest taxonomy rules for Unit, Feature or Integration, Browser, and Architecture or Governance tests, plus auditing and reclassifying the first obvious misfit batch surfaced during rollout.
|
|
||||||
- Defining and documenting initial runtime budgets for at least fast-feedback, confidence, browser, and the first identified heavy family, with heavy-governance refinement informed by the first profiling evidence.
|
|
||||||
- Capturing DB strategy guidance for SQLite-memory default runs, the isolated PostgreSQL suite, seeds policy, and later schema-baseline evaluation.
|
|
||||||
|
|
||||||
### Contract Implementation Note
|
|
||||||
|
|
||||||
- The lane manifest contract is schema-first and intentionally runner-agnostic. It defines what a checked-in lane declaration must contain even if the first implementation stores it in PHP arrays or config instead of a dedicated parser.
|
|
||||||
- The OpenAPI file is logical rather than transport-prescriptive. It documents the expected semantics of lane execution and report generation for commands, tasks, or wrappers that will remain in-process repository tooling.
|
|
||||||
- The plan intentionally avoids introducing a new runtime service or database table for lane reporting. Artifacts remain filesystem-based and ephemeral.
|
|
||||||
|
|
||||||
### Deployment Sequencing Note
|
|
||||||
|
|
||||||
- No database migration is planned.
|
|
||||||
- No asset publish step changes.
|
|
||||||
- The rollout should start with lane/report visibility, then cheap helper defaults, then lane reshaping of the heaviest families, and only afterward optional framework-level tuning such as schema-baseline evaluation.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
|
||||||
|-----------|------------|-------------------------------------|
|
|
||||||
| None | Not applicable | Not applicable |
|
|
||||||
|
|
||||||
## Proportionality Review
|
|
||||||
|
|
||||||
- **Current operator problem**: Contributors cannot reliably choose the right test lane, and maintainers cannot see slow-test drift or hidden heavy setup early enough to keep the default run healthy.
|
|
||||||
- **Existing structure is insufficient because**: Current execution relies on a broad serial default, sparse Pest groups, heavyweight shared defaults, and ad-hoc local reporting rather than checked-in governance.
|
|
||||||
- **Narrowest correct implementation**: A repo-local lane manifest, explicit checked-in commands, artifact/report conventions, and minimal/full fixture profiles solve the problem without adding runtime services or persistence.
|
|
||||||
- **Ownership cost created**: The repo must maintain lane selectors, helper profile rules, budgets, and a small set of guard tests that keep drift visible.
|
|
||||||
- **Alternative intentionally rejected**: Pure parallelization without taxonomy, or local-only helper workarounds without checked-in reporting and lane definitions.
|
|
||||||
- **Release truth**: Current-release truth and immediate prerequisite for larger suite growth and later CI hardening.
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
# Quickstart: Test Suite Governance & Performance Foundation
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Establish a fast default contributor run, a broader confidence run that owns the non-browser feature or integration middle path, dedicated browser and heavy-governance lanes, visible slow-test reporting, and cheap shared defaults for the most widely used fixture paths.
|
|
||||||
|
|
||||||
## Implementation Order
|
|
||||||
|
|
||||||
1. Introduce the checked-in lane manifest or equivalent selector map that combines suites, directories, groups, and explicit files.
|
|
||||||
2. Add the checked-in lane command entry points for fast-feedback, confidence, browser, heavy-governance, profiling, and JUnit reporting.
|
|
||||||
3. Align the default contributor run with fast-feedback and define the confidence lane as the Unit suite plus the manifest-defined non-browser Feature or Integration selectors outside explicit heavy-governance exclusions.
|
|
||||||
4. Seed the heavy-governance lane only with the obvious initial families, such as browser-adjacent governance scans, architecture, deprecation, and the first obvious guard-heavy clusters.
|
|
||||||
5. Wire artifact output to `storage/logs/test-lanes` and emit a budget-aware summary for each reporting path.
|
|
||||||
6. Add focused guard tests that verify browser exclusion, valid lane declarations, confidence-lane scope, and stable report semantics.
|
|
||||||
7. Run the first serial profiling pass and capture the current full-suite baseline plus the first measured lane baselines.
|
|
||||||
8. Refine the heavy-governance selectors from the profiling evidence and publish the initial runtime budgets.
|
|
||||||
9. Split `createUserWithTenant()` into a cheap default profile and an explicit heavy or provider-enabled profile.
|
|
||||||
10. Introduce minimal and heavy states for the next most expensive factory or fixture cluster touched in this slice.
|
|
||||||
11. Audit the obvious taxonomy misfits surfaced during rollout and reclassify or regroup the first batch.
|
|
||||||
12. Document the honest taxonomy rules, DB reset strategy, seed restrictions, and deferred schema-baseline evaluation rules.
|
|
||||||
|
|
||||||
## Suggested Code Touches
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/platform/composer.json
|
|
||||||
apps/platform/phpunit.xml
|
|
||||||
apps/platform/phpunit.pgsql.xml
|
|
||||||
apps/platform/tests/Pest.php
|
|
||||||
apps/platform/tests/TestCase.php
|
|
||||||
apps/platform/tests/Feature/Guards/*
|
|
||||||
apps/platform/tests/Architecture/*
|
|
||||||
apps/platform/tests/Deprecation/*
|
|
||||||
apps/platform/tests/Feature/OpsUx/*
|
|
||||||
apps/platform/tests/Browser/*
|
|
||||||
apps/platform/tests/Support/*
|
|
||||||
apps/platform/database/factories/*
|
|
||||||
scripts/platform-test-lane
|
|
||||||
scripts/platform-test-report
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation Flow
|
|
||||||
|
|
||||||
After the checked-in commands exist, validate the end-state workflow with the canonical repo-root wrappers:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/platform-test-lane fast-feedback
|
|
||||||
./scripts/platform-test-lane confidence
|
|
||||||
./scripts/platform-test-lane heavy-governance
|
|
||||||
./scripts/platform-test-lane browser
|
|
||||||
./scripts/platform-test-lane profiling
|
|
||||||
./scripts/platform-test-lane junit
|
|
||||||
./scripts/platform-test-report fast-feedback
|
|
||||||
./scripts/platform-test-report confidence
|
|
||||||
./scripts/platform-test-report heavy-governance
|
|
||||||
./scripts/platform-test-report browser
|
|
||||||
./scripts/platform-test-report profiling
|
|
||||||
./scripts/platform-test-report junit
|
|
||||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
|
||||||
```
|
|
||||||
|
|
||||||
The app-local Sail Composer commands remain valid, but the repo-root wrapper is the safer default for long lanes because it forces Composer to run with `--timeout=0`.
|
|
||||||
|
|
||||||
During implementation, keep the feedback loop tight by running the most relevant focused suites first:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact -c phpunit.pgsql.xml
|
|
||||||
```
|
|
||||||
|
|
||||||
## Recorded Baselines
|
|
||||||
|
|
||||||
These measurements were captured from the checked-in lane wrappers on the standard local Sail environment used for Spec 206 rollout.
|
|
||||||
|
|
||||||
| Scope | Wall clock | Budget | Status |
|
|
||||||
|-------|------------|--------|--------|
|
|
||||||
| Full suite baseline | `2624.60s` | reference only | Baseline anchor for lane targets |
|
|
||||||
| `fast-feedback` | `176.74s` | `200s` | Within budget and more than 50% below the full-suite baseline |
|
|
||||||
| `confidence` | `394.38s` | `450s` | Within budget and below the full-suite baseline |
|
|
||||||
| `heavy-governance` | `83.66s` | `120s` | Within budget |
|
|
||||||
| `browser` | `128.87s` | `150s` | Within budget |
|
|
||||||
| `junit` | `380.14s` | `450s` | Within budget |
|
|
||||||
| `profiling` | `2701.51s` | `3000s` | Within budget for the intentional serial drift run |
|
|
||||||
|
|
||||||
The first budget-backed family thresholds are:
|
|
||||||
|
|
||||||
- `ops-ux-governance`: `120s`
|
|
||||||
- `browser-smoke`: `150s`
|
|
||||||
|
|
||||||
## Taxonomy and Fixture Rules
|
|
||||||
|
|
||||||
- `Unit` owns isolated logic and helper coverage.
|
|
||||||
- `Feature` owns HTTP, Livewire, Filament, jobs, and non-browser integration slices.
|
|
||||||
- `Browser` owns only end-to-end browser smoke and workflow coverage.
|
|
||||||
- `heavy-governance` owns the first intentionally expensive families: `tests/Architecture`, `tests/Deprecation`, `tests/Feature/078`, `tests/Feature/090`, `tests/Feature/144`, `tests/Feature/OpsUx`, `tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php`, `tests/Feature/Guards/ActionSurfaceContractTest.php`, `tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, and `tests/Feature/ProviderConnections/CredentialLeakGuardTest.php`.
|
|
||||||
- `createUserWithTenant()` defaults to the cheap `minimal` profile.
|
|
||||||
- Use `createMinimalUserWithTenant()` for high-usage membership-focused callers.
|
|
||||||
- Use `provider-enabled`, `credential-enabled`, `ui-context`, or `heavy` only when the test truly needs those side effects.
|
|
||||||
- Default lanes stay on SQLite `:memory:` plus `RefreshDatabase`.
|
|
||||||
- The `Pgsql` suite stays isolated for schema and foreign-key assertions.
|
|
||||||
- Seeds stay opt-in. Do not make seeded state part of the default lane contract.
|
|
||||||
- Schema-baseline or dump acceleration remains follow-up work, not part of the first-slice lane contract.
|
|
||||||
|
|
||||||
## Manual Review Checklist
|
|
||||||
|
|
||||||
1. Confirm the default contributor command excludes browser and explicitly heavy governance families.
|
|
||||||
2. Confirm the confidence command covers the Unit suite plus the manifest-defined non-browser Feature or Integration selectors while still excluding the browser lane and explicit heavy-governance exclusions, and that its measured runtime remains below the current full-suite baseline.
|
|
||||||
3. Confirm each lane declaration records its purpose, intended audience, included families, excluded families, and ownership expectations.
|
|
||||||
4. Confirm the profiling support-lane entry runs serially and emits a ranked view with at least the top 10 slowest entries through the shared reporting envelope.
|
|
||||||
5. Confirm the JUnit support-lane entry writes machine-readable output under `storage/logs/test-lanes` through the same manifest and report contract shape.
|
|
||||||
6. Confirm the cheap default shared helper path no longer provisions workspace, membership, credentials, provider connection state, session context, cache-clearing side effects, or UI context unless explicitly requested.
|
|
||||||
7. Confirm at least one additional factory or fixture cluster exposes minimal versus heavy behavior explicitly.
|
|
||||||
8. Confirm the documented lane budgets and family thresholds are visible in report output, are anchored to the recorded measurements above, preserve the fast-feedback target of at least 50% below the current full-suite baseline, and keep the confidence lane below the current full-suite baseline.
|
|
||||||
9. Confirm the initial heavy-governance selector set was refined after the first profiling pass rather than locked purely from intuition.
|
|
||||||
10. Confirm the first obvious taxonomy misfits were reclassified or regrouped instead of only being documented.
|
|
||||||
|
|
||||||
## Exit Criteria
|
|
||||||
|
|
||||||
1. The repo has a single fast default contributor run and separate confidence, browser, heavy, profile, and JUnit entry points, with profile and JUnit represented as support-lane entries in the shared manifest and report contract.
|
|
||||||
2. Browser tests are not silently part of the fast path.
|
|
||||||
3. The confidence lane is defined as the Unit suite plus the manifest-defined non-browser Feature or Integration selectors that remain outside explicit heavy-governance exclusions.
|
|
||||||
4. Every lane declaration carries purpose, intended audience, included families, excluded families, and ownership expectations.
|
|
||||||
5. Slow-test visibility and machine-readable reports are available through checked-in commands, including a report with at least the top 10 slowest entries and family-threshold evaluation.
|
|
||||||
6. The dominant shared helper and at least one additional factory or fixture cluster have explicit cheap defaults.
|
|
||||||
7. Runtime budgets and family thresholds are documented from the recorded baseline measurements above, including the fast-feedback target of at least 50% below the current full-suite baseline and the confidence target of staying below the current full-suite baseline, and DB strategy guidance is tied to the lane model.
|
|
||||||
8. The first obvious taxonomy misfits have been reclassified or regrouped as part of the rollout.
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
# Research: Test Suite Governance & Performance Foundation
|
|
||||||
|
|
||||||
## Decision 1: Use Sail-wrapped `artisan test` as the canonical lane runner
|
|
||||||
|
|
||||||
- Decision: Checked-in execution paths should be expressed as Sail-compatible repository commands that ultimately run through Laravel's `artisan test` entry point.
|
|
||||||
- Rationale: Repo rules already require Sail-first execution for PHP and Artisan commands, and the current stack exposes parallel and profiling support through Laravel's test runner. Reusing the same runner keeps output, environment boot, and contributor ergonomics aligned with existing conventions.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Raw `vendor/bin/pest` as the primary interface: rejected because it would bypass the repo's standard Sail/Artisan workflow and fragment contributor guidance.
|
|
||||||
- Repo-root JavaScript scripts: rejected because PHP test orchestration in this repo currently lives in `apps/platform`, not in the root package manager flow.
|
|
||||||
- CI-only wrappers: rejected because the spec is explicitly about local defaults and shared authoring discipline, not just future CI.
|
|
||||||
|
|
||||||
## Decision 2: Build lane membership from a hybrid of suites, directories, files, and curated groups
|
|
||||||
|
|
||||||
- Decision: The lane model should combine existing PHPUnit suites (`Unit`, `Feature`, `Browser`, `Pgsql`), directory selectors, explicit file selectors, and curated Pest groups instead of forcing a one-time folder reorganization.
|
|
||||||
- Rationale: The repository already has clear suite directories, natural browser separation, and domain-heavy subfolders, but existing Pest grouping is sparse. A hybrid manifest lets the repo separate lanes immediately while leaving broad classification cleanup for follow-up slices.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Full directory reorganization before lane rollout: rejected because it adds high churn and delays the governance layer the spec is meant to establish.
|
|
||||||
- Group-only lane control: rejected because only a small part of the suite currently uses groups, so the immediate payoff would be too limited.
|
|
||||||
- Suite-only lane control: rejected because `Feature` is too large and heterogeneous to stand in for both fast-feedback and confidence lanes.
|
|
||||||
|
|
||||||
## Decision 3: Keep fast-feedback and confidence lanes parallel, but require serial profiling
|
|
||||||
|
|
||||||
- Decision: Fast-feedback and confidence lanes should default to parallel execution, while the profiling lane must remain serial.
|
|
||||||
- Rationale: Laravel's test runner supports `--parallel`, which directly addresses wall-clock time for the most common contributor flows. At the same time, the local Pest runtime explicitly rejects `--profile` when `--parallel` is present, so profiling must be its own serial command path.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Keep all lanes serial for simplicity: rejected because it would ignore an officially supported performance lever and preserve the current feedback bottleneck.
|
|
||||||
- Run profiling in parallel anyway: rejected because the local Pest runtime forbids the combination.
|
|
||||||
- Make every lane parallel including browser by default: rejected for the first slice because browser cost and environment contention deserve their own governance boundary.
|
|
||||||
|
|
||||||
## Decision 4: Keep `RefreshDatabase` as the first-slice reset default and optimize fixture cost first
|
|
||||||
|
|
||||||
- Decision: The first slice should leave the global `RefreshDatabase` bootstrap in place for `tests/Feature` and `tests/Browser`, while helper and factory defaults are slimmed before any attempt at suite-wide reset-strategy changes.
|
|
||||||
- Rationale: `tests/Pest.php` currently applies `RefreshDatabase` across the broadest and most expensive suites, and Laravel documents heavier full-reset strategies as slower than `RefreshDatabase`. The more urgent current cost driver is unnecessary fixture work layered on top of the reset strategy.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Replace `RefreshDatabase` with heavier full-reset traits: rejected because Laravel documents those as significantly slower and they would worsen the near-term problem.
|
|
||||||
- Remove the global reset default immediately: rejected because the suite is too large and would likely break in noisy, low-signal ways before cheap defaults are fixed.
|
|
||||||
- Ignore DB strategy entirely: rejected because the spec explicitly requires written guidance for DB-backed tests, seeds, and later schema-baseline evaluation.
|
|
||||||
|
|
||||||
## Decision 5: Split the dominant shared tenant-user helper into minimal and explicit heavy profiles
|
|
||||||
|
|
||||||
- Decision: `createUserWithTenant()` should gain a truly cheap default profile and an explicitly named heavy or provider-enabled path, instead of continuing to provision provider connection state by default.
|
|
||||||
- Rationale: The helper is referenced by roughly 607 test files and currently creates a user, tenant, workspace, workspace membership, session context, tenant membership link, capability cache clears, and a default Microsoft provider connection unless told otherwise. That makes it the highest-leverage fixture seam in the current suite.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Keep the current helper and rely on optional boolean flags: rejected because the expensive behavior remains the default and the cost stays visually hidden.
|
|
||||||
- Inline setup in every test: rejected because that would duplicate fixture logic and reduce consistency.
|
|
||||||
- Introduce a generic fixture framework first: rejected because PROP-001 and ABSTR-001 favor the smallest targeted change that fixes the current cost hotspot.
|
|
||||||
|
|
||||||
## Decision 6: Introduce explicit minimal and heavy factory states for cascading graphs
|
|
||||||
|
|
||||||
- Decision: At least one additional factory cluster beyond the shared tenant-user helper must expose documented minimal and heavy states, with the minimal state becoming the preferred default for new tests.
|
|
||||||
- Rationale: The suite already uses stateful factories heavily, and expensive object graphs often hide behind convenient defaults. The governance model needs one concrete factory discipline seam in addition to the shared helper split so new tests stop inheriting unnecessary relationships by accident.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Leave factory defaults untouched and only optimize helpers: rejected because helpers are not the only source of hidden cost.
|
|
||||||
- Rewrite all factories at once: rejected because it is too broad for the first slice and would create review noise.
|
|
||||||
- Document factory guidance without changing any states: rejected because the spec requires at least the largest known setup paths to gain minimal modes.
|
|
||||||
|
|
||||||
## Decision 7: Standardize report artifacts under `apps/platform/storage/logs/test-lanes`
|
|
||||||
|
|
||||||
- Decision: Lane reports, JUnit XML, slow-test summaries, and budget evaluations should be written under `apps/platform/storage/logs/test-lanes`.
|
|
||||||
- Rationale: Existing ad-hoc test logs already live under `apps/platform/storage/logs`, and `scripts/platform-sail` changes the working directory into `apps/platform` before execution. Keeping artifacts under the app's existing log tree avoids new repo roots, matches current workflow, and keeps path handling predictable.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Repo-root `tmp/` or custom top-level directories: rejected because repo guidance discourages new base folders and because the wrapper already anchors execution inside `apps/platform`.
|
|
||||||
- CI-only artifact paths: rejected because the spec requires everyday local visibility.
|
|
||||||
- Writing artifacts into committed spec directories: rejected because runtime artifacts are ephemeral execution outputs, not planning artifacts.
|
|
||||||
|
|
||||||
## Decision 8: Treat schema-baseline adoption as a follow-up evaluation, not a first-slice requirement
|
|
||||||
|
|
||||||
- Decision: The first slice should document when a prebuilt schema baseline may help but defer actual schema-dump adoption to a later hardening step.
|
|
||||||
- Rationale: The default test configuration uses in-memory SQLite, where migration history cost behaves differently than persistent database setups. The PostgreSQL configuration is currently isolated to a single suite, so schema-baseline work is not the highest-leverage first move.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Adopt schema dumps immediately: rejected because the current default runner is not primarily constrained by the dedicated PostgreSQL path.
|
|
||||||
- Rule out schema dumps entirely: rejected because Laravel documents them as relevant to growing suites and the spec explicitly calls for guidance.
|
|
||||||
- Expand PostgreSQL usage first and then revisit: rejected because that would increase cost before governance improves the current defaults.
|
|
||||||
|
|
||||||
## Decision 9: Initial heavy lanes should start with obviously separable families, then harden through profiling
|
|
||||||
|
|
||||||
- Decision: The first heavy selectors should start with the already separate browser suite plus clearly broad scan or governance families such as architecture, deprecation, and high-fan-out guard or smoke clusters, then tighten based on measured profiling output.
|
|
||||||
- Rationale: The repository already isolates browser tests by directory, and some governance-oriented families are visibly broader than ordinary authoring loops. Starting from obvious boundaries gives the first slice immediate value without pretending the full heavy inventory is already perfect.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Reclassify every current file before lane rollout: rejected because the spec explicitly avoids a full-suite rewrite in one pass.
|
|
||||||
- Guess heavy families only from intuition: rejected because the spec requires slow-test observability and measurable drift control.
|
|
||||||
- Treat only browser as heavy: rejected because broad discovery, smoke, and guard suites can also dominate wall-clock time.
|
|
||||||
|
|
||||||
## Decision 10: Initial budgets should start as documented report thresholds, not immediate hard CI gates
|
|
||||||
|
|
||||||
- Decision: The first runtime budgets should be documented and emitted in machine-readable reports before they become hard CI failure conditions.
|
|
||||||
- Rationale: The repo needs shared visibility and stable baselines before hard-failing contributors on performance regressions. This keeps the first slice actionable while leaving room for later CI enforcement.
|
|
||||||
- Alternatives considered:
|
|
||||||
- Hard-fail budgets immediately: rejected because current lane baselines are not yet stabilized and would create noisy adoption risk.
|
|
||||||
- Avoid budgets until CI exists: rejected because the spec explicitly requires runtime budgets and early regression visibility.
|
|
||||||
- Use purely narrative budgets with no report output: rejected because that would not create measurable governance.
|
|
||||||
@ -1,198 +0,0 @@
|
|||||||
# Feature Specification: Test Suite Governance & Performance Foundation
|
|
||||||
|
|
||||||
**Feature Branch**: `206-test-suite-governance`
|
|
||||||
**Created**: 2026-04-16
|
|
||||||
**Status**: Draft
|
|
||||||
**Input**: User description: "Spec 206 — Test Suite Governance & Performance Foundation"
|
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
|
||||||
|
|
||||||
- **Problem**: TenantPilot's test suite has become expensive enough that feedback speed, suite honesty, and authoring discipline are now repo-level engineering concerns rather than local developer preferences.
|
|
||||||
- **Today's failure**: The default run is too close to a broad full-suite execution, heavy helpers and factory defaults silently inflate test cost, and slow-test regressions are hard to see before they become the new normal.
|
|
||||||
- **User-visible improvement**: Contributors get a clearly faster standard run, a clearly broader confidence run, separate heavy lanes, and visible slow-test signals without relying on private shell habits.
|
|
||||||
- **Smallest enterprise-capable version**: Define four operational test lanes (Fast Feedback, Confidence, Browser, and Heavy Governance), add checked-in entry points plus profiling and machine-readable reporting runs, document honest taxonomy and cheap-default rules, standardize slow-test visibility, set baseline-backed runtime budgets, and split the heaviest shared setup paths into minimal versus full modes.
|
|
||||||
- **Explicit non-goals**: No wholesale rewrite of the entire suite, no blanket removal of browser or database tests, no immediate hard-fail CI matrix rollout, and no attempt to optimize every slow file in one pass.
|
|
||||||
- **Permanent complexity imported**: Lane vocabulary, taxonomy rules, runtime budgets, reporting conventions, helper and factory naming discipline, and guard tests that keep lane drift visible.
|
|
||||||
- **Why now**: The suite is already large enough that further feature waves, wider browser coverage, and later CI hardening will compound current cost unless the repo establishes structure first.
|
|
||||||
- **Why not local**: Personal scripts and tribal knowledge cannot stop default-run drift, hidden heavy setup, or ambiguous classification; the rules and entry points must live in the repo and be shareable.
|
|
||||||
- **Approval class**: Cleanup
|
|
||||||
- **Red flags triggered**: New classification vocabulary and "foundation" framing. Defense: the scope is intentionally narrow to repo test execution, visibility, and cheap-default discipline; it does not add new runtime product truth, new user-facing surfaces, or generalized platform abstractions.
|
|
||||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
|
||||||
- **Decision**: approve
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
|
||||||
|
|
||||||
- **Scope**: workspace
|
|
||||||
- **Primary Routes**: No end-user HTTP routes are changed. The affected surfaces are repository-level test entry points, suite grouping rules, shared test helpers, factory defaults, and checked-in test-governance documentation.
|
|
||||||
- **Data Ownership**: Workspace-owned test commands, helper conventions, grouping metadata, runtime budgets, and slow-test reporting outputs. No new tenant-owned runtime records are introduced.
|
|
||||||
- **RBAC**: No end-user authorization behavior changes. The affected actors are repository contributors who need a consistent, checked-in way to run the right test lane.
|
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
|
||||||
|
|
||||||
- **New source of truth?**: no
|
|
||||||
- **New persisted entity/table/artifact?**: yes, but only ephemeral local test-run artifacts under `apps/platform/storage/logs/test-lanes`; no new product database table or persisted product-truth artifact is introduced
|
|
||||||
- **New abstraction?**: yes, but limited to a repo-level lane and classification model for test execution governance
|
|
||||||
- **New enum/state/reason family?**: no
|
|
||||||
- **New cross-domain UI framework/taxonomy?**: no
|
|
||||||
- **Current operator problem**: Developers and reviewers cannot reliably choose the right test run for quick feedback versus broad confidence, and they cannot see where the suite is getting slower until the cost is already entrenched.
|
|
||||||
- **Existing structure is insufficient because**: A single serial default path, misclassified tests, expensive shared helpers, cascading factory defaults, and absent runtime budgets let expensive patterns spread without a shared correction mechanism.
|
|
||||||
- **Narrowest correct implementation**: Introduce four operational lanes plus two support runs, checked-in entry points, written classification and helper rules, baseline-backed initial budgets, slow-test visibility, and the first cheap-default fixes for the heaviest setup paths.
|
|
||||||
- **Ownership cost**: The team must maintain lane definitions, budgets, helper naming, classification rules, and a small set of guard tests or reports as the suite evolves.
|
|
||||||
- **Alternative intentionally rejected**: Pure parallelization without cost discipline, or ad-hoc local scripts without a shared taxonomy and budget model.
|
|
||||||
- **Release truth**: Current-release truth and a near-term prerequisite for larger feature waves, broader browser coverage, and later CI hardening.
|
|
||||||
|
|
||||||
## Problem Statement
|
|
||||||
|
|
||||||
TenantPilot already has a valuable test suite, but its growth pattern now creates avoidable delay and ambiguity:
|
|
||||||
|
|
||||||
- The standard run behaves too much like a broad suite instead of a fast feedback path.
|
|
||||||
- A meaningful share of tests classified as lightweight are still integration-heavy in practice.
|
|
||||||
- Shared helpers and factory defaults often create more tenant, provider, membership, workspace, or UI context than the test actually needs.
|
|
||||||
- Browser, UI-heavy, contract, and guard-heavy families have real value but belong to a different cost class than the usual authoring loop.
|
|
||||||
- Slow-file and slow-test drift is not yet a first-class repository signal.
|
|
||||||
|
|
||||||
Without a governance layer, each new feature wave makes the default path slower, increases the temptation to skip useful runs, and makes later CI hardening more expensive.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Restore a clearly fast standard feedback path for normal feature work.
|
|
||||||
- Separate fast-feedback, confidence, browser, and heavy-governance execution paths into explicit lanes.
|
|
||||||
- Make test taxonomy honest so a file's label matches its real cost and dependencies.
|
|
||||||
- Stop shared helpers and factory defaults from smuggling in heavy setup unless the test opts into it.
|
|
||||||
- Make slow-test regressions visible early enough to review and correct.
|
|
||||||
- Prepare the suite to grow toward 10k+ tests without default-run cost exploding.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Removing high-value security, governance, contract, or browser coverage solely to improve headline runtime.
|
|
||||||
- Rewriting every existing test into a new classification scheme in one feature.
|
|
||||||
- Forcing an immediate CI matrix rollout before the repo has stable lane definitions and budgets.
|
|
||||||
- Replacing the current application testing foundations or browser-testing approach.
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- The current full-suite wall-clock time will be captured as the baseline from the repository's standard development environment before budgets are locked.
|
|
||||||
- Heavy suites remain valuable and are being separated, not downgraded in importance.
|
|
||||||
- This feature may change checked-in commands, grouping metadata, shared helpers, factory states, and test documentation, but it does not require a new product runtime surface.
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Run The Fast Feedback Lane (Priority: P1)
|
|
||||||
|
|
||||||
As a contributor making a normal code change, I want one checked-in default run that gives representative feedback quickly, without silently dragging browser and deliberately heavy governance suites into my normal loop.
|
|
||||||
|
|
||||||
**Why this priority**: The default authoring loop is the highest-frequency path in the repository. If it stays slow and ambiguous, every other optimization has limited impact.
|
|
||||||
|
|
||||||
**Independent Test**: Run the default lane from a clean developer environment, confirm that it excludes browser and intentionally heavy families, and verify that the result arrives within the documented fast-lane budget.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a contributor is working on a non-browser change, **When** they run the repository's default test entry point, **Then** only the fast-feedback lane executes and it completes within the documented fast-lane budget.
|
|
||||||
2. **Given** browser or heavy governance tests exist in the repository, **When** the default lane runs, **Then** those families are not silently included.
|
|
||||||
3. **Given** the fast lane fails, **When** the contributor reads the output, **Then** it is clear whether a broader confidence or heavy lane should be run next.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Run The Broader Confidence Lane Before Merge (Priority: P1)
|
|
||||||
|
|
||||||
As a contributor or reviewer preparing a higher-confidence check, I want a broader lane that covers most feature and integration safety without forcing the cost of every browser and governance-heavy family.
|
|
||||||
|
|
||||||
**Why this priority**: A fast lane alone is not enough. The repository also needs a shared, predictable middle path between quick local feedback and the heaviest suite.
|
|
||||||
|
|
||||||
**Independent Test**: Run the confidence lane and verify that it includes the Unit suite plus the manifest-defined non-browser Feature or Integration selectors that remain outside explicit heavy-governance exclusions, stays under its documented budget, and remains separate from the heavy-governance lane.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a change is ready for broader validation, **When** a contributor runs the confidence lane, **Then** it includes the Unit suite plus the manifest-defined non-browser Feature or Integration selectors that remain outside explicit heavy-governance exclusions while remaining under its documented budget.
|
|
||||||
2. **Given** a suite family is explicitly classified as heavy governance or browser-only, **When** the confidence lane runs, **Then** that family is excluded unless the lane definition says otherwise.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - See Where Runtime Is Getting Worse (Priority: P2)
|
|
||||||
|
|
||||||
As a maintainer investigating suite cost, I want machine-readable reporting and slow-test profiling available through checked-in entry points so I can identify runtime drift before it becomes accepted baseline behavior.
|
|
||||||
|
|
||||||
**Why this priority**: Visibility must come before targeted optimization. Without it, expensive regressions are discovered late and argued from anecdotes.
|
|
||||||
|
|
||||||
**Independent Test**: Generate the machine-readable report and slow-test profile from the repository entry points and confirm that the slowest files or test cases are ranked and attributable to a lane or family.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a maintainer wants to inspect suite cost, **When** they run the reporting and profiling entry points, **Then** the repository produces ranked slow-test output and machine-readable results without ad-hoc local scripting.
|
|
||||||
2. **Given** a lane exceeds its documented budget, **When** the report is reviewed, **Then** the over-budget lane or file cluster is visible enough to trigger follow-up work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 4 - Author Cheap, Honest Tests By Default (Priority: P2)
|
|
||||||
|
|
||||||
As a contributor adding or editing tests, I want written rules and cheap shared defaults so I can place the test in the right lane and avoid inheriting an expensive tenant or UI context I did not ask for.
|
|
||||||
|
|
||||||
**Why this priority**: The suite only stays healthy if new tests default to minimal setup and honest classification instead of repeating the patterns that caused the slowdown.
|
|
||||||
|
|
||||||
**Independent Test**: Add or update a test that uses shared helpers or factories, verify that the default path is minimal, and confirm that heavy context requires an explicit opt-in.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a contributor writes a lightweight test, **When** they use shared helpers or factories, **Then** the default path creates only the minimum required records and relationships.
|
|
||||||
2. **Given** a test genuinely needs full tenant, provider, membership, workspace, or UI context, **When** the author opts into that path, **Then** the helper or factory name makes the heavier cost explicit.
|
|
||||||
3. **Given** a test's real dependencies no longer match its current classification, **When** it is reviewed, **Then** the written taxonomy provides a clear destination lane or group.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- A file mixes lightweight and heavy behavior; the repository must force an explicit grouping or classification decision instead of letting the file float ambiguously in the default path.
|
|
||||||
- Slimming a shared helper exposes tests that depended on hidden side effects; those tests must fail loudly and move to an explicit heavy path rather than re-inflating the default helper.
|
|
||||||
- Browser tests that cross real HTTP or browser boundaries must not be forced into the same reset expectations as in-process tests.
|
|
||||||
- Initial budgets may be transitional; exceeding them should still be visible even if some runs start as warning-level governance rather than immediate hard failure.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment:** This feature changes no end-user routes, no Microsoft Graph behavior, no runtime authorization plane, and no operator-facing product surface. It does introduce repository-wide governance vocabulary and checked-in execution rules, so lane definitions, helper discipline, and reporting outputs must remain explicit, reviewable, and testable.
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001 Lane Model**: The repository MUST define four named operational test lanes: Fast Feedback, Confidence, Browser, and Heavy Governance. Each lane MUST have a written purpose, intended audience, included families, excluded families, and ownership expectations.
|
|
||||||
- **FR-002 Checked-In Entry Points**: The repository MUST provide checked-in entry points for the fast-feedback lane, the broader confidence lane, the browser lane, the heavy-governance lane, a profiling support run, and a machine-readable reporting support run. The profiling and JUnit reporting runs MUST be represented as support-lane entries in the checked-in manifest and logical reporting contract so they share the same contract shape without being treated as default operational lanes.
|
|
||||||
- **FR-003 Default Run Semantics**: The standard contributor test run MUST resolve to the fast-feedback lane instead of behaving like an implicit broad full-suite run.
|
|
||||||
- **FR-004 Honest Taxonomy**: The repository MUST define written classification rules for Unit, Feature or Integration, Browser, and Architecture or Governance tests. The first implementation slice MUST audit the obvious misfit families surfaced during rollout and reclassify or regroup the first batch that does not match its current label.
|
|
||||||
- **FR-005 Browser Isolation**: Browser tests MUST belong to a dedicated lane with a dedicated entry point and dedicated runtime budget. They MUST NOT be silently included in the fast-feedback lane.
|
|
||||||
- **FR-006 Shared Helper Discipline**: Shared test helpers MUST default to a minimal setup path. Provider, credential, membership, workspace, session, cache, and UI-heavy behavior MUST require explicit opt-in and clear naming.
|
|
||||||
- **FR-007 Factory Discipline**: Factories touched by this rollout, plus the first additional heavy helper or factory cluster identified during rollout, MUST support minimal states for cheap records and clearly named heavy states for richer graphs. Expensive cascading defaults encountered in this slice MUST be removed, isolated, or explicitly documented, and the documented taxonomy MUST establish the same expectation for future touched factories.
|
|
||||||
- **FR-008 Slow-Test Observability**: Contributors MUST be able to generate ranked slow-test information and machine-readable results from checked-in entry points, including visibility into the slowest files or test cases and the lane they affect.
|
|
||||||
- **FR-009 Runtime Budgets**: Every checked-in lane entry, including heavy-governance and the two support-lane entries, MUST carry a baseline-backed runtime budget in the manifest and reporting contract. At minimum, the first slice MUST define explicit review targets for the fast-feedback lane, the confidence lane, the browser lane, and the heaviest known files or groups. Those budgets MUST be based on recorded baseline measurements from the current full-suite run and the first lane runs in the standard development environment. The reporting output MUST surface over-budget results.
|
|
||||||
- **FR-010 Database Strategy Guidance**: The repository MUST document when database-backed tests are appropriate, when database resets are justified, when seeds are prohibited or limited, and when a prebuilt schema baseline should be evaluated for the testing environment.
|
|
||||||
- **FR-011 Initial Cheap Defaults**: The first implementation slice MUST split at least the largest known shared setup path into minimal versus full behavior and MUST introduce a comparable minimal path for at least one additional heavy helper or factory cluster.
|
|
||||||
- **FR-012 Heavy Suite Separation**: The heaviest browser, discovery, guard, contract, or surface-scan families identified during rollout MUST be grouped or lane-separated so they are intentionally run rather than silently inherited by the default lane. The initial heavy-governance lane MAY start from obvious seed families, but it MUST be refined using the first profiling evidence captured during rollout.
|
|
||||||
- **FR-013 Growth Governance**: New test files, helper changes, and factory changes MUST be placeable into the written lane and taxonomy model without relying on undocumented local knowledge. For this first slice, the checked-in manifest is intentionally capped at the four operational lanes plus the two support-lane entries; expanding beyond those six entries requires an explicit follow-up spec and contract revision rather than silent manifest drift.
|
|
||||||
|
|
||||||
### Non-Functional Requirements
|
|
||||||
|
|
||||||
- **NFR-001 Feedback Speed**: The fast-feedback lane must be materially faster than the current full-suite baseline and stable enough to become the repository's normal authoring loop.
|
|
||||||
- **NFR-002 Confidence Separation**: The confidence lane must preserve broader trust while remaining distinct from the heaviest governance and browser cost classes.
|
|
||||||
- **NFR-003 Observability Usability**: Runtime and slow-test visibility must be available through checked-in repository paths that are easy for contributors and reviewers to run consistently.
|
|
||||||
- **NFR-004 Scalability Readiness**: The lane model and taxonomy must be durable enough to support suite growth toward 10k+ tests without constant redefinition of the default path.
|
|
||||||
|
|
||||||
## Rollout Guidance
|
|
||||||
|
|
||||||
- Establish visibility, lane definitions, entry points, and initial budgets first.
|
|
||||||
- Slim the default shared helpers and factory paths next so new tests stop inheriting full-context setup by accident.
|
|
||||||
- Reclassify or separate the heaviest misfit suites once the lane model exists.
|
|
||||||
- Evaluate broader framework-level optimization only after the lane model and cheap defaults make the expensive clusters visible.
|
|
||||||
|
|
||||||
## Key Entities *(include if feature involves data)*
|
|
||||||
|
|
||||||
- **Test Lane**: A named execution path with a purpose, intended use frequency, included families, excluded families, and a runtime budget.
|
|
||||||
- **Test Classification Rule**: A written rule set that defines which dependencies and behaviors belong to Unit, Feature or Integration, Browser, and Architecture or Governance tests.
|
|
||||||
- **Shared Fixture Path**: A reusable helper or factory path that can be minimal by default or full by explicit opt-in.
|
|
||||||
- **Runtime Budget**: A documented wall-clock target and drift signal for a lane, file family, or heavy test cluster.
|
|
||||||
- **Slow-Test Report**: A checked-in reporting output that shows the slowest files or test cases and ties them back to a lane or heavy family.
|
|
||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: Contributors can run the fast-feedback lane through one checked-in entry point, and its documented wall-clock budget is at least 50% lower than the current full-suite baseline measured on the same standard environment.
|
|
||||||
- **SC-002**: The confidence lane covers the Unit suite plus the manifest-defined non-browser Feature or Integration selectors that remain outside explicit heavy-governance exclusions while staying within its documented budget and below the current full-suite baseline.
|
|
||||||
- **SC-003**: Browser tests are excluded from the fast-feedback lane in normal use and are available only through their dedicated lane and budget.
|
|
||||||
- **SC-004**: The repository can produce a machine-readable test result artifact and a ranked slow-test report through checked-in entry points, showing at least the top 10 slowest files or test cases.
|
|
||||||
- **SC-005**: At least two of the currently heaviest shared setup paths, including the current tenant-user helper path, expose a documented minimal mode so new tests do not inherit full-context setup by default.
|
|
||||||
- **SC-006**: Reviewers can determine from the written taxonomy where a new or reworked test belongs without case-by-case reinvention, supporting continued suite growth toward 10k+ tests.
|
|
||||||
@ -1,257 +0,0 @@
|
|||||||
# Tasks: Test Suite Governance & Performance Foundation
|
|
||||||
|
|
||||||
**Input**: Design documents from `/specs/206-test-suite-governance/`
|
|
||||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md
|
|
||||||
|
|
||||||
**Tests**: Tests are REQUIRED for this feature because it changes checked-in test execution paths, shared fixture behavior, budget reporting, and guard coverage in a Laravel/Pest codebase.
|
|
||||||
**Operations / RBAC / UI surfaces**: Not applicable to product runtime. This feature is limited to repository test infrastructure, checked-in commands, reporting artifacts, and test-authoring discipline.
|
|
||||||
|
|
||||||
## Phase 1: Setup (Shared Infrastructure)
|
|
||||||
|
|
||||||
**Purpose**: Establish the checked-in lane manifest and reporting scaffolding shared by all user stories.
|
|
||||||
|
|
||||||
- [X] T001 Create the checked-in lane manifest and artifact-path skeleton in `apps/platform/tests/Support/TestLaneManifest.php` and `apps/platform/storage/logs/test-lanes/.gitignore`
|
|
||||||
- [X] T002 [P] Create reusable lane budget and report helpers in `apps/platform/tests/Support/TestLaneBudget.php` and `apps/platform/tests/Support/TestLaneReport.php`
|
|
||||||
- [X] T003 [P] Create the base lane guard and support test skeletons in `apps/platform/tests/Feature/Guards/TestLaneManifestTest.php`, `apps/platform/tests/Feature/Guards/TestLaneCommandContractTest.php`, and `apps/platform/tests/Unit/Support/TestLaneBudgetTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Prerequisites)
|
|
||||||
|
|
||||||
**Purpose**: Put the reusable lane, artifact, and command-routing seams in place before any story-specific lane work begins.
|
|
||||||
|
|
||||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
|
||||||
|
|
||||||
- [X] T004 Implement the shared lane selector, artifact, and budget model in `apps/platform/tests/Support/TestLaneManifest.php`, `apps/platform/tests/Support/TestLaneBudget.php`, and `apps/platform/tests/Support/TestLaneReport.php`
|
|
||||||
- [X] T005 [P] Add the checked-in Sail-friendly lane runner and reporting scripts in `scripts/platform-test-lane` and `scripts/platform-test-report`
|
|
||||||
- [X] T006 [P] Wire lane-facing command aliases and app-root artifact targets in `apps/platform/composer.json`, `apps/platform/phpunit.xml`, and `apps/platform/phpunit.pgsql.xml`
|
|
||||||
- [X] T007 Add foundational guard coverage for manifest validity, command routing, and artifact path behavior in `apps/platform/tests/Feature/Guards/TestLaneManifestTest.php`, `apps/platform/tests/Feature/Guards/TestLaneCommandContractTest.php`, and `apps/platform/tests/Unit/Support/TestLaneBudgetTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: Lane declarations, runner/report scripts, and guard scaffolding exist so story work can proceed independently.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: User Story 1 - Run The Fast Feedback Lane (Priority: P1) 🎯 MVP
|
|
||||||
|
|
||||||
**Goal**: Give contributors one checked-in default run that is clearly faster than the broad suite and that excludes browser and intentionally heavy governance families.
|
|
||||||
|
|
||||||
**Independent Test**: Run the default checked-in lane command from a clean environment and verify that it resolves to the fast-feedback selectors, runs in parallel, excludes browser and heavy governance families, and stays within the documented fast-lane budget target.
|
|
||||||
|
|
||||||
### Tests for User Story 1
|
|
||||||
|
|
||||||
- [X] T008 [P] [US1] Add fast-feedback default-run contract coverage in `apps/platform/tests/Feature/Guards/FastFeedbackLaneContractTest.php` and `apps/platform/tests/Feature/Guards/TestLaneCommandContractTest.php`
|
|
||||||
- [X] T009 [P] [US1] Add browser and heavy-family exclusion coverage for the default lane in `apps/platform/tests/Feature/Guards/FastFeedbackLaneExclusionTest.php` and `apps/platform/tests/Feature/Guards/TestLaneManifestTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 1
|
|
||||||
|
|
||||||
- [X] T010 [US1] Define the `fast-feedback` lane selectors, purpose metadata, intended audience, included or excluded families, ownership expectations, parallel mode, and default-entry semantics in `apps/platform/tests/Support/TestLaneManifest.php`
|
|
||||||
- [X] T011 [US1] Wire the fast-feedback checked-in commands and default test alias behavior in `apps/platform/composer.json` and `scripts/platform-test-lane`
|
|
||||||
- [X] T012 [US1] Encode the initial fast-lane exclusions, intended audience, ownership expectations, and contributor escalation guidance in `apps/platform/tests/Support/TestLaneManifest.php` and `README.md`
|
|
||||||
|
|
||||||
**Checkpoint**: The repository has a single fast default lane that is independently runnable and excludes browser plus intentionally heavy governance families.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Run The Broader Confidence Lane Before Merge (Priority: P1)
|
|
||||||
|
|
||||||
**Goal**: Provide a broader pre-merge lane plus separate browser and heavy-governance paths without collapsing everything back into the old full-suite default.
|
|
||||||
|
|
||||||
**Independent Test**: Run the confidence, browser, and heavy-governance commands independently and verify that confidence remains broader than fast-feedback, stays below the current full-suite baseline, browser stays isolated, and the heavy-governance lane owns the first intentionally expensive families.
|
|
||||||
|
|
||||||
### Tests for User Story 2
|
|
||||||
|
|
||||||
- [X] T013 [P] [US2] Add confidence-lane inclusion, manifest-scope, below-full-suite-baseline, and heavy-lane separation coverage in `apps/platform/tests/Feature/Guards/ConfidenceLaneContractTest.php` and `apps/platform/tests/Feature/Guards/HeavyGovernanceLaneContractTest.php`
|
|
||||||
- [X] T014 [P] [US2] Add browser-lane isolation and command-routing coverage in `apps/platform/tests/Feature/Guards/BrowserLaneIsolationTest.php` and `apps/platform/tests/Feature/Guards/TestLaneCommandContractTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 2
|
|
||||||
|
|
||||||
- [X] T015 [US2] Define the `confidence` lane as the Unit suite plus the manifest-defined non-browser Feature or Integration selectors, and seed the `heavy-governance` plus `browser` selectors with explicit purpose, audience, included or excluded families, and ownership metadata in `apps/platform/tests/Support/TestLaneManifest.php`
|
|
||||||
- [X] T016 [US2] Wire the checked-in confidence, browser, and heavy-governance commands in `apps/platform/composer.json` and `scripts/platform-test-lane`
|
|
||||||
- [X] T017 [US2] Document the confidence-versus-browser-versus-heavy usage model, intended audience, and ownership expectations in `README.md` and `specs/206-test-suite-governance/quickstart.md`
|
|
||||||
|
|
||||||
**Checkpoint**: Contributors and reviewers can independently run a broader confidence lane, a browser lane, and a seed heavy-governance lane without ambiguity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: User Story 3 - See Where Runtime Is Getting Worse (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Make slow-test drift visible through checked-in profiling and machine-readable reporting paths, plus initial documented runtime budgets.
|
|
||||||
|
|
||||||
**Independent Test**: Run the reporting and profiling commands and verify that they emit app-root-relative artifacts, show at least the top 10 slowest entries, and evaluate the run against documented lane budgets plus family thresholds.
|
|
||||||
|
|
||||||
### Tests for User Story 3
|
|
||||||
|
|
||||||
- [X] T018 [P] [US3] Add at-least-top-10 JUnit, summary-artifact, family-budget, and report-shape coverage in `apps/platform/tests/Unit/Support/TestLaneReportTest.php` and `apps/platform/tests/Feature/Guards/TestLaneArtifactsContractTest.php`
|
|
||||||
- [X] T019 [P] [US3] Add profiling-mode, baseline-capture, and budget-status coverage in `apps/platform/tests/Feature/Guards/ProfileLaneContractTest.php` and `apps/platform/tests/Unit/Support/TestLaneBudgetTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 3
|
|
||||||
|
|
||||||
- [X] T020 [US3] Implement JUnit, summary, and budget artifact generation in `scripts/platform-test-report`, `apps/platform/tests/Support/TestLaneReport.php`, and `apps/platform/storage/logs/test-lanes/.gitignore`
|
|
||||||
- [X] T021 [US3] Add serial profiling and JUnit command paths in `apps/platform/composer.json` and `scripts/platform-test-lane`
|
|
||||||
- [X] T022 [US3] Capture the current full-suite baseline and the first measured lane baselines, including explicit verification that `fast-feedback` is at least 50% below the current full-suite baseline and `confidence` remains below the current full-suite baseline, in `README.md` and `specs/206-test-suite-governance/quickstart.md`
|
|
||||||
- [X] T023 [US3] Refine the heavy-governance selectors from profiling evidence and publish the initial lane budgets plus heavy-family thresholds in `apps/platform/tests/Support/TestLaneManifest.php`, `README.md`, and `specs/206-test-suite-governance/quickstart.md`
|
|
||||||
|
|
||||||
**Checkpoint**: The repository can emit machine-readable test artifacts, list at least the top 10 slowest entries, and compare runs against baseline-backed lane budgets plus family thresholds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: User Story 4 - Author Cheap, Honest Tests By Default (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Stop the most common shared helper and one additional factory cluster from silently provisioning heavy tenant/provider graphs unless the test explicitly asks for them.
|
|
||||||
|
|
||||||
**Independent Test**: Use the shared helper and targeted factory states in isolation, verify that the default path stays minimal, and confirm that representative high-usage callers can opt into heavier behavior explicitly where needed.
|
|
||||||
|
|
||||||
### Tests for User Story 4
|
|
||||||
|
|
||||||
- [X] T024 [P] [US4] Add shared-helper profile coverage for provider, credential, session, cache, workspace, membership, and UI-context opt-ins in `apps/platform/tests/Unit/Support/CreateUserWithTenantProfilesTest.php` and `apps/platform/tests/Feature/Guards/FixtureCostProfilesGuardTest.php`
|
|
||||||
- [X] T025 [P] [US4] Add minimal-versus-heavy factory state coverage in `apps/platform/tests/Unit/Factories/TenantFactoryTest.php` and `apps/platform/tests/Unit/Factories/ProviderConnectionFactoryTest.php`
|
|
||||||
- [X] T026 [P] [US4] Add taxonomy-audit and lane-placement guard coverage in `apps/platform/tests/Feature/Guards/TestTaxonomyPlacementGuardTest.php` and `apps/platform/tests/Feature/Guards/ConfidenceLaneContractTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 4
|
|
||||||
|
|
||||||
- [X] T027 [US4] Split `createUserWithTenant()` into minimal and explicit heavy, provider-enabled, credential-enabled, or UI-context profiles in `apps/platform/tests/Pest.php`
|
|
||||||
- [X] T028 [US4] Introduce explicit minimal and heavy state behavior for hidden workspace or provider graph creation in `apps/platform/database/factories/TenantFactory.php` and `apps/platform/database/factories/ProviderConnectionFactory.php`
|
|
||||||
- [X] T029 [US4] Audit and reclassify the first obvious heavy-governance batch in `apps/platform/tests/Support/TestLaneManifest.php`, covering `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php`, `apps/platform/tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php`, `apps/platform/tests/Deprecation/IsPlatformSuperadminDeprecationTest.php`, and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
|
||||||
- [X] T030 [US4] Migrate the first high-usage callers to the new fixture profiles in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/OpsUx/OperateHubShellTest.php`, `apps/platform/tests/Feature/Findings/FindingExceptionRegisterTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
|
|
||||||
- [X] T031 [US4] Document honest taxonomy, DB reset rules, seed restrictions, and schema-baseline follow-up guidance in `README.md` and `specs/206-test-suite-governance/quickstart.md`
|
|
||||||
|
|
||||||
**Checkpoint**: Shared test setup defaults are materially cheaper, and the first obvious taxonomy misfits have been reclassified or regrouped with written guidance for future tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7: Polish & Cross-Cutting Concerns
|
|
||||||
|
|
||||||
**Purpose**: Reconcile the implementation with the design artifacts and validate the end-to-end workflow.
|
|
||||||
|
|
||||||
- [X] T032 [P] Reconcile the implemented lane model with `specs/206-test-suite-governance/contracts/test-lane-manifest.schema.json`, `specs/206-test-suite-governance/contracts/test-suite-governance.logical.openapi.yaml`, and `specs/206-test-suite-governance/quickstart.md`
|
|
||||||
- [X] T033 Run the focused validation flow documented in `specs/206-test-suite-governance/quickstart.md`
|
|
||||||
- [X] T034 Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
|
||||||
|
|
||||||
### Phase Dependencies
|
|
||||||
|
|
||||||
- **Setup (Phase 1)**: No dependencies, can start immediately.
|
|
||||||
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all story work.
|
|
||||||
- **User Story 1 (Phase 3)**: Starts after Foundational completion.
|
|
||||||
- **User Story 2 (Phase 4)**: Starts after Foundational completion and can proceed in parallel with US1.
|
|
||||||
- **User Story 3 (Phase 5)**: Starts after Foundational completion; it benefits from US1 and US2 landing first so the lane inventory is stable.
|
|
||||||
- **User Story 4 (Phase 6)**: Starts after Foundational completion; it can proceed in parallel with US3 once the lane manifest and command seams exist.
|
|
||||||
- **Polish (Phase 7)**: Runs after the desired user stories are complete.
|
|
||||||
|
|
||||||
### User Story Dependencies
|
|
||||||
|
|
||||||
- **User Story 1 (P1)**: No dependency on other stories. This is the recommended MVP slice.
|
|
||||||
- **User Story 2 (P1)**: Depends only on the foundational lane and command scaffolding, not on US1 completion.
|
|
||||||
- **User Story 3 (P2)**: Depends on the foundational lane and artifact scaffolding; it is strongest once US1 and US2 stabilize the lane inventory.
|
|
||||||
- **User Story 4 (P2)**: Depends on the foundational lane/guard scaffolding; it does not require US3 but shares docs and reporting outcomes with it.
|
|
||||||
|
|
||||||
### Within Each User Story
|
|
||||||
|
|
||||||
- Tests MUST be written and fail before implementation.
|
|
||||||
- Lane manifest and command-routing seams must exist before story-specific lane work begins.
|
|
||||||
- Artifact generation, baseline capture, and profiling refinement must exist before budget publication is considered complete.
|
|
||||||
- Helper and factory profile changes must land before high-usage caller migrations are considered complete.
|
|
||||||
- The first taxonomy audit must land before the initial reclassification batch is considered complete.
|
|
||||||
|
|
||||||
### Parallel Opportunities
|
|
||||||
|
|
||||||
- T002 and T003 can run in parallel.
|
|
||||||
- T005 and T006 can run in parallel.
|
|
||||||
- US1 test tasks T008 and T009 can run in parallel.
|
|
||||||
- US2 test tasks T013 and T014 can run in parallel.
|
|
||||||
- US3 test tasks T018 and T019 can run in parallel.
|
|
||||||
- US4 test tasks T024, T025, and T026 can run in parallel.
|
|
||||||
- After Foundational completion, US2, US3, and US4 can proceed in parallel with separate owners.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Parallel Example: User Story 1
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Launch the fast-feedback guard coverage together:
|
|
||||||
Task: "Add fast-feedback default-run contract coverage in apps/platform/tests/Feature/Guards/FastFeedbackLaneContractTest.php and apps/platform/tests/Feature/Guards/TestLaneCommandContractTest.php"
|
|
||||||
Task: "Add browser and heavy-family exclusion coverage for the default lane in apps/platform/tests/Feature/Guards/FastFeedbackLaneExclusionTest.php and apps/platform/tests/Feature/Guards/TestLaneManifestTest.php"
|
|
||||||
|
|
||||||
# Then land the fast lane implementation:
|
|
||||||
Task: "Define the fast-feedback lane selectors, parallel mode, and default-entry semantics in apps/platform/tests/Support/TestLaneManifest.php"
|
|
||||||
Task: "Wire the fast-feedback checked-in commands and default test alias behavior in apps/platform/composer.json and scripts/platform-test-lane"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Parallel Example: User Story 2
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Launch the broader-lane guard coverage together:
|
|
||||||
Task: "Add confidence-lane inclusion and heavy-lane separation coverage in apps/platform/tests/Feature/Guards/ConfidenceLaneContractTest.php and apps/platform/tests/Feature/Guards/HeavyGovernanceLaneContractTest.php"
|
|
||||||
Task: "Add browser-lane isolation and command-routing coverage in apps/platform/tests/Feature/Guards/BrowserLaneIsolationTest.php and apps/platform/tests/Feature/Guards/TestLaneCommandContractTest.php"
|
|
||||||
|
|
||||||
# Then land the lane separation work:
|
|
||||||
Task: "Define the confidence, heavy-governance, and browser lane selectors with baseline-backed budget metadata in apps/platform/tests/Support/TestLaneManifest.php"
|
|
||||||
Task: "Wire the checked-in confidence, browser, and heavy-governance commands in apps/platform/composer.json and scripts/platform-test-lane"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Parallel Example: User Story 3
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Launch the reporting coverage together:
|
|
||||||
Task: "Add at-least-top-10 JUnit, summary-artifact, family-budget, and report-shape coverage in apps/platform/tests/Unit/Support/TestLaneReportTest.php and apps/platform/tests/Feature/Guards/TestLaneArtifactsContractTest.php"
|
|
||||||
Task: "Add profiling-mode, baseline-capture, and budget-status coverage in apps/platform/tests/Feature/Guards/ProfileLaneContractTest.php and apps/platform/tests/Unit/Support/TestLaneBudgetTest.php"
|
|
||||||
|
|
||||||
# Then land the report generation and baseline work:
|
|
||||||
Task: "Implement JUnit, summary, and budget artifact generation in scripts/platform-test-report, apps/platform/tests/Support/TestLaneReport.php, and apps/platform/storage/logs/test-lanes/.gitignore"
|
|
||||||
Task: "Capture the current full-suite baseline and the first measured lane baselines in README.md and specs/206-test-suite-governance/quickstart.md"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Parallel Example: User Story 4
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Launch the cheap-default coverage together:
|
|
||||||
Task: "Add shared-helper profile coverage in apps/platform/tests/Unit/Support/CreateUserWithTenantProfilesTest.php and apps/platform/tests/Feature/Guards/FixtureCostProfilesGuardTest.php"
|
|
||||||
Task: "Add minimal-versus-heavy factory state coverage in apps/platform/tests/Unit/Factories/TenantFactoryTest.php and apps/platform/tests/Unit/Factories/ProviderConnectionFactoryTest.php"
|
|
||||||
Task: "Add taxonomy-audit and lane-placement guard coverage in apps/platform/tests/Feature/Guards/TestTaxonomyPlacementGuardTest.php and apps/platform/tests/Feature/Guards/ConfidenceLaneContractTest.php"
|
|
||||||
|
|
||||||
# Then land the fixture changes and taxonomy work:
|
|
||||||
Task: "Split createUserWithTenant() into minimal and explicit heavy or provider-enabled profiles in apps/platform/tests/Pest.php"
|
|
||||||
Task: "Audit and reclassify the first obvious heavy-governance batch in apps/platform/tests/Support/TestLaneManifest.php, covering apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php, apps/platform/tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php, apps/platform/tests/Deprecation/IsPlatformSuperadminDeprecationTest.php, and apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Strategy
|
|
||||||
|
|
||||||
### MVP First (User Story 1 Only)
|
|
||||||
|
|
||||||
1. Complete Phase 1: Setup.
|
|
||||||
2. Complete Phase 2: Foundational.
|
|
||||||
3. Complete Phase 3: User Story 1.
|
|
||||||
4. Validate that the default contributor path now resolves to the fast-feedback lane and excludes browser plus heavy-governance families.
|
|
||||||
|
|
||||||
### Incremental Delivery
|
|
||||||
|
|
||||||
1. Land the shared lane manifest, runner, and report scaffolding.
|
|
||||||
2. Ship the fast-feedback lane as the first visible contributor improvement.
|
|
||||||
3. Add the broader confidence lane plus separate browser and heavy-governance paths.
|
|
||||||
4. Add reporting, budgets, and profiling visibility.
|
|
||||||
5. Finish with cheap shared defaults and taxonomy guidance so new tests stop reintroducing hidden cost.
|
|
||||||
|
|
||||||
### Parallel Team Strategy
|
|
||||||
|
|
||||||
1. One developer lands the foundational manifest and runner/report scaffolding.
|
|
||||||
2. A second developer can own fast-feedback and confidence lane separation while another owns reporting and budget output.
|
|
||||||
3. A final developer can drive the helper/factory cheap-default work and migrate the highest-usage caller files once the new fixture profiles exist.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `[P]` tasks are limited to work on different files with no incomplete dependency overlap.
|
|
||||||
- US1 is the recommended MVP because it restores the default contributor loop first.
|
|
||||||
- US2 turns the lane model into a full pre-merge workflow by separating confidence, browser, and heavy-governance paths.
|
|
||||||
- US3 makes slow-test regressions visible and measurable.
|
|
||||||
- US4 ensures the suite does not grow back into hidden heavy defaults after the lane model lands.
|
|
||||||
Loading…
Reference in New Issue
Block a user