Compare commits

..

3 Commits

Author SHA1 Message Date
d8e331e92f Spec 207: implement shared test fixture slimming (#240)
## Summary
- implement the canonical shared fixture profile model with minimal, standard, and full semantics plus temporary legacy alias resolution
- slim default factory behavior for operation runs, backup sets, provider connections, and provider credentials while keeping explicit heavy opt-in states
- migrate the first console, navigation, RBAC, and drift caller packs to explicit lean helpers and wire lane comparison reporting into the existing Spec 206 seams
- reconcile spec 207 docs, contracts, quickstart guidance, and task tracking with the implemented behavior

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CreateUserWithTenantProfilesTest.php tests/Unit/Factories/TenantFactoryTest.php tests/Unit/Factories/OperationRunFactoryTest.php tests/Unit/Factories/BackupSetFactoryTest.php tests/Unit/Factories/ProviderConnectionFactoryTest.php tests/Unit/Factories/ProviderCredentialFactoryTest.php tests/Feature/Guards/FixtureCostProfilesGuardTest.php tests/Feature/Guards/FixtureLaneImpactBudgetTest.php tests/Feature/Guards/TestLaneArtifactsContractTest.php tests/Feature/Console/ReconcileOperationRunsCommandTest.php tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `./scripts/platform-test-lane fast-feedback`
- `./scripts/platform-test-lane confidence`
- `./scripts/platform-test-report fast-feedback`
- `./scripts/platform-test-report confidence`

## Lane outcome
- `fast-feedback`: 136.400761s vs 176.73623s baseline, status `improved`
- `confidence`: 394.5669s vs 394.383441s baseline, status `stable`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #240
2026-04-16 17:29:25 +00:00
3c38192405 Spec 206: implement test suite governance foundation (#239)
## Summary

This PR implements Spec 206 end to end and establishes the first checked-in test suite governance foundation for the platform app.

Key changes:
- add manifest-backed test lanes for fast-feedback, confidence, browser, heavy-governance, profiling, and junit
- add budget and report helpers plus app-local artifact generation under `apps/platform/storage/logs/test-lanes`
- add repo-root Sail-friendly lane/report wrappers
- switch the default contributor test path to the fast-feedback lane
- introduce explicit fixture profiles and cheaper defaults for shared tenant/provider test setup
- add minimal/heavy factory states for tenant and provider connection setup
- migrate the first high-usage and provider-sensitive tests to explicit fixture profiles
- document budgets, taxonomy rules, DB reset guidance, and the full Spec 206 plan/contracts/tasks set

## Validation

Executed during implementation:
- focused Spec 206 guard/support/factory validation pack: 31 passed
- provider-sensitive regression pack: 29 passed
- first high-usage caller migration pack: 120 passed
- lane routing and wrapper validation succeeded
- pint completed successfully

Measured lane baselines captured in docs:
- fast-feedback: 176.74s
- confidence: 394.38s
- heavy-governance: 83.66s
- browser: 128.87s
- junit: 380.14s
- profiling: 2701.51s
- full-suite baseline anchor: 2624.60s

## Notes

- Livewire v4 / Filament v5 runtime behavior is unchanged by this PR.
- No new runtime routes, product UI flows, or database migrations are introduced.
- Panel provider registration remains unchanged in `bootstrap/providers.php`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #239
2026-04-16 13:58:50 +00:00
e02799b383 feat: implement spec 198 monitoring page state contract (#238)
## Summary
- implement Spec 198 monitoring page-state contracts across Operations, Audit Log, Finding Exceptions Queue, Evidence Overview, Baseline Compare Landing, and Baseline Compare Matrix
- align selected-record and draft/apply behavior with query/session restoration semantics, including canonical navigation and tenant-filter normalization helpers
- add Spec 198 feature and browser coverage, update closure/spec artifacts, and refresh affected regression tests that asserted pre-contract behavior

## Verification
- focused Spec 198 feature pack passed through Sail
- Spec 198 browser smoke passed through Sail
- existing Spec 190 and Spec 194 browser smokes passed through Sail
- targeted fallout tests were updated and rerun during full-suite triage

## Notes
- Livewire v4 / Filament v5 compliant only; no legacy API reintroduction
- no provider registration changes; Laravel 11+ provider registration remains in `bootstrap/providers.php`
- no global-search behavior changed for any resource
- destructive queue decision actions remain confirmation-gated and authorization-backed
- no new Filament assets were added; existing deploy step for `php artisan filament:assets` remains unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #238
2026-04-15 21:59:42 +00:00
78 changed files with 7062 additions and 185 deletions

View File

@ -190,6 +190,10 @@ ## 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 + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail (207-shared-test-fixture-slimming)
- SQLite `:memory:` for the default test environment, isolated PostgreSQL coverage via the existing dedicated suite, and lane-measurement artifacts under the app-root contract path `storage/logs/test-lanes` (207-shared-test-fixture-slimming)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -224,8 +228,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 207-shared-test-fixture-slimming: Added PHP 8.4.15 + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail
- 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`
- 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
View File

@ -37,6 +37,10 @@ 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

View File

@ -37,6 +37,71 @@ ### 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 `createStandardUserWithTenant()` or `fixtureProfile: 'standard'` when a test needs a default Microsoft provider connection without credentials, cache resets, or UI context.
- Use `createFullUserWithTenant()` or `fixtureProfile: 'full'` when a test intentionally needs provider, credential, cache-reset, and UI-context side effects together.
- Use `OperationRun::factory()->minimal()` for system-style runs and `OperationRun::factory()->withUser($user)` only when the initiator identity is materially part of the assertion.
- Use `BackupSet::factory()->full()` only when the test really needs backup items; the default backup-set factory path now stays item-free.
- `provider-enabled`, `credential-enabled`, `ui-context`, and `heavy` remain available only as temporary transition aliases while the first migration packs are landing.
### 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`

View File

@ -22,6 +22,8 @@ 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';
@ -81,7 +83,7 @@ protected static function booted(): void
$tenant->status = self::STATUS_ACTIVE; $tenant->status = self::STATUS_ACTIVE;
} }
if ($tenant->workspace_id === null && app()->runningUnitTests()) { if ($tenant->workspace_id === null && app()->runningUnitTests() && ! static::$skipTestWorkspaceProvisioning) {
$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)),
@ -118,6 +120,11 @@ 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()) {

View File

@ -54,7 +54,31 @@
], ],
"test": [ "test": [
"@php artisan config:clear --ansi", "@php artisan config:clear --ansi",
"@php artisan test" "@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::runLane('fast-feedback'));\""
],
"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",
@ -67,7 +91,7 @@
"./vendor/bin/sail stop" "./vendor/bin/sail stop"
], ],
"sail:test": [ "sail:test": [
"./vendor/bin/sail artisan test --compact" "./vendor/bin/sail composer run test"
], ],
"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"

View File

@ -23,12 +23,47 @@ public function definition(): array
'name' => fake()->words(3, true), 'name' => fake()->words(3, true),
'created_by' => fake()->email(), 'created_by' => fake()->email(),
'status' => 'completed', 'status' => 'completed',
'item_count' => fake()->numberBetween(0, 100), 'item_count' => 0,
'completed_at' => now(), 'completed_at' => now(),
'metadata' => [], 'metadata' => [],
]; ];
} }
public function minimal(): static
{
return $this->state(fn (): array => [
'item_count' => 0,
'metadata' => [],
]);
}
/**
* @param array<string, mixed> $itemAttributes
*/
public function withItems(int $count = 1, array $itemAttributes = []): static
{
$count = max(1, $count);
return $this->state(fn (): array => [
'item_count' => $count,
])->afterCreating(function ($backupSet) use ($count, $itemAttributes): void {
BackupItem::factory()
->count($count)
->for($backupSet->tenant)
->for($backupSet)
->create(array_merge([
'payload' => ['id' => 'backup-item-'.fake()->uuid()],
'metadata' => [],
'assignments' => [],
], $itemAttributes));
});
}
public function full(): static
{
return $this->recentCompleted()->withItems();
}
public function recentCompleted(): static public function recentCompleted(): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [

View File

@ -44,8 +44,8 @@ public function definition(): array
return (int) $tenant->workspace_id; return (int) $tenant->workspace_id;
}, },
'user_id' => User::factory(), 'user_id' => null,
'initiator_name' => fake()->name(), 'initiator_name' => 'System',
'type' => fake()->randomElement(OperationRunType::values()), 'type' => fake()->randomElement(OperationRunType::values()),
'status' => OperationRunStatus::Queued->value, 'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,
@ -58,6 +58,22 @@ public function definition(): array
]; ];
} }
public function minimal(): static
{
return $this->state(fn (): array => [
'user_id' => null,
'initiator_name' => 'System',
]);
}
public function withUser(?User $user = null): static
{
return $this->state(fn (): array => [
'user_id' => $user?->getKey() ?? User::factory(),
'initiator_name' => $user?->name ?? fake()->name(),
]);
}
public function forTenant(Tenant $tenant): static public function forTenant(Tenant $tenant): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [

View File

@ -3,10 +3,13 @@
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;
@ -72,6 +75,14 @@ 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 => [
@ -79,6 +90,15 @@ public function dedicated(): static
]); ]);
} }
public function standard(): static
{
return $this->dedicated()
->verifiedHealthy()
->state(fn (): array => [
'is_default' => true,
]);
}
public function consentGranted(): static public function consentGranted(): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [
@ -107,4 +127,38 @@ 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();
});
}
public function full(): static
{
return $this->withCredential();
}
} }

View File

@ -31,6 +31,18 @@ public function definition(): array
]; ];
} }
public function standard(): static
{
return $this->state(fn (): array => [
'provider_connection_id' => ProviderConnection::factory()->standard(),
]);
}
public function verifiedConnection(): static
{
return $this->standard();
}
public function legacyMigrated(): static public function legacyMigrated(): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [

View File

@ -11,10 +11,12 @@
*/ */
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 ($tenant->workspace_id !== null) { if (! $this->provisionsWorkspace || $tenant->workspace_id !== null) {
return; return;
} }
@ -83,4 +85,24 @@ 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);
});
}
} }

View File

@ -39,6 +39,7 @@
<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"/>

View File

@ -34,6 +34,7 @@
<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"/>

View File

@ -16,7 +16,7 @@
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
it('Baseline finding fidelity is content when both baseline and current are content', function () { it('Baseline finding fidelity is content when both baseline and current are content', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
@ -154,7 +154,7 @@
}); });
it('Baseline finding fidelity is meta when baseline evidence is meta (even if current content exists)', function () { it('Baseline finding fidelity is meta when baseline evidence is meta (even if current content exists)', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,

View File

@ -41,7 +41,7 @@ protected function makeBaselineCompareMatrixFixture(
string $viewerRole = 'owner', string $viewerRole = 'owner',
?string $workspaceRole = null, ?string $workspaceRole = null,
): array { ): array {
[$user, $visibleTenant] = createUserWithTenant(role: $viewerRole, workspaceRole: $workspaceRole ?? $viewerRole); [$user, $visibleTenant] = createMinimalUserWithTenant(role: $viewerRole, workspaceRole: $workspaceRole ?? $viewerRole);
$workspace = Workspace::query()->findOrFail((int) $visibleTenant->workspace_id); $workspace = Workspace::query()->findOrFail((int) $visibleTenant->workspace_id);

View File

@ -36,7 +36,7 @@ protected function makePortfolioTriageActor(
'name' => $tenantName, 'name' => $tenantName,
]); ]);
[$user, $tenant] = createUserWithTenant( [$user, $tenant] = createMinimalUserWithTenant(
tenant: $tenant, tenant: $tenant,
role: $role, role: $role,
workspaceRole: $workspaceRole, workspaceRole: $workspaceRole,
@ -55,7 +55,7 @@ protected function makePortfolioTriagePeer(User $user, Tenant $workspaceTenant,
'name' => $name, 'name' => $name,
]); ]);
createUserWithTenant( createMinimalUserWithTenant(
tenant: $tenant, tenant: $tenant,
user: $user, user: $user,
role: 'owner', role: 'owner',

View File

@ -29,11 +29,10 @@
$startedAt = CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC'); $startedAt = CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC');
$operationRun = OperationRun::create([ $operationRun = OperationRun::factory()
'workspace_id' => (int) $tenant->workspace_id, ->minimal()
'tenant_id' => $tenant->id, ->forTenant($tenant)
'user_id' => null, ->create([
'initiator_name' => 'System',
'type' => 'backup_schedule_run', 'type' => 'backup_schedule_run',
'status' => 'running', 'status' => 'running',
'outcome' => 'pending', 'outcome' => 'pending',

View File

@ -10,12 +10,9 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('reconciles stale covered runs from the console command', function (): void { it('reconciles stale covered runs from the console command', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'policy.sync', 'type' => 'policy.sync',
'status' => OperationRunStatus::Queued->value, 'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,
@ -34,12 +31,9 @@
}); });
it('supports dry-run mode without mutating runs', function (): void { it('supports dry-run mode without mutating runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'policy.sync', 'type' => 'policy.sync',
'status' => OperationRunStatus::Queued->value, 'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,

View File

@ -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'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
$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'); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, user: $user, role: 'owner', fixtureProfile: 'credential-enabled');
$this->actingAs($user) $this->actingAs($user)
->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA)) ->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA))

View File

@ -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'); [$this->user, $this->tenant] = createUserWithTenant(tenant: $this->tenant, user: $this->user, role: 'owner', fixtureProfile: 'credential-enabled');
}); });
it('renders policy version view without any Graph calls during render', function () { it('renders policy version view without any Graph calls during render', function () {

View File

@ -20,7 +20,7 @@
$mock->shouldReceive('request')->never(); $mock->shouldReceive('request')->never();
}); });
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
$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'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
$this->actingAs($user); $this->actingAs($user);
$tenant->makeCurrent(); $tenant->makeCurrent();

View File

@ -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'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
$service = app(EntraGroupSyncService::class); $service = app(EntraGroupSyncService::class);

View File

@ -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'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
EntraGroup::factory()->create([ EntraGroup::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),

View File

@ -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'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
Config::set('directory_groups.retention_days', 90); Config::set('directory_groups.retention_days', 90);

View File

@ -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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$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] = createUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$payload = buildPayload( $payload = buildPayload(
[readerRoleDef()], [readerRoleDef()],

View File

@ -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'); [$user, $tenantA] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
$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'); [$user] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
$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'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
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'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
$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'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
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'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
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'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
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'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
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'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);

View File

@ -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] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
$approver = User::factory()->create(); $approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner'); createMinimalUserWithTenant(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] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(tenant: $tenant, role: 'owner');
$approver = User::factory()->create(); $approver = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner'); createMinimalUserWithTenant(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] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner', workspaceRole: 'owner'); [$viewer, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'readonly'); [$viewer, $tenant] = createMinimalUserWithTenant(role: 'readonly');
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$requester] = createMinimalUserWithTenant(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,

View File

@ -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] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant( [$user, $tenant] = createMinimalUserWithTenant(
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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'manager'); [$manager, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); [$approver, $tenant] = createMinimalUserWithTenant(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');

View File

@ -0,0 +1,29 @@
<?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/');
}
});

View File

@ -0,0 +1,27 @@
<?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');
});

View File

@ -0,0 +1,29 @@
<?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));
});

View File

@ -0,0 +1,23 @@
<?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();
});

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
it('keeps the canonical shared tenant helper profile catalog explicit and reviewable', function (): void {
$profiles = createUserWithTenantProfileCatalog();
expect($profiles)->toHaveKeys([
'minimal',
'standard',
'full',
])
->and($profiles['minimal'])->toMatchArray([
'workspace' => true,
'membership' => true,
'session' => true,
'provider' => false,
'credential' => false,
'cache' => false,
'uiContext' => false,
])
->and($profiles['standard'])->toMatchArray([
'workspace' => true,
'membership' => true,
'session' => true,
'provider' => true,
'credential' => false,
'cache' => false,
'uiContext' => false,
])
->and($profiles['full'])->toMatchArray([
'workspace' => true,
'membership' => true,
'session' => true,
'provider' => true,
'credential' => true,
'cache' => true,
'uiContext' => true,
]);
});
it('keeps the temporary legacy fixture aliases explicit and reviewable', function (): void {
$aliases = createUserWithTenantLegacyProfileAliases();
expect($aliases)->toHaveKeys([
'provider-enabled',
'credential-enabled',
'ui-context',
'heavy',
])
->and($aliases['provider-enabled']['profile'])->toBe('standard')
->and($aliases['credential-enabled']['profile'])->toBe('full')
->and($aliases['ui-context']['profile'])->toBe('full')
->and($aliases['heavy']['profile'])->toBe('full')
->and($aliases['provider-enabled']['removalTrigger'])->not->toBe('')
->and($aliases['credential-enabled']['removalTrigger'])->not->toBe('')
->and($aliases['ui-context']['removalTrigger'])->not->toBe('')
->and($aliases['heavy']['removalTrigger'])->not->toBe('');
});
it('resolves legacy aliases to the promised side-effect bundles', function (): void {
$credentialEnabled = resolveCreateUserWithTenantProfile('credential-enabled');
$uiContext = resolveCreateUserWithTenantProfile('ui-context');
expect($credentialEnabled['canonicalProfile'])->toBe('full')
->and($credentialEnabled['legacyAlias'])->toBe('credential-enabled')
->and($credentialEnabled['sideEffects'])->toMatchArray([
'provider' => true,
'credential' => true,
'cache' => false,
'uiContext' => false,
])
->and($uiContext['canonicalProfile'])->toBe('full')
->and($uiContext['legacyAlias'])->toBe('ui-context')
->and($uiContext['sideEffects'])->toMatchArray([
'provider' => false,
'credential' => false,
'cache' => true,
'uiContext' => true,
]);
});

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneReport;
it('keeps the shared fixture slimming pre-migration baselines recorded for the standard lanes', function (): void {
$fastFeedback = TestLaneManifest::comparisonBaseline('shared-test-fixture-slimming', 'fast-feedback');
$confidence = TestLaneManifest::comparisonBaseline('shared-test-fixture-slimming', 'confidence');
expect($fastFeedback)->toMatchArray([
'laneId' => 'fast-feedback',
'wallClockSeconds' => 176.73623,
'targetImprovementPercent' => 10,
'maxRegressionPercent' => 5,
])
->and($confidence)->toMatchArray([
'laneId' => 'confidence',
'wallClockSeconds' => 394.383441,
'targetImprovementPercent' => 10,
'maxRegressionPercent' => 5,
]);
});
it('classifies lane-impact comparison status against the recorded fixture slimming baseline', function (): void {
$improved = TestLaneReport::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 150.0,
slowestEntries: [],
durationsByFile: [],
comparisonProfile: 'shared-test-fixture-slimming',
);
$stable = TestLaneReport::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 180.0,
slowestEntries: [],
durationsByFile: [],
comparisonProfile: 'shared-test-fixture-slimming',
);
$regressed = TestLaneReport::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 190.0,
slowestEntries: [],
durationsByFile: [],
comparisonProfile: 'shared-test-fixture-slimming',
);
expect(data_get($improved, 'sharedFixtureSlimmingComparison.status'))->toBe('improved')
->and(data_get($stable, 'sharedFixtureSlimmingComparison.status'))->toBe('stable')
->and(data_get($regressed, 'sharedFixtureSlimmingComparison.status'))->toBe('regressed');
});

View File

@ -0,0 +1,25 @@
<?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');
});

View File

@ -0,0 +1,17 @@
<?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');
});

View File

@ -0,0 +1,44 @@
<?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');
});
it('publishes the shared fixture slimming comparison only for the governed standard lanes', function (): void {
$fastFeedback = TestLaneReport::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 176.73623,
slowestEntries: [],
durationsByFile: [],
comparisonProfile: 'shared-test-fixture-slimming',
);
$heavyGovernance = TestLaneReport::buildReport(
laneId: 'heavy-governance',
wallClockSeconds: 83.66,
slowestEntries: [],
durationsByFile: [],
comparisonProfile: 'shared-test-fixture-slimming',
);
expect($fastFeedback)->toHaveKey('sharedFixtureSlimmingComparison')
->and($heavyGovernance)->not->toHaveKey('sharedFixtureSlimmingComparison');
});

View File

@ -0,0 +1,43 @@
<?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');
});

View File

@ -0,0 +1,64 @@
<?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);
});

View File

@ -0,0 +1,19 @@
<?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');
});

View File

@ -22,7 +22,7 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('reuses finding related navigation and caches deterministic negative results', function (): void { it('reuses finding related navigation and caches deterministic negative results', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -45,8 +45,7 @@
'policy_id' => (int) $policy->getKey(), 'policy_id' => (int) $policy->getKey(),
]); ]);
$run = OperationRun::factory()->for($tenant)->create([ $run = OperationRun::factory()->minimal()->forTenant($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare', 'type' => 'baseline_compare',
]); ]);
@ -103,12 +102,11 @@
}); });
it('reuses operation-run related context across detail and header consumers', function (): void { it('reuses operation-run related context across detail and header consumers', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
$run = OperationRun::factory()->for($tenant)->create([ $run = OperationRun::factory()->minimal()->forTenant($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_set.add_policies', 'type' => 'backup_set.add_policies',
'context' => [ 'context' => [
'backup_set_id' => 123, 'backup_set_id' => 123,
@ -130,7 +128,7 @@
it('keeps related-navigation target routes tenant-safe for non-members and capability-limited members', function (): void { it('keeps related-navigation target routes tenant-safe for non-members and capability-limited members', function (): void {
$workspaceTenant = \App\Models\Tenant::factory()->create(); $workspaceTenant = \App\Models\Tenant::factory()->create();
[$member, $workspaceTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'readonly'); [$member, $workspaceTenant] = createMinimalUserWithTenant(tenant: $workspaceTenant, role: 'readonly');
$nonMember = \App\Models\User::factory()->create(); $nonMember = \App\Models\User::factory()->create();
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([

View File

@ -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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $entitledTenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'operator'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(tenant: $tenantA, role: 'owner'); [$user, $tenantA] = createMinimalUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([ $tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id, 'workspace_id' => (int) $tenantA->workspace_id,
]); ]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); createMinimalUserWithTenant(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] = createUserWithTenant(tenant: $runTenant, role: 'owner'); [$user, $runTenant] = createMinimalUserWithTenant(tenant: $runTenant, role: 'owner');
$currentTenant = Tenant::factory()->create([ $currentTenant = Tenant::factory()->create([
'workspace_id' => (int) $runTenant->workspace_id, 'workspace_id' => (int) $runTenant->workspace_id,
]); ]);
createUserWithTenant(tenant: $currentTenant, user: $user, role: 'owner'); createMinimalUserWithTenant(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] = createUserWithTenant(tenant: $runTenant, role: 'owner'); [$user, $runTenant] = createMinimalUserWithTenant(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',
]); ]);
createUserWithTenant( createMinimalUserWithTenant(
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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner'); [$user, $rememberedTenant] = createMinimalUserWithTenant(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',
]); ]);
createUserWithTenant(tenant: $routedTenant, user: $user, role: 'owner'); createMinimalUserWithTenant(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] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner'); [$user, $rememberedTenant] = createMinimalUserWithTenant(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,
]); ]);
createUserWithTenant(tenant: $routedTenant, user: $user, role: 'owner'); createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(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] = createUserWithTenant(tenant: $runTenant, role: 'owner'); [$user, $runTenant] = createMinimalUserWithTenant(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',
]); ]);
createUserWithTenant( createMinimalUserWithTenant(
tenant: $rememberedTenant, tenant: $rememberedTenant,
user: $user, user: $user,
role: 'owner', role: 'owner',

View File

@ -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(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
$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(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
$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(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
$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(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
// 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)

View File

@ -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(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
$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(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
$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(); [$user, $tenant] = createUserWithTenant(fixtureProfile: 'provider-enabled');
$generator = app(FindingGeneratorContract::class); $generator = app(FindingGeneratorContract::class);

View File

@ -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'); [$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'provider-enabled');
$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'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'provider-enabled');
$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'); [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'provider-enabled');
$this->actingAs($user); $this->actingAs($user);
$tenant->makeCurrent(); $tenant->makeCurrent();

View File

@ -2,7 +2,7 @@
use App\Models\TenantPermission; use App\Models\TenantPermission;
it('renders required permissions overview with missing-first ordering and clickable feature cards', function (): void { it('renders required permissions overview with missing-first ordering and 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,6 +29,5 @@
->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);
}); });

View File

@ -24,7 +24,7 @@
}); });
it('allows workspace members to open the workspace-managed tenants index', function (): void { it('allows workspace members to open the workspace-managed tenants index', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -43,7 +43,7 @@
}); });
it('allows workspace members to open the workspace-managed tenant view route', function (): void { it('allows workspace members to open the workspace-managed tenant view route', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -52,7 +52,7 @@
}); });
it('exposes a provider connections link from the workspace-managed tenant view page', function (): void { it('exposes a provider connections link from the workspace-managed tenant view page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -72,7 +72,7 @@
}); });
it('exposes memberships management under workspace scope', function (): void { it('exposes memberships management under workspace scope', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -89,7 +89,7 @@
'tenant_id' => '11111111-1111-1111-1111-111111111111', 'tenant_id' => '11111111-1111-1111-1111-111111111111',
]); ]);
[$entitledUser] = createUserWithTenant($tenant, role: 'readonly'); [$entitledUser] = createMinimalUserWithTenant(tenant: $tenant, role: 'readonly');
$nonEntitledUser = User::factory()->create(); $nonEntitledUser = User::factory()->create();
WorkspaceMembership::factory()->create([ WorkspaceMembership::factory()->create([
@ -120,7 +120,7 @@
}); });
it('keeps tenant panel route shape canonical and rejects duplicated /t prefixes', function (): void { it('keeps tenant panel route shape canonical and rejects duplicated /t prefixes', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -134,7 +134,7 @@
}); });
it('removes tenant-scoped management routes', function (): void { it('removes tenant-scoped management routes', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -153,7 +153,7 @@
}); });
it('serves provider connection management under workspace-managed tenant routes only', function (): void { it('serves provider connection management under workspace-managed tenant routes only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->create([ $connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
@ -174,7 +174,7 @@
}); });
it('returns 403 for workspace members missing mutation capability on provider connections', function (): void { it('returns 403 for workspace members missing mutation capability on provider connections', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly', workspaceRole: 'readonly'); [$user, $tenant] = createMinimalUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
$this->followingRedirects() $this->followingRedirects()
->actingAs($user) ->actingAs($user)
@ -190,7 +190,7 @@
}); });
it('writes canonical membership audit entries for membership mutations', function (): void { it('writes canonical membership audit entries for membership mutations', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner'); [$owner, $tenant] = createMinimalUserWithTenant(role: 'owner');
$member = User::factory()->create(); $member = User::factory()->create();
/** @var TenantMembershipManager $manager */ /** @var TenantMembershipManager $manager */
@ -233,7 +233,7 @@
}); });
it('keeps workspace navigation entries after panel split', function (): void { it('keeps workspace navigation entries after panel split', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -251,7 +251,7 @@
expect($tenantPanelResources)->not->toContain(TenantResource::class); expect($tenantPanelResources)->not->toContain(TenantResource::class);
expect($tenantPanelResources)->not->toContain(ProviderConnectionResource::class); expect($tenantPanelResources)->not->toContain(ProviderConnectionResource::class);
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
@ -262,7 +262,7 @@
}); });
it('keeps global search scoped to workspace-managed tenant resources only', function (): void { it('keeps global search scoped to workspace-managed tenant resources only', function (): void {
[$workspaceUser, $tenant] = createUserWithTenant(role: 'owner'); [$workspaceUser, $tenant] = createMinimalUserWithTenant(role: 'owner');
Filament::setCurrentPanel('admin'); Filament::setCurrentPanel('admin');
Filament::setTenant(null, true); Filament::setTenant(null, true);

View File

@ -75,6 +75,41 @@
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']);
@ -333,6 +368,327 @@ 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 createUserWithTenantProfileCatalog(): array
{
return [
'minimal' => [
'workspace' => true,
'membership' => true,
'session' => true,
'provider' => false,
'credential' => false,
'cache' => false,
'uiContext' => false,
],
'standard' => [
'workspace' => true,
'membership' => true,
'session' => true,
'provider' => true,
'credential' => false,
'cache' => false,
'uiContext' => false,
],
'full' => [
'workspace' => true,
'membership' => true,
'session' => true,
'provider' => true,
'credential' => true,
'cache' => true,
'uiContext' => true,
],
];
}
/**
* @return array<string, array{profile: string, overrides?: array{workspace?: bool, membership?: bool, session?: bool, provider?: bool, credential?: bool, cache?: bool, uiContext?: bool}, removalTrigger: string}>
*/
function createUserWithTenantLegacyProfileAliases(): array
{
return [
'provider-enabled' => [
'profile' => 'standard',
'removalTrigger' => 'Retire after the first fast-feedback and confidence migration packs use the canonical standard profile directly.',
],
'credential-enabled' => [
'profile' => 'full',
'overrides' => [
'cache' => false,
'uiContext' => false,
],
'removalTrigger' => 'Retire after the credential-dependent caller pack adopts createFullUserWithTenant() or fixtureProfile: full plus local overrides.',
],
'ui-context' => [
'profile' => 'full',
'overrides' => [
'provider' => false,
'credential' => false,
],
'removalTrigger' => 'Retire after UI-context callers switch to the canonical full profile or an explicit local UI-context helper.',
],
'heavy' => [
'profile' => 'full',
'removalTrigger' => 'Retire after legacy heavy callers migrate to the canonical full profile.',
],
];
}
/**
* @return array{requestedProfile: string, canonicalProfile: string, sideEffects: array{workspace: bool, membership: bool, session: bool, provider: bool, credential: bool, cache: bool, uiContext: bool}, legacyAlias: ?string, removalTrigger: ?string}
*/
function resolveCreateUserWithTenantProfile(string $fixtureProfile): array
{
$catalog = createUserWithTenantProfileCatalog();
if (array_key_exists($fixtureProfile, $catalog)) {
return [
'requestedProfile' => $fixtureProfile,
'canonicalProfile' => $fixtureProfile,
'sideEffects' => $catalog[$fixtureProfile],
'legacyAlias' => null,
'removalTrigger' => null,
];
}
$aliases = createUserWithTenantLegacyProfileAliases();
if (! array_key_exists($fixtureProfile, $aliases)) {
throw new \InvalidArgumentException(sprintf('Unknown fixture profile [%s].', $fixtureProfile));
}
$resolution = $aliases[$fixtureProfile];
$canonicalProfile = $resolution['profile'];
if (! array_key_exists($canonicalProfile, $catalog)) {
throw new \InvalidArgumentException(sprintf('Unknown canonical fixture profile [%s].', $canonicalProfile));
}
return [
'requestedProfile' => $fixtureProfile,
'canonicalProfile' => $canonicalProfile,
'sideEffects' => array_replace($catalog[$canonicalProfile], $resolution['overrides'] ?? []),
'legacyAlias' => $fixtureProfile,
'removalTrigger' => $resolution['removalTrigger'],
];
}
/**
* @return array<string, array{workspace: bool, membership: bool, session: bool, provider: bool, credential: bool, cache: bool, uiContext: bool}>
*/
function createUserWithTenantProfiles(): array
{
$profiles = createUserWithTenantProfileCatalog();
foreach (createUserWithTenantLegacyProfileAliases() as $alias => $resolution) {
$profiles[$alias] = array_replace(
$profiles[$resolution['profile']],
$resolution['overrides'] ?? [],
);
}
return $profiles;
}
/**
* @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}
*/
function createStandardUserWithTenant(
?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: 'standard',
ensureDefaultCredential: $ensureDefaultCredential,
clearCapabilityCaches: $clearCapabilityCaches,
setUiContext: $setUiContext,
);
}
/**
* @return array{0: User, 1: Tenant}
*/
function createFullUserWithTenant(
?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: 'full',
ensureDefaultCredential: $ensureDefaultCredential,
clearCapabilityCaches: $clearCapabilityCaches,
setUiContext: $setUiContext,
);
}
/**
* @return array{0: User, 1: Tenant}
*/
function createProviderEnabledUserWithTenant(
?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: 'provider-enabled',
ensureDefaultCredential: $ensureDefaultCredential,
clearCapabilityCaches: $clearCapabilityCaches,
setUiContext: $setUiContext,
);
}
/**
* @return array{0: User, 1: Tenant}
*/
function createCredentialEnabledUserWithTenant(
?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: 'credential-enabled',
ensureDefaultCredential: $ensureDefaultCredential,
clearCapabilityCaches: $clearCapabilityCaches,
setUiContext: $setUiContext,
);
}
/**
* @return array{0: User, 1: Tenant}
*/
function createUiContextUserWithTenant(
?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: 'ui-context',
ensureDefaultCredential: $ensureDefaultCredential,
clearCapabilityCaches: $clearCapabilityCaches,
setUiContext: $setUiContext,
);
}
/**
* @return array{0: User, 1: Tenant}
*/
function createHeavyUserWithTenant(
?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: 'heavy',
ensureDefaultCredential: $ensureDefaultCredential,
clearCapabilityCaches: $clearCapabilityCaches,
setUiContext: $setUiContext,
);
}
/** /**
* @return array{0: User, 1: Tenant} * @return array{0: User, 1: Tenant}
*/ */
@ -341,9 +697,15 @@ function createUserWithTenant(
?User $user = null, ?User $user = null,
string $role = 'owner', string $role = 'owner',
?string $workspaceRole = null, ?string $workspaceRole = null,
bool $ensureDefaultMicrosoftProviderConnection = true, bool $ensureDefaultMicrosoftProviderConnection = false,
string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value, string $defaultProviderConnectionType = ProviderConnectionType::Dedicated->value,
string $fixtureProfile = 'minimal',
bool $ensureDefaultCredential = false,
?bool $clearCapabilityCaches = null,
?bool $setUiContext = null,
): array { ): array {
$resolvedProfile = resolveCreateUserWithTenantProfile($fixtureProfile);
$profile = $resolvedProfile['sideEffects'];
$user ??= User::factory()->create(); $user ??= User::factory()->create();
$tenant ??= Tenant::factory()->create(); $tenant ??= Tenant::factory()->create();
@ -372,25 +734,48 @@ function createUserWithTenant(
])->save(); ])->save();
} }
WorkspaceMembership::query()->updateOrCreate([ if ($profile['membership']) {
'workspace_id' => (int) $workspace->getKey(), WorkspaceMembership::query()->updateOrCreate([
'user_id' => (int) $user->getKey(), 'workspace_id' => (int) $workspace->getKey(),
], [ 'user_id' => (int) $user->getKey(),
'role' => $workspaceRole, ], [
]); 'role' => $workspaceRole,
]);
}
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); if ($profile['session']) {
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save(); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
}
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => $role], $tenant->getKey() => ['role' => $role],
]); ]);
app(CapabilityResolver::class)->clearCache(); $shouldClearCapabilityCaches = $clearCapabilityCaches ?? $profile['cache'];
app(WorkspaceCapabilityResolver::class)->clearCache();
if ($ensureDefaultMicrosoftProviderConnection) { if ($shouldClearCapabilityCaches) {
ensureDefaultProviderConnection($tenant, 'microsoft', $defaultProviderConnectionType); app(CapabilityResolver::class)->clearCache();
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];
@ -543,6 +928,7 @@ 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;
@ -638,6 +1024,15 @@ 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(),

View File

@ -0,0 +1,116 @@
<?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;
}
}

View File

@ -0,0 +1,703 @@
<?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',
];
private const COMPARISON_BASELINES = [
'shared-test-fixture-slimming' => [
'fast-feedback' => [
'laneId' => 'fast-feedback',
'finishedAt' => '2026-04-16T13:11:57+00:00',
'wallClockSeconds' => 176.73623,
'budgetThresholdSeconds' => 200,
'targetImprovementPercent' => 10,
'maxRegressionPercent' => 5,
],
'confidence' => [
'laneId' => 'confidence',
'finishedAt' => '2026-04-16T13:11:57+00:00',
'wallClockSeconds' => 394.383441,
'budgetThresholdSeconds' => 450,
'targetImprovementPercent' => 10,
'maxRegressionPercent' => 5,
],
],
];
/**
* @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 array<string, float|int|string>|null
*/
public static function comparisonBaseline(string $comparisonProfile, string $laneId): ?array
{
$profileBaselines = self::COMPARISON_BASELINES[$comparisonProfile] ?? null;
if (! is_array($profileBaselines)) {
return null;
}
$baseline = $profileBaselines[$laneId] ?? null;
return is_array($baseline) ? $baseline : null;
}
/**
* @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, ?string $comparisonProfile = null): 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'],
comparisonProfile: $comparisonProfile,
);
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;
}
}

View File

@ -0,0 +1,303 @@
<?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,
?string $comparisonProfile = 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;
}
$comparison = self::buildSharedFixtureSlimmingComparison($laneId, $wallClockSeconds, $comparisonProfile);
if ($comparison !== null) {
$report['sharedFixtureSlimmingComparison'] = $comparison;
}
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'],
'sharedFixtureSlimmingComparison' => $report['sharedFixtureSlimmingComparison'] ?? null,
], 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 = '',
?string $comparisonProfile = null,
): 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'],
comparisonProfile: $comparisonProfile,
);
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']),
];
if (isset($report['sharedFixtureSlimmingComparison']) && is_array($report['sharedFixtureSlimmingComparison'])) {
$comparison = $report['sharedFixtureSlimmingComparison'];
$lines[] = sprintf(
'- Shared fixture slimming baseline: %.2f seconds (%s, %+0.2f%%)',
(float) $comparison['baselineSeconds'],
(string) $comparison['status'],
(float) $comparison['deltaPercent'],
);
}
$lines[] = '';
$lines[] = '## Slowest entries';
foreach ($report['slowestEntries'] as $entry) {
$lines[] = sprintf('- %s (%.2fs)', $entry['subject'], (float) $entry['durationSeconds']);
}
return implode(PHP_EOL, $lines).PHP_EOL;
}
/**
* @return array<string, float|int|string>|null
*/
private static function buildSharedFixtureSlimmingComparison(
string $laneId,
float $wallClockSeconds,
?string $comparisonProfile,
): ?array {
if ($comparisonProfile !== 'shared-test-fixture-slimming') {
return null;
}
$baseline = TestLaneManifest::comparisonBaseline($comparisonProfile, $laneId);
if (! is_array($baseline)) {
return null;
}
$baselineSeconds = (float) $baseline['wallClockSeconds'];
$deltaSeconds = round($wallClockSeconds - $baselineSeconds, 6);
$deltaPercent = $baselineSeconds > 0
? round(($deltaSeconds / $baselineSeconds) * 100, 6)
: 0.0;
$targetImprovementPercent = (int) ($baseline['targetImprovementPercent'] ?? 10);
$maxRegressionPercent = (int) ($baseline['maxRegressionPercent'] ?? 5);
$status = 'stable';
if ($deltaPercent <= -$targetImprovementPercent) {
$status = 'improved';
} elseif ($deltaPercent > $maxRegressionPercent) {
$status = 'regressed';
}
return [
'comparisonProfile' => $comparisonProfile,
'baselineFinishedAt' => (string) $baseline['finishedAt'],
'baselineSeconds' => $baselineSeconds,
'deltaSeconds' => $deltaSeconds,
'deltaPercent' => $deltaPercent,
'targetImprovementPercent' => $targetImprovementPercent,
'maxRegressionPercent' => $maxRegressionPercent,
'status' => $status,
];
}
private static function ensureDirectory(string $directory): void
{
if (is_dir($directory)) {
return;
}
mkdir($directory, 0777, true);
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Models\BackupSet;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps the default backup set factory path free of backup item side effects', function (): void {
$backupSet = BackupSet::factory()->create();
expect($backupSet->item_count)->toBe(0)
->and($backupSet->items()->count())->toBe(0);
});
it('can opt into a fuller backup graph with explicit items', function (): void {
$backupSet = BackupSet::factory()->full()->create();
expect($backupSet->item_count)->toBe(1)
->and($backupSet->items()->count())->toBe(1);
});
it('keeps stale and degraded backup item graphs behind explicit named states', function (): void {
$stale = BackupSet::factory()->staleCompleted()->create();
$degraded = BackupSet::factory()->degradedCompleted()->create();
expect($stale->items()->count())->toBe(1)
->and($degraded->items()->count())->toBe(1);
});

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps the default operation run factory path lean by avoiding implicit user creation', function (): void {
$run = OperationRun::factory()->create();
expect($run->tenant_id)->not->toBeNull()
->and($run->workspace_id)->not->toBeNull()
->and($run->user_id)->toBeNull()
->and($run->initiator_name)->toBe('System');
});
it('can opt into an interactive operation context with an explicit user state', function (): void {
$user = User::factory()->create();
$run = OperationRun::factory()->withUser($user)->create();
expect($run->user_id)->toBe((int) $user->getKey())
->and($run->initiator_name)->toBe($user->name);
});
it('keeps tenantless workspace runs available through the explicit tenantless state', function (): void {
$workspace = Workspace::factory()->create();
$run = OperationRun::factory()
->minimal()
->tenantlessForWorkspace($workspace)
->create();
expect($run->tenant_id)->toBeNull()
->and($run->workspace_id)->toBe((int) $workspace->getKey());
});

View File

@ -0,0 +1,28 @@
<?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('keeps the explicit standard provider connection state healthy without credential side effects', function (): void {
$connection = ProviderConnection::factory()->standard()->create();
expect($connection->is_default)->toBeTrue()
->and($connection->credential()->exists())->toBeFalse();
});
it('can opt into a heavier provider graph with a checked-in full factory state', function (): void {
$connection = ProviderConnection::factory()->full()->create();
expect($connection->credential()->exists())->toBeTrue()
->and($connection->refresh()->is_default)->toBeTrue();
});

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Models\ProviderCredential;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderCredentialSource;
use App\Support\Providers\ProviderVerificationStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps the default provider credential path tied to a dedicated connection without extra health side effects', function (): void {
$credential = ProviderCredential::factory()->create();
$connection = $credential->providerConnection()->first();
expect($connection)->not->toBeNull()
->and($connection?->connection_type->value)->toBe(ProviderConnectionType::Dedicated->value)
->and($connection?->is_default)->toBeFalse();
});
it('can opt into a verified dedicated connection graph explicitly', function (): void {
$credential = ProviderCredential::factory()->verifiedConnection()->create();
$connection = $credential->providerConnection()->first();
expect($connection)->not->toBeNull()
->and($connection?->is_default)->toBeTrue()
->and($connection?->verification_status->value)->toBe(ProviderVerificationStatus::Healthy->value)
->and($connection?->credential?->is($credential))->toBeTrue();
});
it('keeps legacy migrated credentials available through an explicit named state', function (): void {
$credential = ProviderCredential::factory()->legacyMigrated()->create();
expect($credential->source->value)->toBe(ProviderCredentialSource::LegacyMigrated->value)
->and($credential->expires_at)->toBeNull();
});

View File

@ -0,0 +1,28 @@
<?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();
});
it('restores default workspace provisioning after an explicit minimal tenant is created', function (): void {
Tenant::factory()->minimal()->create();
$tenant = Tenant::factory()->create();
expect($tenant->workspace_id)->not->toBeNull();
});

View File

@ -0,0 +1,122 @@
<?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);
beforeEach(function (): void {
Filament::setTenant(null, true);
});
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 a standard provider context only when the canonical standard profile asks for it', 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);
[, $tenant] = createStandardUserWithTenant();
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('is_default', true)
->first();
expect($connection)->not->toBeNull()
->and($connection?->credential()->exists())->toBeFalse()
->and(Filament::getTenant())->toBeNull();
});
it('keeps the credential-enabled alias explicit without forcing cache or ui side effects', 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);
[, $tenant] = createCredentialEnabledUserWithTenant();
$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())->toBeNull();
});
it('keeps the ui-context alias explicit without provider or credential side effects', 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);
[, $tenant] = createUiContextUserWithTenant();
expect(ProviderConnection::query()->where('tenant_id', (int) $tenant->getKey())->exists())->toBeFalse()
->and(Filament::getTenant()?->is($tenant))->toBeTrue();
});
it('opt-ins provider, credential, ui-context, and cache resets only when the canonical full 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);
[, $tenant] = createFullUserWithTenant();
$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();
});

View File

@ -0,0 +1,49 @@
<?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',
]);
});

View File

@ -0,0 +1,76 @@
<?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();
});

39
scripts/platform-test-lane Executable file
View File

@ -0,0 +1,39 @@
#!/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}" -- "$@"

12
scripts/platform-test-report Executable file
View File

@ -0,0 +1,12 @@
#!/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] ?? ""), (string) ($argv[2] ?? "shared-test-fixture-slimming")));' "${LANE}" "shared-test-fixture-slimming"

View File

@ -0,0 +1,37 @@
# 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.

View File

@ -0,0 +1,694 @@
{
"$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": []
}
}
}

View File

@ -0,0 +1,396 @@
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

View File

@ -0,0 +1,238 @@
# 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

View File

@ -0,0 +1,164 @@
# 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.

View File

@ -0,0 +1,127 @@
# 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.

View File

@ -0,0 +1,91 @@
# 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.

View File

@ -0,0 +1,198 @@
# 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.

View File

@ -0,0 +1,257 @@
# 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.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Shared Test Fixture Slimming
**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 pass completed after removing leftover template content from the initial scaffold.
- No clarification markers or placeholder sections remain in the spec.
- The spec is ready for `/speckit.plan`.

View File

@ -0,0 +1,511 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantatlas.local/schemas/shared-fixture-profile.schema.json",
"title": "SharedFixtureProfileCatalog",
"type": "object",
"additionalProperties": false,
"required": [
"version",
"defaultProfile",
"profiles",
"helperBindings",
"auditedFactories",
"migrationPacks"
],
"properties": {
"version": {
"type": "integer",
"minimum": 1
},
"defaultProfile": {
"type": "string",
"const": "minimal"
},
"profiles": {
"type": "array",
"minItems": 3,
"items": {
"$ref": "#/$defs/profile"
},
"allOf": [
{
"contains": {
"type": "object",
"required": ["id"],
"properties": {
"id": {
"const": "minimal"
}
}
}
},
{
"contains": {
"type": "object",
"required": ["id"],
"properties": {
"id": {
"const": "standard"
}
}
}
},
{
"contains": {
"type": "object",
"required": ["id"],
"properties": {
"id": {
"const": "full"
}
}
}
}
]
},
"helperBindings": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/helperBinding"
}
},
"auditedFactories": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/auditedFactory"
}
},
"migrationPacks": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/migrationPack"
}
}
},
"$defs": {
"profile": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"costClass",
"notes",
"sideEffects"
],
"properties": {
"id": {
"type": "string",
"enum": [
"minimal",
"standard",
"full"
]
},
"costClass": {
"type": "string",
"enum": [
"minimal",
"standard",
"integration-heavy"
]
},
"legacyAliases": {
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true
},
"notes": {
"type": "string",
"minLength": 1
},
"sideEffects": {
"type": "object",
"additionalProperties": false,
"required": [
"workspace",
"workspaceMembership",
"tenantMembership",
"session",
"cache",
"providerConnection",
"providerCredential",
"uiContext"
],
"properties": {
"workspace": {
"type": "boolean"
},
"workspaceMembership": {
"type": "boolean"
},
"tenantMembership": {
"type": "boolean"
},
"session": {
"type": "boolean"
},
"cache": {
"type": "boolean"
},
"providerConnection": {
"type": "boolean"
},
"providerCredential": {
"type": "boolean"
},
"uiContext": {
"type": "boolean"
}
}
}
},
"allOf": [
{
"if": {
"properties": {
"id": {
"const": "minimal"
}
}
},
"then": {
"properties": {
"costClass": {
"const": "minimal"
},
"sideEffects": {
"properties": {
"providerConnection": {
"const": false
},
"providerCredential": {
"const": false
},
"cache": {
"const": false
},
"uiContext": {
"const": false
}
}
}
}
}
},
{
"if": {
"properties": {
"id": {
"const": "full"
}
}
},
"then": {
"properties": {
"costClass": {
"const": "integration-heavy"
}
}
}
}
]
},
"helperBinding": {
"type": "object",
"additionalProperties": false,
"required": [
"helperName",
"defaultProfile",
"supportedProfiles",
"transitionStatus"
],
"properties": {
"helperName": {
"type": "string",
"minLength": 1
},
"defaultProfile": {
"type": "string",
"enum": [
"minimal",
"standard",
"full"
]
},
"supportedProfiles": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": [
"minimal",
"standard",
"full"
]
},
"uniqueItems": true
},
"legacyAliases": {
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true
},
"legacyAliasResolutions": {
"type": "array",
"items": {
"$ref": "#/$defs/legacyAliasResolution"
}
},
"transitionStatus": {
"type": "string",
"enum": [
"current",
"legacy-transition",
"planned-removal"
]
},
"removalTrigger": {
"type": "string",
"minLength": 1
},
"announcesHeavyContext": {
"type": "boolean"
}
},
"allOf": [
{
"if": {
"properties": {
"transitionStatus": {
"enum": [
"legacy-transition",
"planned-removal"
]
}
}
},
"then": {
"required": [
"removalTrigger"
]
}
}
]
},
"legacyAliasResolution": {
"type": "object",
"additionalProperties": false,
"required": [
"alias",
"resolvedProfile",
"removalTrigger"
],
"properties": {
"alias": {
"type": "string",
"minLength": 1
},
"resolvedProfile": {
"type": "string",
"enum": [
"minimal",
"standard",
"full"
]
},
"removalTrigger": {
"type": "string",
"minLength": 1
},
"sideEffectOverrides": {
"type": "object",
"additionalProperties": false,
"properties": {
"workspace": {
"type": "boolean"
},
"workspaceMembership": {
"type": "boolean"
},
"tenantMembership": {
"type": "boolean"
},
"session": {
"type": "boolean"
},
"cache": {
"type": "boolean"
},
"providerConnection": {
"type": "boolean"
},
"providerCredential": {
"type": "boolean"
},
"uiContext": {
"type": "boolean"
}
}
}
}
},
"auditedFactory": {
"type": "object",
"additionalProperties": false,
"required": [
"factoryName",
"defaultCostClass",
"cascadeFindings"
],
"properties": {
"factoryName": {
"type": "string",
"minLength": 1
},
"defaultCostClass": {
"type": "string",
"enum": [
"minimal",
"standard",
"integration-heavy"
]
},
"leanStates": {
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true
},
"heavyStates": {
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true
},
"cascadeFindings": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/cascadeFinding"
}
}
}
},
"cascadeFinding": {
"type": "object",
"additionalProperties": false,
"required": [
"source",
"triggerCondition",
"createdObjects",
"disposition"
],
"properties": {
"source": {
"type": "string",
"enum": [
"definition",
"attribute-callback",
"afterCreating",
"model-event",
"support-helper"
]
},
"triggerCondition": {
"type": "string",
"minLength": 1
},
"createdObjects": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
},
"uniqueItems": true
},
"disposition": {
"type": "string",
"enum": [
"remove",
"make-explicit",
"retain-documented",
"follow-up"
]
},
"notes": {
"type": "string"
}
}
},
"migrationPack": {
"type": "object",
"additionalProperties": false,
"required": [
"packId",
"targetLanes",
"selectionBasis",
"callerSelectors",
"targetProfile"
],
"properties": {
"packId": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
},
"targetLanes": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": [
"fast-feedback",
"confidence",
"browser",
"heavy-governance"
]
},
"uniqueItems": true
},
"selectionBasis": {
"type": "string",
"enum": [
"high-usage",
"high-cost",
"shared-surface",
"legacy-dependency"
]
},
"callerSelectors": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"targetProfile": {
"type": "string",
"enum": [
"minimal",
"standard",
"full"
]
},
"legacyFallbackAllowed": {
"type": "boolean"
},
"expectedLaneImpact": {
"type": "string"
}
}
}
}
}

View File

@ -0,0 +1,327 @@
openapi: 3.1.0
info:
title: Shared Test Fixture Slimming Logical Contract
version: 1.0.0
summary: Logical contract for resolving fixture profiles, reading cascade audits, and comparing lane impact.
description: |
This is a logical contract for repository tooling, tests, and planning artifacts.
It does not imply a new runtime HTTP service. It documents the expected semantics
of fixture-profile resolution and lane-impact comparison so helper behavior,
guard tests, and reporting remain consistent during the migration.
x-logical-contract: true
servers:
- url: https://tenantatlas.local/logical
paths:
/shared-fixtures/helpers/{helperName}/contexts:
post:
summary: Resolve the promised side effects of a helper for a selected fixture profile.
operationId: resolveSharedFixtureContext
parameters:
- name: helperName
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/HelperResolutionRequest'
responses:
'200':
description: Logical resolution of the helper profile and its side effects.
content:
application/json:
schema:
$ref: '#/components/schemas/HelperResolution'
/shared-fixtures/audits/factories/{factoryName}:
get:
summary: Read the current cascade-audit result for a touched factory or model seam.
operationId: getFactoryCascadeAudit
parameters:
- name: factoryName
in: path
required: true
schema:
type: string
responses:
'200':
description: Current cascade-audit description for the requested seam.
content:
application/json:
schema:
$ref: '#/components/schemas/FactoryCascadeAudit'
/shared-fixtures/lane-impact/runs:
post:
summary: Compare pre- and post-migration lane measurements for a migration pack.
operationId: compareFixtureLaneImpact
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LaneImpactComparisonRequest'
responses:
'200':
description: Comparison result for the affected lanes against the current Spec 206 budgets.
content:
application/json:
schema:
$ref: '#/components/schemas/LaneImpactComparison'
components:
schemas:
ProfileId:
type: string
enum:
- minimal
- standard
- full
CostClass:
type: string
enum:
- minimal
- standard
- integration-heavy
TransitionStatus:
type: string
enum:
- current
- legacy-transition
- planned-removal
RemovalTrigger:
type: string
minLength: 1
Disposition:
type: string
enum:
- remove
- make-explicit
- retain-documented
- follow-up
LaneId:
type: string
enum:
- fast-feedback
- confidence
- browser
- heavy-governance
SideEffects:
type: object
additionalProperties: false
required:
- workspace
- workspaceMembership
- tenantMembership
- session
- cache
- providerConnection
- providerCredential
- uiContext
properties:
workspace:
type: boolean
workspaceMembership:
type: boolean
tenantMembership:
type: boolean
session:
type: boolean
cache:
type: boolean
providerConnection:
type: boolean
providerCredential:
type: boolean
uiContext:
type: boolean
HelperResolutionRequest:
type: object
additionalProperties: false
required:
- profileId
properties:
profileId:
$ref: '#/components/schemas/ProfileId'
legacyAlias:
type: string
expectedHeavyContext:
type: boolean
HelperResolution:
type: object
additionalProperties: false
required:
- helperName
- defaultProfile
- requestedProfile
- canonicalProfile
- costClass
- sideEffects
- transitionStatus
properties:
helperName:
type: string
defaultProfile:
$ref: '#/components/schemas/ProfileId'
requestedProfile:
$ref: '#/components/schemas/ProfileId'
canonicalProfile:
$ref: '#/components/schemas/ProfileId'
costClass:
$ref: '#/components/schemas/CostClass'
sideEffects:
$ref: '#/components/schemas/SideEffects'
transitionStatus:
$ref: '#/components/schemas/TransitionStatus'
legacyAliasUsed:
type: boolean
legacyAlias:
type: string
removalTrigger:
$ref: '#/components/schemas/RemovalTrigger'
notes:
type: string
allOf:
- if:
properties:
requestedProfile:
const: minimal
then:
properties:
costClass:
const: minimal
sideEffects:
properties:
providerConnection:
const: false
providerCredential:
const: false
cache:
const: false
uiContext:
const: false
FactoryCascadeFinding:
type: object
additionalProperties: false
required:
- source
- triggerCondition
- createdObjects
- disposition
properties:
source:
type: string
enum:
- definition
- attribute-callback
- afterCreating
- model-event
- support-helper
triggerCondition:
type: string
createdObjects:
type: array
minItems: 1
items:
type: string
disposition:
$ref: '#/components/schemas/Disposition'
notes:
type: string
FactoryCascadeAudit:
type: object
additionalProperties: false
required:
- factoryName
- defaultCostClass
- findings
properties:
factoryName:
type: string
defaultCostClass:
$ref: '#/components/schemas/CostClass'
leanStates:
type: array
items:
type: string
heavyStates:
type: array
items:
type: string
findings:
type: array
minItems: 1
items:
$ref: '#/components/schemas/FactoryCascadeFinding'
LaneImpactComparisonRequest:
type: object
additionalProperties: false
required:
- packId
- laneMeasurements
properties:
packId:
type: string
laneMeasurements:
type: array
minItems: 1
items:
$ref: '#/components/schemas/LaneMeasurement'
LaneMeasurement:
type: object
additionalProperties: false
required:
- laneId
- baselineSeconds
- postMigrationSeconds
- budgetThresholdSeconds
properties:
laneId:
$ref: '#/components/schemas/LaneId'
baselineSeconds:
type: number
minimum: 0
postMigrationSeconds:
type: number
minimum: 0
budgetThresholdSeconds:
type: number
minimum: 0
LaneImpactComparison:
type: object
additionalProperties: false
required:
- packId
- results
properties:
packId:
type: string
results:
type: array
minItems: 1
items:
type: object
additionalProperties: false
required:
- laneId
- deltaSeconds
- deltaPercent
- budgetThresholdSeconds
- status
properties:
laneId:
$ref: '#/components/schemas/LaneId'
deltaSeconds:
type: number
deltaPercent:
type: number
budgetThresholdSeconds:
type: number
status:
type: string
enum:
- improved
- stable
- regressed
notes:
type: string

View File

@ -0,0 +1,221 @@
# Data Model: Shared Test Fixture Slimming
This feature introduces no new runtime database tables. The data-model work formalizes repository-level fixture-governance objects that describe how shared helpers, factories, migration packs, and lane measurements should behave. Field names are conceptual and may later map to PHP arrays, guard tests, or documentation rather than to persisted runtime records.
## 1. Fixture Profile
### Purpose
Represents one named level of setup cost and side effects for shared test support.
### Fields
- `profile_id`: stable identifier such as `minimal`, `standard`, or `full`
- `display_name`: contributor-facing name
- `cost_class`: `minimal`, `standard`, or `integration-heavy`
- `creates_workspace`: boolean
- `creates_workspace_membership`: boolean
- `creates_tenant_membership`: boolean
- `sets_session_context`: boolean
- `clears_capability_caches`: boolean
- `creates_provider_connection`: boolean
- `creates_provider_credential`: boolean
- `sets_ui_context`: boolean
- `legacy_aliases`: optional list such as `provider-enabled`, `credential-enabled`, or `ui-context`
- `notes`: short guidance for when the profile is appropriate
### Validation Rules
- The catalog must contain at least `minimal`, `standard`, and `full`.
- Exactly one profile may be the default profile for a given helper binding.
- `minimal` must not create provider, credential, cache-clearing, or UI-context side effects.
- `full` may create integration-heavy side effects, but those side effects must be declared explicitly.
## 2. Helper Entry Point
### Purpose
Represents one shared helper or helper alias that resolves to a fixture profile contract.
### Fields
- `helper_name`: example `createUserWithTenant`
- `default_profile_id`: the profile used when no explicit profile is requested
- `supported_profile_ids`: list of allowed profiles
- `legacy_aliases`: transitional helper names or profile aliases
- `announces_heavy_context`: boolean indicating whether the name itself signals heavy behavior
- `side_effect_contract`: list of side effects promised by this helper when a given profile is selected
- `transition_status`: `current`, `legacy-transition`, or `planned-removal`
### Validation Rules
- Every shared helper must resolve to exactly one default profile.
- Heavy behavior must be reachable through an explicit profile or clearly named helper, not only hidden booleans.
- Legacy aliases must map to a declared profile, remain temporary, and point to a declared removal trigger in the transition-path model.
## 3. Factory Cascade Finding
### Purpose
Represents one audited place where a factory, model hook, or callback creates hidden extra context.
### Fields
- `finding_id`: stable identifier
- `subject_name`: factory or model name such as `TenantFactory` or `Tenant::booted`
- `cascade_source`: `definition`, `attribute-callback`, `afterCreating`, `model-event`, or `support-helper`
- `trigger_condition`: what causes the extra context to appear
- `created_objects`: list of related models or state written as a side effect
- `cost_class`: `standard` or `heavy`
- `disposition`: `remove`, `make-explicit`, `retain-documented`, or `follow-up`
- `notes`: rationale and migration concerns
### Validation Rules
- Every audited high-usage subject must have at least one explicit disposition.
- A finding marked `retain-documented` must explain why the behavior is materially required.
## 4. Factory State Contract
### Purpose
Represents the expected cost posture of a factory default or named state.
### Fields
- `factory_name`
- `state_name`
- `cost_class`: `minimal`, `standard`, or `integration-heavy`
- `default_state`: boolean
- `creates_relationships`: related models or graph expansions
- `forbidden_side_effects`: side effects that must not happen in this state
- `intended_usage`: short explanation of what the state is for
### Validation Rules
- A touched high-usage factory must have a declared default cost class.
- A `minimal` state must not create undeclared relationships or repair missing workspace/provider context behind the scenes.
- An `integration-heavy` state must document the graph it creates.
## 5. Legacy Transition Path
### Purpose
Represents how an old helper or heavy default remains temporarily available during migration.
### Fields
- `transition_id`
- `legacy_entry_point`: helper name, alias, or factory default being preserved temporarily
- `resolved_profile_id`: the explicit profile or state it maps to
- `visibility_mode`: `named-alias`, `warning-comment`, or `guard-tested`
- `removal_trigger`: condition for retiring the transition path
- `notes`
### Validation Rules
- A transition path must remain visibly heavier than the new default behavior.
- A transition path must have a declared removal trigger so it does not become permanent accidental API.
## 6. Caller Migration Pack
### Purpose
Represents one batch of high-usage callers migrated together.
### Fields
- `pack_id`
- `target_lanes`: one or more of `fast-feedback`, `confidence`, `browser`, or `heavy-governance`
- `selection_basis`: `high-usage`, `high-cost`, `shared-surface`, or `legacy-dependency`
- `caller_selectors`: directories, files, or patterns identifying the pack
- `target_profile_id`: preferred fixture profile after migration
- `legacy_fallback_allowed`: boolean
- `expected_lane_impact`: narrative or numeric expectation
### Validation Rules
- The first slice must include at least one migration pack affecting either `fast-feedback` or `confidence`.
- Packs migrated to `minimal` or `standard` must not silently fall back to full context without an explicit reason.
## 7. Fixture Guard Contract
### Purpose
Represents a guard test that protects fixture behavior from regression.
### Fields
- `guard_id`
- `subject_type`: `helper`, `factory`, `builder`, or `lane-measurement`
- `subject_name`
- `asserts_absent_context`: list of context that must not appear
- `asserts_present_context`: list of context that must appear
- `covers_transition_path`: boolean
- `failure_signal`: brief description of what regression should be obvious in CI
### Validation Rules
- Minimal-path guards must assert absence of hidden heavy context.
- Heavy-path guards must assert presence of promised heavy context.
- Transition-path guards must remain explicit about what legacy behavior is being preserved.
## 8. Lane Impact Measurement
### Purpose
Represents before and after measurement for a migration pack using the Spec 206 reporting seams.
### Fields
- `measurement_id`
- `lane_id`: example `fast-feedback` or `confidence`
- `baseline_seconds`: pre-migration wall-clock value
- `post_migration_seconds`: post-migration wall-clock value
- `budget_threshold_seconds`: current Spec 206 budget for the lane
- `status`: `improved`, `stable`, or `regressed`
- `notes`
### Validation Rules
- Standard-lane measurements must reference the existing Spec 206 budgets.
- A lane marked `regressed` must explain whether the regression is temporary, justified, or blocking.
## 9. First-Slice Governance Inventory
### Current Shared Fixture Surface
- `apps/platform/tests/Pest.php` now exposes a canonical `minimal`, `standard`, and `full` profile catalog plus explicit transition helpers for `provider-enabled`, `credential-enabled`, `ui-context`, and `heavy` callers.
- `apps/platform/app/Models/Tenant.php` still contains a test-only boot hook that auto-provisions a workspace when `workspace_id` is null.
- `apps/platform/database/factories/TenantFactory.php` uses `afterCreating()` workspace provisioning by default and only partially suppresses it in `minimal()`.
- `apps/platform/database/factories/OperationRunFactory.php` now keeps the default path userless and requires an explicit `withUser()` opt-in for interactive initiator context.
- `apps/platform/database/factories/ProviderConnectionFactory.php` now exposes explicit `minimal`, `standard`, and `full` provider graph states instead of leaving the cost posture implicit.
- `apps/platform/database/factories/ProviderCredentialFactory.php` now exposes an explicit verified-connection state and keeps legacy-migrated credentials visible through a named state.
- `apps/platform/database/factories/BackupSetFactory.php` now keeps the default path item-free and uses explicit `full`, `staleCompleted()`, and `degradedCompleted()` states for backup-item graphs.
- `apps/platform/tests/Feature/Concerns/BuildsBaselineCompareMatrixFixtures.php` and similar builders create intentionally rich multi-record graphs that should remain explicit heavy helpers.
### Recorded Cascade Dispositions
- `Tenant::booted` workspace auto-provisioning: `make-explicit` via `Tenant::skipTestWorkspaceProvisioning()` and `TenantFactory::minimal()`.
- `TenantFactory::configure()` default workspace provisioning: `retain-documented` for legacy callers, with `minimal()` as the lean escape hatch.
- `OperationRunFactory` implicit initiator user creation: `remove` from the default path, now restored only through `withUser()`.
- `OperationRunFactory` workspace repair when a tenant lacks workspace context: `follow-up`; guarded through lean caller migration rather than silently broadening helper defaults further in this slice.
- `ProviderConnectionFactory` provider credential graph inflation: `make-explicit` through `full()` and `withCredential()`.
- `ProviderCredentialFactory` verified dedicated connection graph: `make-explicit` through `verifiedConnection()`.
- `BackupSetFactory` backup-item graph creation: `retain-documented` on named states only (`full`, `staleCompleted`, `degradedCompleted`).
### First-Slice Migration Packs
- `console-navigation-pack`: `tests/Feature/Console/ReconcileOperationRunsCommandTest.php`, `tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php`, and `tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php` now use explicit minimal helpers or lean operation-run factory states.
- `rbac-drift-pack`: `tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php` and `tests/Feature/BaselineDriftEngine/FindingFidelityTest.php` now call `createMinimalUserWithTenant()` directly instead of depending on the generic default helper entry point.
- `heavy-builder-pack`: `BuildsBaselineCompareMatrixFixtures` and `BuildsPortfolioTriageFixtures` now make the chosen minimal actor-setup path explicit while still remaining intentionally graph-heavy builders.
### First-Slice Target Objects
- Canonical fixture-profile vocabulary and helper bindings
- Tenant and workspace provisioning cascade audit
- Lean state contracts for the touched high-usage factories
- Legacy transition paths for heavy historical callers
- High-usage migration packs for fast-feedback and confidence
- Guard contracts for lean, heavy, and legacy-path behavior
- Lane impact measurements against Spec 206 budgets

View File

@ -0,0 +1,178 @@
# Implementation Plan: Shared Test Fixture Slimming
**Branch**: `207-shared-test-fixture-slimming` | **Date**: 2026-04-16 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/207-shared-test-fixture-slimming/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/207-shared-test-fixture-slimming/spec.md`
## Summary
Complete the next stage of suite-cost reduction on top of Spec 206 by formalizing a repository-wide fixture-profile model, keeping minimal setup as the default in shared helpers and high-usage factories, auditing hidden cascades across the test support layer, migrating the highest-usage expensive callers in the fast-feedback and confidence lanes, and validating lane impact through the existing Spec 206 reporting and budget seams instead of introducing a new fixture framework.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail
**Storage**: SQLite `:memory:` for the default test environment, isolated PostgreSQL coverage via the existing dedicated suite, and lane-measurement artifacts under the app-root contract path `storage/logs/test-lanes`
**Testing**: Pest feature, unit, browser, architecture, and guard coverage run through Sail-wrapped `artisan test`; shared-fixture regressions should be protected by focused Pest tests plus existing lane-report tooling
**Target Platform**: Laravel monorepo application with the implementation scoped to `apps/platform` test support and factories
**Project Type**: Monorepo with a Laravel platform app and separate Astro website; this feature affects only platform test infrastructure
**Performance Goals**: Lower per-test cost for the dominant shared fixture paths, keep Spec 206 fast-feedback and confidence lanes stable or improved, and achieve at least one measured standard-lane improvement of 10% or more after the migration pack with no affected standard lane regressing by more than 5%
**Constraints**: Sail-first commands only; no new product persistence or runtime routes; no new dependencies; migration must be incremental and backwards-compatible for legacy heavy callers; avoid a broad suite rewrite; keep helper cost visible by naming rather than by hidden booleans alone
**Scale/Scope**: `createUserWithTenant()` is referenced by roughly 607 callers, existing fixture profiles already exist in `apps/platform/tests/Pest.php`, hidden cascade hotspots include `TenantFactory`, `Tenant` model boot hooks, `OperationRunFactory`, `ProviderConnectionFactory`, `BackupSetFactory`, and multi-record builder traits under `apps/platform/tests/Feature/Concerns`
### Filament v5 Implementation Notes
- **Livewire v4.0+ compliance**: Preserved. This feature changes no runtime Filament or Livewire behavior.
- **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**: No runtime destructive actions are introduced. Any new tests added by this feature continue to validate existing confirmation and authorization behavior rather than creating new action surfaces.
- **Asset strategy**: No panel or shared asset changes. Existing `filament:assets` deployment behavior remains unchanged.
- **Testing plan**: Add Pest coverage for fixture-profile semantics, hidden-cascade regression, legacy transition behavior, migrated high-usage caller packs, and lane impact against the Spec 206 budgets.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS. No inventory, snapshot, or backup truth is changed.
- Read/write separation: PASS. The feature governs repository test support behavior only and introduces no end-user write path.
- Graph contract path: PASS. No Graph calls or `config/graph_contracts.php` changes are introduced.
- Deterministic capabilities: PASS. No capability registry or runtime authorization behavior changes.
- RBAC-UX, workspace isolation, tenant isolation: PASS. No runtime routes, policies, or cross-plane semantics are changed.
- Run observability and Ops-UX: PASS. Lane measurements reuse Spec 206 filesystem artifacts and do not introduce `OperationRun` behavior.
- Data minimization: PASS. Fixture audits and lane measurements remain repository-local and contain no secrets.
- Proportionality and bloat control: PASS WITH LIMITS. The only new abstraction is a narrow fixture-profile and cascade-audit vocabulary for test support behavior; the plan explicitly rejects a generalized fixture framework.
- TEST-TRUTH-001: PASS. The feature improves test honesty by making hidden setup and heavy defaults more visible and verifiable.
- Filament/UI constitutions: PASS / NOT APPLICABLE. No operator-facing UI, asset, badge, or action-surface behavior is changed.
**Phase 0 Gate Result**: PASS
- The feature stays inside repository test infrastructure, factories, and documentation.
- No new runtime persistence, product routes, panels, or Graph seams are introduced.
- The chosen design narrows and documents existing behavior rather than layering a new meta-framework on top of the test support layer.
## Project Structure
### Documentation (this feature)
```text
specs/207-shared-test-fixture-slimming/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── shared-fixture-profile.schema.json
│ └── shared-test-fixture-slimming.logical.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/
├── platform/
│ ├── app/
│ │ └── Models/
│ │ └── Tenant.php
│ ├── database/
│ │ └── factories/
│ │ ├── BackupSetFactory.php
│ │ ├── OperationRunFactory.php
│ │ ├── ProviderConnectionFactory.php
│ │ ├── RestoreRunFactory.php
│ │ └── TenantFactory.php
│ ├── tests/
│ │ ├── Pest.php
│ │ ├── Feature/
│ │ │ ├── Concerns/
│ │ │ ├── Console/
│ │ │ ├── Guards/
│ │ │ ├── Navigation/
│ │ │ └── Spec080WorkspaceManagedTenantAdminMigrationTest.php
│ │ ├── Support/
│ │ │ ├── TestLaneManifest.php
│ │ │ ├── TestLaneBudget.php
│ │ │ └── TestLaneReport.php
│ │ └── Unit/
│ └── storage/logs/test-lanes/
├── website/
└── ...
scripts/
├── platform-sail
├── platform-test-lane
└── platform-test-report
```
**Structure Decision**: Keep the feature concentrated in `apps/platform/tests/Pest.php`, the dominant cascading factories under `apps/platform/database/factories`, the hidden test-only model hook in `apps/platform/app/Models/Tenant.php`, selected high-usage caller files under `apps/platform/tests/Feature`, and guard or support coverage that validates fixture semantics and lane impact. Planning artifacts stay in the `specs/207-shared-test-fixture-slimming` directory.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None | Not applicable | Not applicable |
## Proportionality Review
- **Current operator problem**: Contributors and reviewers still pay hidden full-context setup cost in ordinary tests, which blunts the value of Spec 206 lane governance.
- **Existing structure is insufficient because**: Shared helpers, factory callbacks, and model boot hooks still create extra workspace, provider, credential, session, and related graph state without always making that cost visible.
- **Narrowest correct implementation**: Extend the existing helper-profile seam, audit the key cascading factories and builder traits, add lean and explicit heavy states, keep a transition path for legacy callers, and reuse the Spec 206 lane-report contract for measurement.
- **Ownership cost created**: The repo must maintain documented profile names, a small set of guard tests, and a cascade-audit vocabulary for touched factories and helpers.
- **Alternative intentionally rejected**: A brand-new generic fixture DSL or registry, because the repo already has concrete seams in `tests/Pest.php` and the current problem is hidden cost, not missing framework flexibility.
- **Release truth**: Current-release repository truth and a direct prerequisite for sharper heavy-suite segmentation in Spec 208.
## Phase 0 — Research (complete)
- Output: [research.md](./research.md)
- Resolved key decisions:
- Reuse the existing `createUserWithTenant()` profile seam in `apps/platform/tests/Pest.php` as the foundation instead of introducing a new fixture framework.
- Normalize the feature around the spec-facing model of `minimal`, `standard`, and `full` or `integration-heavy`, while preserving current explicit variants such as `provider-enabled`, `credential-enabled`, and `ui-context` as named heavy add-ons or temporary transition aliases with declared removal triggers.
- Audit tenant workspace provisioning at both the factory layer and the `Tenant` model boot hook because lean tenant creation is incomplete if only one layer is slimmed.
- Treat `OperationRunFactory`, `ProviderConnectionFactory`, `ProviderCredentialFactory`, and `BackupSetFactory` as first-class cascade-audit targets because they still create extra workspace or relationship context by default.
- Keep multi-record concern builders such as `BuildsBaselineCompareMatrixFixtures` explicit and heavy by intent; expose their cost rather than forcing them into the minimal path.
- Reuse Spec 206 lane artifacts and budgets in `storage/logs/test-lanes` for before and after measurement rather than creating a separate performance harness.
- Prefer a backwards-compatible transition path with explicit legacy aliases, removal triggers, and guard coverage over a mass rewrite of all callers.
- Protect the rollout with fixture guard tests that assert both absence of hidden heavy context on minimal paths and presence of promised context on explicit heavy paths.
## Phase 1 — Design & Contracts (complete)
- Output: [data-model.md](./data-model.md) formalizes fixture profiles, helper entry points, factory cascade findings, transition paths, migration packs, guard contracts, and lane-impact measurements.
- Output: [contracts/shared-fixture-profile.schema.json](./contracts/shared-fixture-profile.schema.json) defines the logical contract for a checked-in fixture profile catalog, helper bindings, audited factories, and migration packs.
- Output: [contracts/shared-test-fixture-slimming.logical.openapi.yaml](./contracts/shared-test-fixture-slimming.logical.openapi.yaml) captures the logical contract for resolving helper profiles, reading cascade audits, and comparing lane impact without implying a new runtime service.
- 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 fixture-profile vocabulary is directly justified by current suite cost and does not become a generalized platform abstraction.
- PASS: The design favors extending concrete existing seams in `tests/Pest.php` and the touched factories over adding new registries or a new DSL.
- PASS WITH WORK: Legacy aliases must remain visible, temporary, and tied to explicit removal triggers so the transition layer does not become a permanent second system.
- PASS WITH WORK: Lane validation must use the existing Spec 206 budgets and reporting outputs so performance claims remain evidence-backed.
## Phase 2 — Implementation Planning
`tasks.md` should cover:
- Inventorying the current shared helper surface, high-usage caller patterns, and hidden cascade hotspots touched by this rollout.
- Capturing the pre-migration fast-feedback and confidence baseline with the existing Spec 206 wrappers before fixture edits begin.
- Inventorying and classifying the current shared helper surface, high-usage caller patterns, and hidden cascade hotspots touched by this rollout.
- Finalizing the canonical fixture-profile vocabulary of `minimal`, `standard`, and `full` or `integration-heavy`, including how existing explicit variants map into that model and how temporary aliases retire.
- Refining `createUserWithTenant()` and `createMinimalUserWithTenant()` so the default semantics remain lean and the heavier variants announce themselves clearly.
- Auditing and slimming `TenantFactory`, the `Tenant` model test-only workspace provisioning hook, `OperationRunFactory`, `ProviderConnectionFactory`, `ProviderCredentialFactory`, and `BackupSetFactory`, plus any direct shared-helper user-side effect that proves equally costly during implementation.
- Designing explicit lean and heavy states for the touched factories instead of leaving workspace or provider graph inflation hidden in callbacks or attribute resolvers.
- Adding a visible transition path for legacy heavy callers so existing tests can migrate incrementally without obscuring cost, while keeping every temporary alias on a declared retirement path.
- Migrating the first high-usage caller packs in the fast-feedback and confidence lanes, prioritizing console, authorization, navigation, and narrow finding or baseline tests that do not need provider-heavy setup.
- Reviewing heavy-by-intent concern builders and making their cost explicit through naming or parameterization rather than forcing them into default minimal semantics.
- Adding guard coverage for fixture-profile semantics, hidden cascade regression, and stable legacy-path behavior.
- Measuring post-migration lane impact with the existing Spec 206 wrappers and report artifacts, then documenting the comparison against the recorded pre-migration baseline and current budgets.
- Publishing concise contributor guidance for choosing the cheapest valid fixture path and recognizing when heavy integration context is being requested.
### Contract Implementation Note
- The fixture-profile schema is schema-first and intentionally repo-tooling-oriented. It defines what a checked-in fixture catalog must express even if the first implementation remains PHP arrays and guard tests rather than a new parser.
- The OpenAPI file is logical rather than transport-prescriptive. It documents the expected semantics of helper-profile resolution, cascade-audit inspection, and lane-impact comparison for commands, tests, or wrappers that remain in-process repository tooling.
- The plan intentionally avoids introducing a new runtime service, new database table, or new filesystem artifact family outside the existing Spec 206 lane-report directory.
### Deployment Sequencing Note
- No database migration is planned.
- No asset publish step changes.
- The rollout should start with audited helper and factory seams, then move to high-usage caller migration, then stabilize guard coverage, and finally validate lane impact against the existing Spec 206 budgets.

View File

@ -0,0 +1,104 @@
# Quickstart: Shared Test Fixture Slimming
## Goal
Reduce per-test cost in the shared support layer without reducing meaningful coverage by making minimal fixture behavior the default, moving heavy provider or membership or workspace behavior behind explicit opt-in paths, migrating the highest-usage expensive callers, and proving the result against the existing Spec 206 lane budgets.
## Implementation Order
1. Capture the pre-migration fast-feedback and confidence lane baseline with the existing Spec 206 wrappers before helper or factory edits begin.
2. Inventory and classify the existing helper and factory seams that still hide extra workspace, provider, credential, session, user-relationship, or graph state.
3. Finalize the fixture-profile vocabulary of `minimal`, `standard`, and `full` or `integration-heavy`, including mapping rules and retirement triggers for current explicit variants.
4. Audit and slim tenant workspace provisioning across both `TenantFactory` and the `Tenant` model boot hook.
5. Audit and slim the next high-cost factories, starting with `OperationRunFactory`, `ProviderConnectionFactory`, `ProviderCredentialFactory`, and `BackupSetFactory`.
6. Make heavy-by-intent builders and helpers announce their cost through naming or explicit parameters.
7. Add guard coverage that asserts lean paths stay lean and heavy paths create only their promised extra context.
8. Migrate the first high-usage caller packs in fast-feedback and confidence.
9. Measure post-migration lane impact against the recorded baseline with the existing Spec 206 lane-report tooling. `./scripts/platform-test-report` should emit the `sharedFixtureSlimmingComparison` block for `fast-feedback` and `confidence`.
10. Publish concise contributor guidance for choosing the cheapest valid fixture path.
## Suggested Code Touches
```text
apps/platform/app/Models/Tenant.php
apps/platform/database/factories/TenantFactory.php
apps/platform/database/factories/OperationRunFactory.php
apps/platform/database/factories/ProviderConnectionFactory.php
apps/platform/database/factories/BackupSetFactory.php
apps/platform/database/factories/RestoreRunFactory.php
apps/platform/tests/Pest.php
apps/platform/tests/Feature/Concerns/*
apps/platform/tests/Feature/Console/*
apps/platform/tests/Feature/Guards/*
apps/platform/tests/Feature/Navigation/*
apps/platform/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
apps/platform/tests/Support/*
scripts/platform-test-lane
scripts/platform-test-report
```
## Validation Flow
Use focused suites while slimming one seam at a time, then validate the standard lanes against the Spec 206 baseline:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Console
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Navigation
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php
./scripts/platform-test-lane fast-feedback
./scripts/platform-test-lane confidence
./scripts/platform-test-report fast-feedback
./scripts/platform-test-report confidence
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
If a touched factory or helper affects an intentionally heavy builder, validate that specific surface before measuring the lanes.
## Recorded Pre-Migration Baseline Capture
The current pre-migration snapshots for this spec were captured on `2026-04-16T13:11:57+00:00` and are the baseline that the lane-impact guard and report comparison use:
| Lane | Baseline | Budget | Target |
|------|----------|--------|--------|
| `fast-feedback` | `176.73623s` | `200s` | improve by `>=10%` or stay within `+5%` |
| `confidence` | `394.383441s` | `450s` | improve by `>=10%` or stay within `+5%` |
## Baseline References
Use the existing Spec 206 recorded measurements as the starting point for lane impact:
- `fast-feedback`: `176.74s` budget `200s`
- `confidence`: `394.38s` budget `450s`
- `heavy-governance`: `83.66s` budget `120s`
- `browser`: `128.87s` budget `150s`
The first success target for this feature is not to redesign the lane model. It is to improve per-test cost enough that at least one affected standard lane gets measurably faster while the others remain stable.
Record fresh pre-migration fast-feedback and confidence snapshots on the feature branch before fixture changes land so the eventual comparison is not inferred only from historical Spec 206 documentation.
## Legacy Alias Retirement
- `provider-enabled` retires after the first fast-feedback and confidence migration packs call `standard` directly.
- `credential-enabled` retires after credential-dependent callers move to `full` plus explicit local overrides where needed.
- `ui-context` retires after UI-context callers use the canonical `full` profile or an equally explicit local helper.
- `heavy` retires after the remaining legacy callers use `full` directly.
## Manual Review Checklist
1. Confirm the default helper path stays on `minimal` semantics and does not silently create provider or credential or UI context.
2. Confirm touched factories declare lean versus heavy behavior explicitly instead of hiding it in callbacks or attribute resolvers.
3. Confirm the tenant workspace cascade is controlled consistently across both factory and model layers.
4. Confirm legacy heavy paths remain visible, temporary during migration, and tied to explicit removal triggers.
5. Confirm the first migrated caller packs match their real business need and do not fall back to full context without explanation.
6. Confirm guard coverage asserts both absence and presence of context where appropriate.
7. Confirm both pre- and post-migration lane measurements are taken through the existing Spec 206 wrappers and artifacts under `storage/logs/test-lanes`.
## Exit Criteria
1. The shared support layer has a documented fixture-profile model with `minimal`, `standard`, and `full` or `integration-heavy`.
2. Touched high-usage helpers and factories no longer create hidden heavy context by default.
3. Legacy heavy behavior is available through an explicit and visibly temporary transition path with a declared removal trigger.
4. The first high-usage caller packs in fast-feedback and confidence have been migrated.
5. Guard coverage protects lean paths, heavy paths, and the transition path.
6. Post-migration lane measurements show at least one affected standard lane improved and no affected standard lane materially regressed against the Spec 206 budgets.

View File

@ -0,0 +1,73 @@
# Research: Shared Test Fixture Slimming
## Decision 1: Reuse the existing `createUserWithTenant()` seam instead of introducing a new fixture framework
- Decision: The rollout should build on the existing helper surface in `apps/platform/tests/Pest.php`, with `createUserWithTenant()` and `createMinimalUserWithTenant()` remaining the primary entry points for shared tenant-user setup.
- Rationale: The repo already has named fixture profiles and a dedicated helper used by roughly 607 callers. The problem is hidden cost and incomplete slimming, not the absence of a fixture abstraction.
- Alternatives considered:
- Create a new generic fixture registry or DSL: rejected because it would add abstraction cost without solving the immediate hidden-side-effect problem better than the existing seam.
- Push all setup inline into each test: rejected because it would duplicate setup logic and weaken shared guard coverage.
## Decision 2: Normalize the profile model around `minimal`, `standard`, and `full` or `integration-heavy`
- Decision: The plan should use the spec-facing profile vocabulary of `minimal`, `standard`, and `full` or `integration-heavy`, while mapping current explicit variants such as `provider-enabled`, `credential-enabled`, and `ui-context` as named heavy opt-ins or temporary transition aliases with declared removal triggers.
- Rationale: The specification requires a clear three-level model for authors and reviewers. The current profile set is useful, but it is helper-centric and does not yet present a consistent suite-wide vocabulary.
- Alternatives considered:
- Keep only the current helper-specific profile names: rejected because they do not express a unified fixture discipline across helpers and factories.
- Collapse everything into `minimal` and `heavy`: rejected because a bounded middle path is useful for common setup that is more than bare minimum but still not full integration context.
## Decision 3: Audit tenant workspace provisioning at both the factory and model-event layers
- Decision: The tenant cascade audit must treat `TenantFactory` and the `Tenant` model boot hook as one coupled hotspot, and lean tenant creation must disable both layers when the test requests minimal behavior.
- Rationale: `TenantFactory::minimal()` already tries to suppress workspace creation, but the `Tenant` model still has test-only workspace provisioning logic in `booted()`. Slimming only one layer leaves hidden graph inflation in place.
- Alternatives considered:
- Slim only the factory: rejected because the model event can still recreate the hidden workspace relationship.
- Remove all test-only workspace provisioning immediately without transition handling: rejected because some callers still rely on the historical behavior and need an explicit heavy path.
## Decision 4: Treat `OperationRunFactory`, `ProviderConnectionFactory`, and `BackupSetFactory` as first-class cascade-audit targets
- Decision: The first factory audit pass should cover `OperationRunFactory`, `ProviderConnectionFactory`, and `BackupSetFactory` in addition to `TenantFactory`.
- Rationale: These factories still create or repair extra workspace, tenant, credential, or backup-item context through attribute callbacks or `afterCreating()` hooks, and they appear in common test-support flows.
- Alternatives considered:
- Restrict the audit to `TenantFactory` only: rejected because several heavy test paths start from factories that hide cost even after tenant setup is slimmed.
- Attempt to audit every factory in one pass: rejected because the first slice should focus on high-usage or high-cost factories that materially affect the standard lanes.
## Decision 5: Keep concern builders explicit and heavy by intent
- Decision: Multi-record builders such as `BuildsBaselineCompareMatrixFixtures` should remain explicit heavy helpers, but their cost should be surfaced through naming, parameters, or guard documentation rather than being treated like ordinary default fixtures.
- Rationale: These builders exist to create deliberately rich graphs for matrix, portfolio, or governance scenarios. The correct fix is to announce cost, not to pretend they are minimal.
- Alternatives considered:
- Force concern builders into the minimal profile model: rejected because it would blur the difference between narrow test setup and intentionally broad scenario construction.
- Leave them undocumented: rejected because hidden heavy setup in support traits still harms review clarity.
## Decision 6: Reuse Spec 206 lane tooling for before and after measurement
- Decision: Lane impact should be measured through the existing `TestLaneManifest`, `TestLaneBudget`, `TestLaneReport`, `scripts/platform-test-lane`, and `scripts/platform-test-report` seams introduced by Spec 206.
- Rationale: The repository already has budgeted fast-feedback and confidence lanes plus artifact output under `storage/logs/test-lanes`. Creating a second performance harness would duplicate governance and weaken comparability.
- Alternatives considered:
- Measure ad hoc with one-off local scripts: rejected because it would not create stable, reviewable evidence.
- Introduce a dedicated new profiling subsystem: rejected because the repo already has lane reporting and budget evaluation contracts.
## Decision 7: Use a backwards-compatible transition layer rather than a hard cutover
- Decision: The rollout should keep a visible transition path for legacy heavy callers, mark aliases as temporary, and retire them once the first migration packs and guard coverage are complete rather than forcing a suite-wide cutover.
- Rationale: Existing tests may depend on historical side effects. A staged migration gives the suite time to expose hidden dependencies while keeping cost visible instead of silently restoring old defaults or preserving a permanent second vocabulary.
- Alternatives considered:
- Rewrite all callers in one pass: rejected because it creates too much review noise and raises breakage risk.
- Freeze legacy behavior permanently: rejected because it would blunt the cost reduction that the spec exists to achieve.
## Decision 8: Guard coverage must assert both absence and presence of context
- Decision: Fixture guard tests should verify that minimal paths do not create hidden provider, credential, workspace, session, or cache side effects, and that explicit heavy paths create only the promised additional context.
- Rationale: Slimming work regresses easily when helpers or factories gain new callbacks. Both negative and positive assertions are needed to keep the support layer honest.
- Alternatives considered:
- Test only the lean path: rejected because heavy profiles also need a stable contract.
- Rely only on wall-clock improvements: rejected because runtime improvements alone do not prove that the correct context is being created.
## Decision 9: Migrate high-usage caller packs by business need, not by file ownership
- Decision: The first migration packs should target high-usage callers in console, authorization, navigation, and narrow findings or baseline tests where provider-heavy or full integration context is usually unnecessary.
- Rationale: These packs sit closest to the fast-feedback and confidence lanes and provide early lane impact without forcing speculative rewrites of already heavy-by-intent suites.
- Alternatives considered:
- Start with the most complex multi-tenant builders: rejected because their business need often justifies heavy context and they are a weaker first proof of lane impact.
- Pick callers only by directory ownership: rejected because fixture cost and lane importance do not align neatly with team or folder boundaries.

View File

@ -0,0 +1,318 @@
# Feature Specification: Shared Test Fixture Slimming
**Feature Branch**: `207-shared-test-fixture-slimming`
**Created**: 2026-04-16
**Status**: Draft
**Input**: User description: "Spec 207 - Shared Test Fixture Slimming"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Shared test fixtures and factory defaults regularly create more tenant, user, workspace, membership, provider, session, and capability context than many tests actually need.
- **Today's failure**: Short, ordinary tests silently pay full-context setup cost, hidden helper side effects make tests harder to read, and lane improvements from Spec 206 are limited because per-test cost stays inflated.
- **User-visible improvement**: Contributors get a cheaper standard test setup, clearer fixture intent at the call site, and a more stable fast-feedback or confidence experience without reducing legitimate coverage.
- **Smallest enterprise-capable version**: Define a small fixture-profile model, make minimal setup the default, move heavy provider or membership or workspace behavior behind explicit opt-in paths, slim the most-used factories and helpers, migrate the highest-usage expensive callers, and add guard coverage plus before and after runtime comparison.
- **Explicit non-goals**: No full-suite reorganization, no blanket rewrite of every existing test, no removal of valid integration-heavy coverage that still reflects real business risk, no browser-strategy redesign, and no CI rollout redesign.
- **Permanent complexity imported**: Fixture-profile vocabulary, lean versus heavy helper entry points, lighter factory states, a transition layer for legacy callers, guard coverage, and short author guidance.
- **Why now**: Spec 206 already made suite cost visible. Without reducing per-test cost now, standard lanes will keep inheriting expensive defaults and later lane segmentation work will remain more expensive than necessary.
- **Why not local**: Local cleanups in one or two tests cannot stop the support layer from reintroducing hidden full-context setup across the suite. The default behavior must change at the shared-fixture level.
- **Approval class**: Cleanup
- **Red flags triggered**: "Foundation" language and a new profile vocabulary. Defense: this feature narrows and clarifies existing fixture behavior instead of adding new product runtime truth, new operator surfaces, or speculative 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 change. The affected surfaces are repository-level test fixtures, shared setup paths, factory defaults, contributor guidance, and runtime comparison outputs.
- **Data Ownership**: Workspace-owned test-support conventions, fixture profiles, migration guidance, guard coverage, and runtime comparison evidence. No tenant-owned product records are added or changed.
- **RBAC**: No end-user authorization behavior changes. The affected actors are repository contributors and reviewers who need a cheaper, clearer default test path.
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no new product persistence; only repository documentation, test support behavior, and runtime comparison evidence
- **New abstraction?**: yes, but limited to a small fixture-profile vocabulary for the shared test support layer
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Contributors and reviewers cannot rely on the shared test support layer to stay cheap by default, so ordinary tests inherit hidden setup cost and the standard lanes remain slower than they need to be.
- **Existing structure is insufficient because**: shared helpers and factory defaults currently behave like opt-out full-context builders, which hides cost and makes later lane governance less effective.
- **Narrowest correct implementation**: keep the change inside the test support layer, a small fixture-profile model, the most-used factories and helpers, a limited caller migration pack, and guard coverage.
- **Ownership cost**: The team must maintain profile naming, transition guidance, guard tests, and a small amount of runtime comparison evidence as fixture behavior evolves.
- **Alternative intentionally rejected**: isolated caller-by-caller cleanup without changing shared defaults, because it would leave the hidden cost model intact and allow regressions to re-enter through new tests.
- **Release truth**: current-release repository truth that reduces existing suite cost and prepares the next heavy-suite segmentation step.
## Problem Statement
TenantPilot's test suite is expensive not only because it is large, but because many shared fixtures act like full-context convenience builders by default.
The current pain is structural:
- Shared helpers often create broad tenant, user, workspace, membership, provider, session, or capability context even when the test only needs a narrow slice.
- Expensive context is commonly opt-out instead of opt-in.
- Factory defaults can trigger hidden cascades that create relationship graphs a test author did not clearly ask for.
- The real setup cost of a short test is hard to see during review.
- Standard lanes pay for integration-heavy defaults even when the business assertion is narrow.
If these defaults stay in place, the fast-feedback and confidence lanes introduced by Spec 206 will keep absorbing avoidable per-test cost as the suite grows.
## Dependencies
- Depends on Spec 206 - Test Suite Governance & Performance Foundation for the baseline, lane vocabulary, and runtime comparison discipline.
- Recommended before Spec 208 - Filament or Livewire Heavy Suite Segmentation so per-test cost is reduced before the heaviest families are moved into sharper lane boundaries.
- Does not block ongoing feature delivery as long as new tests already follow the minimal-fixture discipline introduced here.
## Goals
- Reduce per-test cost without reducing meaningful coverage.
- Make minimal fixtures the standard path for ordinary tests.
- Require explicit opt-in for provider, credential, membership, workspace, session, capability, and other integration-heavy setup.
- Reduce hidden helper and factory side effects.
- Make test intent easier to read because required context is visible at the call site.
- Prevent new tests from drifting back into expensive full-context defaults.
## Non-Goals
- Reorganizing the full suite or redefining all lane boundaries.
- Rewriting every existing test in one pass.
- Removing valid provider, membership, workspace, or panel-adjacent tests that still need full context.
- Redesigning CI behavior or browser strategy.
- Refactoring unrelated application architecture outside the test support layer.
## Assumptions
- Spec 206 baseline reporting is available or can be regenerated before lane impact is evaluated.
- Existing business-relevant coverage remains valuable; the goal is to lower setup cost, not to reduce confidence.
- Migration will be incremental, with a temporary transition layer where legacy callers still need heavier behavior.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Use A Minimal Fixture By Default (Priority: P1)
As a contributor adding or editing an ordinary test, I want the default shared setup path to create only the smallest valid context for the behavior under test so the test stays cheap and easy to understand.
**Why this priority**: This is the highest-frequency authoring path in the repository. If it stays expensive by default, every new test can quietly push lane cost upward.
**Independent Test**: Use the default shared fixture path and default factory states for a representative narrow test, then verify that no provider, credential, membership, workspace, session, capability, or other heavy add-ons appear unless the test asked for them.
**Acceptance Scenarios**:
1. **Given** a contributor uses the default shared fixture path for a narrow test, **When** the test asks only for core context, **Then** only the smallest valid context is created and heavy add-ons are absent.
2. **Given** a contributor uses a default factory state, **When** no heavy state is requested, **Then** extra relationship graphs are not created implicitly.
---
### User Story 2 - Opt Into Heavy Context Deliberately (Priority: P1)
As a contributor working on behavior that truly depends on provider, membership, workspace, session, or workflow context, I want an explicit way to request that heavier setup so the test gets the right environment without hiding its cost.
**Why this priority**: Some tests genuinely need heavy context. The feature must preserve those cases while making the cost visible and intentional.
**Independent Test**: Select an explicit heavy profile or add-on state for a representative integration-heavy test and verify that the promised additional context is present while remaining clearly identifiable at the call site.
**Acceptance Scenarios**:
1. **Given** a test requires heavy integration context, **When** the author selects an explicit heavy profile or add-on state, **Then** the required extra context is created.
2. **Given** a reviewer inspects a heavy test setup, **When** the test asks for the heavier path, **Then** the setup clearly announces that it is requesting more than the minimal default.
---
### User Story 3 - Migrate High-Usage Callers Safely (Priority: P2)
As a maintainer reducing suite cost, I want the most frequently used expensive setup paths migrated to lighter defaults without causing unnecessary mass breakage.
**Why this priority**: Shared defaults only pay off when the most-used callers move to the cheaper profiles that match their real needs.
**Independent Test**: Migrate a representative pack of high-usage callers, run their tests, and verify that narrow tests move to lighter profiles while legacy full-context callers remain available through the transition path until they are intentionally migrated.
**Acceptance Scenarios**:
1. **Given** a high-usage caller only needs narrow context, **When** it is migrated, **Then** it uses a lighter profile and keeps its business assertions green.
2. **Given** a legacy caller still depends on full context, **When** the transition path is used, **Then** the behavior remains available during migration and its heavier cost stays visible.
---
### User Story 4 - Catch Fixture Cost Regressions Early (Priority: P2)
As a reviewer or maintainer, I want guard coverage and visible cost signals around shared fixtures so regressions in hidden setup cost are caught before they spread through the standard lanes.
**Why this priority**: Without early signals, expensive defaults quietly return and erase the benefit of the migration work.
**Independent Test**: Run guard coverage and runtime comparison after a shared-fixture change and confirm that unexpected extra context or lane regressions are detected.
**Acceptance Scenarios**:
1. **Given** a shared helper or factory begins creating unexpected extra context, **When** guard coverage runs, **Then** the regression fails clearly.
2. **Given** the main migration pack is complete, **When** runtime comparison is reviewed against the Spec 206 baseline, **Then** the affected standard lanes are stable or improved.
### Edge Cases
- A legacy test depended on hidden session or capability mutation from a generic helper; the migration must expose that dependency rather than silently recreating it in the minimal path.
- A test needs one expensive add-on, such as provider context, without needing the rest of the full integration graph.
- A factory callback or model default quietly recreates a removed relationship graph even after a lean state is selected.
- A high-usage caller pack spans more than one fixture profile during the transition period and must remain readable rather than falling back to generic magic.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes no end-user routes, no Microsoft Graph behavior, no queued operations, and no authorization planes. It does change repository-wide test support behavior, so the fixture-profile model, transition path, and guard coverage must remain explicit, reviewable, and measurable.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The only intentional new abstraction is a narrow fixture-profile model for the shared test support layer. It is justified because existing defaults hide cost and make lane governance ineffective. The implementation must prefer explicit, local behavior over a broad new framework and must not create new product persistence or speculative semantic layers.
### Functional Requirements
- **FR-001 Fixture Profile Model**: The shared test support layer MUST define at least three clearly named fixture profiles: minimal, standard, and full or integration-heavy. Each profile MUST have a documented purpose and clearly bounded cost expectations.
- **FR-002 Minimal Default**: Shared fixtures MUST use the minimal profile as the default path for ordinary tests.
- **FR-003 Explicit Heavy Opt-In**: Provider, credential, membership, workspace, session, capability, and other integration-heavy setup MUST be created only when a test explicitly requests it.
- **FR-004 Shared Helper Split**: Central shared helpers MUST be split into clearly distinguishable light and heavy paths, whether by separate entry points or explicit profile selection, so the cost difference is visible to authors and reviewers.
- **FR-005 Expensive Defaults Removed**: Automatic provider connections, provider credentials, membership enrichment, workspace enrichment, session or capability state, and extra user or tenant relationships MUST no longer be silent defaults in the shared fixture path.
- **FR-006 Factory State Discipline**: The highest-usage test factories MUST provide lean defaults and clearly named add-on states for provider, membership, workspace, and operation-context needs. Defaults MUST avoid unnecessary cascades.
- **FR-007 Hidden Cascade Audit**: The most important test aggregates, including tenant, workspace, user, provider connection, provider credential, operation context, and other high-usage shared setup inputs, MUST be audited for callbacks, defaults, or repair behavior that silently create extra objects or state.
- **FR-008 Transition Layer**: A backwards-compatible transition path MUST exist so existing tests do not break unnecessarily while migration is in progress, heavier legacy behavior MUST remain clearly identifiable, and every legacy alias or full-context fallback MUST carry a declared removal trigger tied to migration-pack completion.
- **FR-009 Caller Migration Pack**: The most frequently used expensive callers in the fast-feedback and confidence lanes MUST be migrated to the lightest profile that still matches their business needs.
- **FR-010 Cost Visibility**: The test support layer MUST make heavy setup visible through naming, guidance, or guard signals so contributors can tell when they are requesting more than the minimal path.
- **FR-011 Guard Coverage**: Guard tests MUST verify that minimal profiles do not create hidden heavy context, heavy profiles create only their promised extra context, and fixture regression is detected early.
- **FR-012 Coverage Preservation**: The rollout MUST preserve legitimate provider, membership, workspace, and other integration-heavy coverage where that context is materially required.
- **FR-013 Budget Validation**: After the main migration pack, the affected standard lanes MUST be compared against the Spec 206 baseline and show stable or improved runtime.
- **FR-014 Author Guidance**: Contributors MUST have concise guidance explaining when to use minimal, standard, and full fixture paths and how to avoid reintroducing hidden setup cost.
### Non-Functional Requirements
- **NFR-001 Cheap-Default Bias**: The cheapest valid setup path must be easier to reach than the heavy path.
- **NFR-002 Readable Intent**: Test setup must communicate required context more clearly than the current convenience-heavy defaults.
- **NFR-003 Incremental Adoption**: Migration must be possible in small slices without forcing a full-suite rewrite.
- **NFR-004 Lane Benefit Preservation**: Changes must reinforce, not undermine, the fast-feedback and confidence lanes defined in Spec 206.
## Work Packages
### Work Package A - Fixture Surface Inventory
- Catalogue the central shared helpers that are most frequently used in ordinary tests.
- Identify the highest-usage expensive caller paths.
- Record which hidden side effects currently inflate context or cost.
- Classify each hidden-cascade finding as removed, made explicit, retained-documented, or follow-up before caller migration begins.
- Distinguish where narrow context is enough versus where true full integration context is required.
### Work Package B - Profile Design
- Finalize the minimal, standard, and full or integration-heavy profile model.
- Make the light versus heavy API boundary obvious to authors and reviewers.
- Move hidden helper semantics into explicit profile choices or clearly separate entry points.
- Ensure heavy paths announce themselves rather than hiding behind generic convenience names.
### Work Package C - Factory Slimming
- Audit the most important factory defaults for hidden cascades.
- Introduce lean defaults and explicit heavy add-on states.
- Remove or isolate automatic graph inflation that is not required by most tests.
- Keep any unavoidable heavy behavior explicit and documented.
### Work Package D - Caller Migration
- Migrate the most-used expensive callers to lighter profiles first.
- Retain full-context setup only where the business assertion genuinely needs it.
- Prioritize callers exercised by the fast-feedback and confidence lanes.
- Use the transition layer to avoid unnecessary broad breakage during migration.
### Work Package E - Regression Pack
- Add guard coverage for minimal, standard, and full fixture semantics.
- Protect against reintroduction of hidden heavy defaults.
- Keep a small regression pack for legacy transition behavior until migration is complete.
- Validate lane impact against the Spec 206 budgets after the main migration slice.
## Deliverables
- Slimmed shared test fixtures with a documented profile model.
- Leaner default states for the most-used factories and explicit heavy add-on states.
- A hidden-cascade audit for the key shared setup inputs with a recorded disposition for each named finding.
- A transition path for legacy callers.
- Guard coverage for fixture behavior and regression detection.
- Migration of the highest-usage expensive callers.
- Concise author guidance for minimal versus full fixture setup.
- A before and after runtime comparison against the Spec 206 baseline.
## Risks
### Hidden Dependency Breakage
Existing tests may rely on side effects that are currently invisible. Slimming defaults can expose those dependencies quickly.
### Over-Slimming
If the minimal path removes context that is still meaningfully required, tests can become harder to write or understand.
### API Proliferation
Too many helper variants could make the support layer harder to navigate than the current defaults.
### Incomplete Migration
If shared defaults change but the highest-usage callers remain on legacy behavior, the lane-level impact will stay limited.
## Rollout Guidance
- Start with the most-used and most expensive shared setup paths rather than trying to slim everything at once.
- Capture a pre-migration fast-feedback and confidence baseline through the existing Spec 206 wrappers before helper or factory edits begin.
- Finalize the profile model before migrating callers so the new naming is stable.
- Remove silent heavy defaults before expanding the migration pack.
- Use the transition layer to keep the rollout incremental and reviewable, but attach a removal trigger to every temporary alias or legacy full-context path.
- Measure lane impact after the main migration slice rather than waiting for total suite completion.
## Design Rules
- **Minimal first**: The cheapest valid fixture is the default.
- **No surprise provider setup**: Provider or credential context is never created implicitly.
- **No surprise session or capability mutation**: Session or capability state changes require explicit intent.
- **No hidden graph inflation**: Factories must not silently inflate relationship graphs by default.
- **Readable intent over helper magic**: Slightly more explicit setup is acceptable when it makes the required context obvious.
- **Heavy context must announce itself**: Integration-heavy setup must be recognizable at the call site.
- **Legacy heavy aliases stay temporary**: Transitional helper aliases and legacy full-context fallbacks must declare how they will be retired.
## Suggested Task Breakdown
1. Inventory shared fixtures and high-usage callers.
2. Finalize the fixture-profile model.
3. Split shared helper APIs into light and heavy paths.
4. Audit the most-used factory defaults.
5. Introduce lean factory states and explicit heavy add-ons.
6. Remove provider, credential, membership, workspace, session, and capability defaults that should be opt-in.
7. Migrate the top caller pack.
8. Add guard and regression coverage.
9. Validate lane impact against the Spec 206 budgets.
10. Publish concise author guidance.
## Definition of Done
This feature is done when:
- the support layer has a clearly documented cheap default path,
- heavy defaults are explicit rather than silent,
- the central factories no longer create unnecessary hidden cascades by default,
- the highest-usage expensive callers have moved to the new lighter paths where appropriate,
- guard and regression coverage is green,
- the affected standard lanes are successfully validated against the Spec 206 baseline,
- and reviewers can tell from test setup whether a new test is asking for minimal, standard, or full integration context.
## Recommended Next Spec
After this feature, the next recommended step is Spec 208 - Filament or Livewire Heavy Suite Segmentation, so the suite first reduces per-test cost and then sharpens lane boundaries for the heaviest families.
## Key Entities *(include if feature involves data)*
- **Fixture Profile**: A named level of shared setup cost and scope, such as minimal, standard, or full or integration-heavy.
- **Shared Fixture Path**: A reusable setup path that creates only a defined amount of context for a test.
- **Lean Factory State**: A factory default or add-on state that keeps record creation limited to the context the test actually needs.
- **Hidden Cascade Finding**: An audited place where defaults, callbacks, or repair behavior silently create extra records or state.
- **Legacy Transition Path**: A temporary way to preserve heavier historical behavior while callers migrate to lighter profiles.
- **Fixture Cost Signal**: Naming, guidance, or guard coverage that makes a heavy setup request obvious.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A documented fixture-profile model exists with minimal, standard, and full or integration-heavy levels, and all migrated call sites make the chosen level recognizable from the setup request.
- **SC-002**: The default shared fixture path and the default states of the highest-usage audited factories no longer create provider, credential, membership, workspace, session, or capability add-ons unless explicitly requested.
- **SC-003**: Guard coverage verifies both that minimal profiles stay lean and that heavy profiles create their promised extra context for the audited shared helpers and factories.
- **SC-004**: The top migration pack for the fast-feedback and confidence lanes is completed, and the migrated callers retain their intended business assertions without blanket fallback to legacy full-context setup.
- **SC-005**: Post-migration measurement shows that at least one affected standard lane improves wall-clock runtime by 10% or more versus the pre-migration baseline, and no affected standard lane regresses by more than 5% while coverage remains stable.
- **SC-006**: The hidden-cascade audit is completed for the named high-usage aggregates, and each finding is classified as removed, made explicit, or intentionally retained with a documented reason.
- **SC-007**: Contributors have concise author guidance for choosing the cheapest valid fixture path, and reviewers can identify when a new test is asking for heavy integration context.

View File

@ -0,0 +1,239 @@
# Tasks: Shared Test Fixture Slimming
**Input**: Design documents from `/specs/207-shared-test-fixture-slimming/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md
**Tests**: Tests are REQUIRED for this feature because it changes shared test helpers, factory defaults, migration behavior, guard coverage, and lane-impact validation in a Laravel/Pest codebase.
**Operations / RBAC / UI surfaces**: Not applicable to product runtime. This feature is limited to repository test infrastructure, factories, contributor guidance, and Spec 206 lane-measurement reuse.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Create the missing regression-test surfaces that the fixture-slimming work will fill.
- [X] T001 Create the missing fixture-slimming test files in `apps/platform/tests/Feature/Guards/FixtureLaneImpactBudgetTest.php`, `apps/platform/tests/Unit/Factories/OperationRunFactoryTest.php`, `apps/platform/tests/Unit/Factories/BackupSetFactoryTest.php`, and `apps/platform/tests/Unit/Factories/ProviderCredentialFactoryTest.php`
- [X] T002 [P] Expand the shared regression scaffolding in `apps/platform/tests/Feature/Guards/FixtureCostProfilesGuardTest.php`, `apps/platform/tests/Unit/Support/CreateUserWithTenantProfilesTest.php`, `apps/platform/tests/Unit/Factories/TenantFactoryTest.php`, `apps/platform/tests/Unit/Factories/ProviderConnectionFactoryTest.php`, and `apps/platform/tests/Unit/Factories/ProviderCredentialFactoryTest.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Record the pre-migration measurement baseline, classify the hidden-cost surface, and establish the canonical fixture-profile vocabulary before story work begins.
**Critical**: No user story work should begin until this phase is complete.
- [X] T003 Capture the pre-migration fast-feedback and confidence lane baseline and reuse the Spec 206 comparison seams in `apps/platform/tests/Support/TestLaneManifest.php`, `apps/platform/tests/Support/TestLaneReport.php`, `apps/platform/tests/Feature/Guards/FixtureLaneImpactBudgetTest.php`, and `specs/207-shared-test-fixture-slimming/quickstart.md`
- [X] T004 [P] Inventory and classify the shared helper surface, high-usage caller packs, and hidden cascade findings in `specs/207-shared-test-fixture-slimming/data-model.md` and `specs/207-shared-test-fixture-slimming/quickstart.md`
- [X] T005 Implement the canonical `minimal`, `standard`, and `full` fixture profile catalog plus temporary legacy alias resolution in `apps/platform/tests/Pest.php`
**Checkpoint**: The shared support layer has a recorded pre-migration baseline, a classified audit surface, one canonical profile language, and one measurement path that all stories can build on.
---
## Phase 3: User Story 1 - Use A Minimal Fixture By Default (Priority: P1)
**Goal**: Make the default shared fixture path and default factory behavior create only the smallest valid context.
**Independent Test**: Run the helper and factory guards and verify that ordinary calls do not create provider, credential, cache, UI, or hidden workspace graph side effects.
### Tests for User Story 1
- [X] T006 [P] [US1] Add failing minimal-helper and user-side-effect coverage in `apps/platform/tests/Unit/Support/CreateUserWithTenantProfilesTest.php` and `apps/platform/tests/Feature/Guards/FixtureCostProfilesGuardTest.php`
- [X] T007 [P] [US1] Add failing lean tenant and workspace cascade coverage in `apps/platform/tests/Unit/Factories/TenantFactoryTest.php` and `apps/platform/tests/Unit/Factories/OperationRunFactoryTest.php`
### Implementation for User Story 1
- [X] T008 [US1] Make `minimal` the default shared fixture path and apply the already-defined `standard` side-effect contract in `apps/platform/tests/Pest.php`
- [X] T009 [US1] Remove implicit lean-workspace provisioning from `apps/platform/app/Models/Tenant.php` and `apps/platform/database/factories/TenantFactory.php`
- [X] T010 [US1] Add lean operation-context defaults in `apps/platform/database/factories/OperationRunFactory.php` and `apps/platform/database/factories/BackupSetFactory.php`
**Checkpoint**: The shared helper default and touched core factory defaults stay lean unless a test explicitly asks for more context.
---
## Phase 4: User Story 2 - Opt Into Heavy Context Deliberately (Priority: P1)
**Goal**: Preserve valid heavy integration setup, but require it to announce itself clearly through explicit profiles or states.
**Independent Test**: Run the helper and factory guards and verify that explicit provider, credential, backup, cache, and UI-context requests create the promised extra context without hiding behind the minimal default.
### Tests for User Story 2
- [X] T011 [P] [US2] Add failing explicit provider, credential, user-relationship, cache, and UI-context coverage in `apps/platform/tests/Unit/Support/CreateUserWithTenantProfilesTest.php` and `apps/platform/tests/Feature/Guards/FixtureCostProfilesGuardTest.php`
- [X] T012 [P] [US2] Add failing explicit heavy-state coverage for provider, provider-credential, and backup graphs in `apps/platform/tests/Unit/Factories/ProviderConnectionFactoryTest.php`, `apps/platform/tests/Unit/Factories/ProviderCredentialFactoryTest.php`, and `apps/platform/tests/Unit/Factories/BackupSetFactoryTest.php`
### Implementation for User Story 2
- [X] T013 [US2] Add explicit heavy, provider-enabled, credential-enabled, and `ui-context` helper entry points plus temporary transition aliases in `apps/platform/tests/Pest.php`
- [X] T014 [US2] Add explicit heavy factory states for provider, provider-credential, and backup graphs in `apps/platform/database/factories/ProviderConnectionFactory.php`, `apps/platform/database/factories/ProviderCredentialFactory.php`, and `apps/platform/database/factories/BackupSetFactory.php`
- [X] T015 [US2] Make heavy-by-intent builder setup announce itself in `apps/platform/tests/Feature/Concerns/BuildsBaselineCompareMatrixFixtures.php`, `apps/platform/tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php`, `apps/platform/tests/Feature/Concerns/BuildsPortfolioTriageFixtures.php`, and `apps/platform/tests/Feature/Concerns/BuildsOperatorExplanationFixtures.php`
**Checkpoint**: Heavy setup remains available, but the call site now makes it obvious when the test is asking for integration-heavy context.
---
## Phase 5: User Story 3 - Migrate High-Usage Callers Safely (Priority: P2)
**Goal**: Move the first high-usage expensive callers onto the lightest valid profiles without unnecessary broad breakage.
**Independent Test**: Run the migrated caller packs and verify that narrow tests adopt `minimal` or `standard` profiles while the legacy full-context path remains available, visibly heavier, and marked for removal where still needed.
### Tests for User Story 3
- [X] T016 [P] [US3] Add failing migration-pack regression coverage for console and navigation callers in `apps/platform/tests/Feature/Console/ReconcileOperationRunsCommandTest.php`, `apps/platform/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php`, and `apps/platform/tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php`
- [X] T017 [P] [US3] Add failing migration-pack regression coverage for RBAC and drift callers in `apps/platform/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php` and `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`
### Implementation for User Story 3
- [X] T018 [US3] Migrate the first console and navigation caller pack to `minimal` or `standard` profiles in `apps/platform/tests/Feature/Console/ReconcileOperationRunsCommandTest.php`, `apps/platform/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php`, and `apps/platform/tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php`
- [X] T019 [US3] Migrate the first RBAC and drift caller pack to `minimal` or `standard` profiles in `apps/platform/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php` and `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`
- [X] T020 [US3] Preserve and label the visible legacy full-context path and declare its removal trigger in `apps/platform/tests/Pest.php`, `README.md`, and `specs/207-shared-test-fixture-slimming/quickstart.md`
**Checkpoint**: The first high-usage packs in the standard lanes use cheaper profiles, and any remaining full-context path is explicit rather than hidden.
---
## Phase 6: User Story 4 - Catch Fixture Cost Regressions Early (Priority: P2)
**Goal**: Add guard coverage and lane validation so hidden fixture-cost regressions are caught before they spread.
**Independent Test**: Run the fixture guards plus the Spec 206 fast-feedback and confidence reports and verify that hidden cascades fail loudly and lane outcomes stay stable or improved.
### Tests for User Story 4
- [X] T021 [P] [US4] Add hidden-cascade audit coverage for the tenant boot hook, user-side effects in shared helper defaults, provider connection defaults, provider-credential defaults, and backup-set callbacks in `apps/platform/tests/Unit/Support/CreateUserWithTenantProfilesTest.php`, `apps/platform/tests/Unit/Factories/TenantFactoryTest.php`, `apps/platform/tests/Unit/Factories/ProviderConnectionFactoryTest.php`, `apps/platform/tests/Unit/Factories/ProviderCredentialFactoryTest.php`, and `apps/platform/tests/Unit/Factories/BackupSetFactoryTest.php`
- [X] T022 [P] [US4] Add lane-impact regression coverage against the Spec 206 budgets in `apps/platform/tests/Feature/Guards/FixtureLaneImpactBudgetTest.php` and `apps/platform/tests/Feature/Guards/TestLaneArtifactsContractTest.php`
### Implementation for User Story 4
- [X] T023 [US4] Record the cascade-audit dispositions and fixture cost signals in `README.md` and `apps/platform/tests/Pest.php`
- [X] T024 [US4] Wire the recorded pre- and post-migration lane comparison into `scripts/platform-test-report`, `apps/platform/tests/Support/TestLaneReport.php`, and `apps/platform/tests/Support/TestLaneManifest.php`
- [X] T025 [US4] Publish concise author guidance for `minimal` vs `standard` vs `full` fixture setup in `README.md` and `specs/207-shared-test-fixture-slimming/quickstart.md`
**Checkpoint**: Hidden-cost regressions are guarded, and the fixture changes are measurable against the existing lane budgets.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Reconcile the implementation with the design artifacts and validate the end-to-end workflow.
- [X] T026 [P] Reconcile the implemented fixture-profile catalog, temporary alias retirement data, and audit coverage with `specs/207-shared-test-fixture-slimming/contracts/shared-fixture-profile.schema.json` and `specs/207-shared-test-fixture-slimming/contracts/shared-test-fixture-slimming.logical.openapi.yaml`
- [X] T027 Run focused fixture and migration-pack validation with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CreateUserWithTenantProfilesTest.php`, `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Factories`, `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Console`, `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php`, `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php`, and `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`
- [X] T028 Run post-migration Spec 206 lane validation and compare against the recorded pre-migration baseline with `./scripts/platform-test-lane fast-feedback`, `./scripts/platform-test-lane confidence`, `./scripts/platform-test-report fast-feedback`, and `./scripts/platform-test-report confidence`
- [X] T029 Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies and 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 is independently testable, but it shares `apps/platform/tests/Pest.php` and the touched factory seams with US1.
- **User Story 3 (Phase 5)**: Depends on US1 and US2 because caller migration needs the new lean and heavy profile semantics to be stable first.
- **User Story 4 (Phase 6)**: Depends on US1, US2, and US3 because guard coverage and lane comparison need the implemented profile and migration behavior in place.
- **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 profile catalog and is independently testable once the helper and factory contracts exist.
- **User Story 3 (P2)**: Depends on US1 and US2 because migration packs need the new explicit profile behavior before callers can move safely.
- **User Story 4 (P2)**: Depends on US1, US2, and US3 because regression guards and lane comparisons must validate the final migrated behavior.
### Within Each User Story
- Tests MUST be written and fail before implementation.
- The pre-migration lane baseline must be recorded before helper or factory semantics change.
- Shared helper profile changes must land before touched caller files migrate to them.
- Lean tenant/workspace behavior must be fixed before operation or backup factories are treated as slimmed.
- Explicit heavy profiles and states must exist before legacy heavy callers are redirected to them.
- Lane-impact comparison must use the existing Spec 206 seams rather than a new one-off harness.
### Parallel Opportunities
- T002 can run in parallel with T001 after the new test files are created.
- T003 and T004 can run in parallel before T005 codifies the canonical profile vocabulary.
- US1 test tasks T006 and T007 can run in parallel.
- US2 test tasks T011 and T012 can run in parallel.
- US3 test tasks T016 and T017 can run in parallel.
- US4 test tasks T021 and T022 can run in parallel.
- After US1 and US2 are complete, one developer can migrate callers while another adds the lane-impact guard and reporting work.
---
## Parallel Example: User Story 1
```bash
# Run the lean-default tests in parallel:
Task: "Add failing minimal-helper side-effect coverage in apps/platform/tests/Unit/Support/CreateUserWithTenantProfilesTest.php and apps/platform/tests/Feature/Guards/FixtureCostProfilesGuardTest.php"
Task: "Add failing lean tenant and workspace cascade coverage in apps/platform/tests/Unit/Factories/TenantFactoryTest.php and apps/platform/tests/Unit/Factories/OperationRunFactoryTest.php"
```
---
## Parallel Example: User Story 2
```bash
# Run the explicit-heavy tests in parallel:
Task: "Add failing explicit provider, credential, cache, and UI-context coverage in apps/platform/tests/Unit/Support/CreateUserWithTenantProfilesTest.php and apps/platform/tests/Feature/Guards/FixtureCostProfilesGuardTest.php"
Task: "Add failing explicit heavy-state coverage for provider and backup graphs in apps/platform/tests/Unit/Factories/ProviderConnectionFactoryTest.php and apps/platform/tests/Unit/Factories/BackupSetFactoryTest.php"
```
---
## Parallel Example: User Story 3
```bash
# Run the migration-pack regressions in parallel:
Task: "Add failing migration-pack regression coverage for console and navigation callers in apps/platform/tests/Feature/Console/ReconcileOperationRunsCommandTest.php, apps/platform/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php, and apps/platform/tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php"
Task: "Add failing migration-pack regression coverage for RBAC and drift callers in apps/platform/tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php and apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php"
```
---
## Parallel Example: User Story 4
```bash
# Run the regression guards in parallel:
Task: "Add hidden-cascade audit coverage for the tenant boot hook, provider connection defaults, and backup-set callbacks in apps/platform/tests/Unit/Factories/TenantFactoryTest.php, apps/platform/tests/Unit/Factories/ProviderConnectionFactoryTest.php, and apps/platform/tests/Unit/Factories/BackupSetFactoryTest.php"
Task: "Add lane-impact regression coverage against the Spec 206 budgets in apps/platform/tests/Feature/Guards/FixtureLaneImpactBudgetTest.php and apps/platform/tests/Feature/Guards/TestLaneArtifactsContractTest.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. Stop and validate that the default shared helper and touched default factories stay lean.
### Incremental Delivery
1. Record the pre-migration baseline, classify the audit surface, and land the canonical fixture profile vocabulary.
2. Ship the minimal-default behavior in the shared helper and core factories.
3. Add explicit heavy opt-in behavior so legitimate integration tests remain readable.
4. Migrate the first high-usage caller packs in the standard lanes.
5. Finish with guard coverage, lane validation, and contributor guidance.
### Parallel Team Strategy
1. One developer establishes the canonical profile catalog and shared comparison seams.
2. A second developer can own the minimal-default and explicit-heavy factory work once the profile catalog exists.
3. A third developer can migrate the first caller packs after the new profile behavior is stable.
4. A final pass adds regression guards, lane comparisons, and documentation updates.
---
## Notes
- `[P]` tasks touch different files and have no unresolved dependency on incomplete work.
- US1 is the recommended MVP because it restores the cheap default path first.
- US2 preserves legitimate heavy integration coverage without hiding its cost.
- US3 converts the highest-usage expensive callers into evidence-backed migration packs.
- US4 makes the new fixture discipline durable through guards and lane-budget validation.