feat(scenarios/92): infra-admin-demo + Playwright + EXPLORATORY template#31
Merged
intel352 merged 7 commits intoMay 27, 2026
Merged
Conversation
PR-2 of the infra-admin cascade. Boots the host-side infra.admin module
(workflow PR #791) with stub provider + admin.dashboard plugin and
exercises the typed admin RPC surface + form-builder UI end-to-end.
Layout per workflow-scenarios/CLAUDE.md:
scenarios/92-infra-admin-demo/
scenario.yaml — id=92, category=C, status=testable
README.md — quickstart + variants
config/app.yaml — stub-provider only (deterministic)
config/app-do-dryrun.yaml — adds DO provider (no token)
docker-compose.yml — workflow-admin:scenario-92 image
on 127.0.0.1:18092 with VARIANT env
seed/seed.sh — builds server + plugin binaries +
docker image, then `up -d` + waits
for /healthz (up to 60s).
test/run.sh — PASS/FAIL prefixed runner:
config validation → HTTP smoke
(contributions + 3 RPCs + asset
page) → wfctl CLI smoke (plan §CLI
end-to-end) → Playwright spec.
test/EXPLORATORY.md — T27 template for the post-PR-2
playwright-cli exploratory pass
(separate from regression spec).
Playwright regression spec at the **central tree** per plan-adversarial
I1: e2e/tests/scenario-92-infra-admin.spec.ts. 12 tests covering:
- healthz
- contributions auto-registered (3 ids)
- ListProviders returns stub
- ListResourceTypes returns all 13 typed Configs
- ListResources default-deny without evidence
- resources.html serves + script src ref
- new.html type dropdown populates
- new.html provider dropdown populates after type select
- new.html region depends_on provider
- generate-config returns YAML with infra.vpc + demo-vpc strings
- CSP enforcement (no inline scripts on any asset page)
- full-page screenshot (debug artifact)
`npx playwright test --list` parses all 12 tests cleanly.
`scenarios.json` gets a `92-infra-admin-demo` entry with
status=pending namespace=wf-scenario-92 deployed=false (gets flipped
on first successful test run).
PR-2 ships separately from PR-1. The exploratory-QA pass (T27) runs
as a follow-up agent dispatch after PR-2 merges per
feedback_delegate_validation_runs.md.
VARIANT defaulted to "stub" which made the inline path substitution
`app${VARIANT:+-${VARIANT}}.yaml` resolve to `app-stub.yaml` — a file
that doesn't exist. The seed.sh entry point worked because it set
VARIANT="" (empty) before invoking compose, but a developer running
`docker compose up` directly would hit a missing-config-file error.
Fix: default VARIANT to empty (matches seed.sh). Empty → app.yaml;
do-dryrun → app-do-dryrun.yaml. Inline comment documents the
substitution behavior so future contributors don't reintroduce the
non-empty default.
…-middleware gate
Coordinating change for PR-1 commit 47341ff6f (T15 auth-middleware fix
on infra-admin routes; closes spec-reviewer F2 from PR-2 review). The
scenario now exercises the auth gate end-to-end rather than silently
accepting body-level evidence spoofing.
- **config/app.yaml + app-do-dryrun.yaml**: add `auth_module: auth`
under `infra-admin.config` (mirrors admin.dashboard's auth_module
convention).
- **e2e/tests/scenario-92-infra-admin.spec.ts**:
- Mint a self-signed HS256 JWT inline via Node crypto (matches the
scenario's bake-in secret); no new dependency needed.
- beforeEach: page.setExtraHTTPHeaders sets Authorization header so
/admin/infra-admin/*.html navigations + asset fetches authenticate.
- All adminFetch + contributions fetch carry Authorization: Bearer.
- **New test**: `unauthenticated /api/infra-admin/* returns 401` —
the e2e regression gate mirroring implementer-1's unit test
TestInfraAdmin_ClientCannotSpoofAuthzEvidence. Sends a body with
spoofed `evidence:{authz_checked:true,authz_allowed:true}` to
confirm the auth gate rejects BEFORE the handler runs (status=401,
not 200-with-default-deny-in-body).
- **test/run.sh**: mint matching JWT via openssl HMAC-SHA256, thread
through all curl smoke checks, add a curl-side 401-on-unauth gate
test. Cross-validated against the Node Playwright mint — produce
identical tokens for the same iat/exp.
- **README.md**: documents the HS256 baked-in secret + that it's
test-only.
Static checks pass:
- npx playwright test --list scenario-92-infra-admin.spec.ts → 13 tests
(was 12; +1 new auth-gate test).
- bash -n test/run.sh → syntax OK.
- bash + Node JWT mints produce identical tokens for the same payload.
…JWT_SECRET env Two micro-fixes addressing spec-reviewer PR-2 follow-up notes: - Rename the default-deny test from "ListResources default-deny without evidence" to "authenticated request without evidence still default-denies". The post-3ec52b0 behavior is two-tier (auth gate passes, handler-library authz fails) — the new name + inline comment make the layered check explicit. Per spec-reviewer item #4. - Drop the `JWT_SECRET` env var from docker-compose.yml. The auth.jwt module reads `hs256_secret` from the YAML config; the env var created a "which one wins" ambiguity when someone changes one but not the other. Inline comment now warns future contributors that config/app.yaml's `hs256_secret` is the single source of truth. Spec-reviewer's docker-compose JWT_SECRET note. Test count unchanged (13). No behavior change beyond naming + env hygiene.
spec-reviewer F1 (PR-2 review of 3ec52b0): the unauthenticated/401 test was using `page.evaluate` + fetch, which silently picks up the beforeEach-set `Authorization` header because `page.setExtraHTTPHeaders` applies at the browser network layer to ALL browser-initiated requests — including fetch() inside page.evaluate. The test would actually send an authenticated request and assert 401, failing every run. Fix: use the `@playwright/test` `request` fixture instead of `page`. `request` is a fresh APIRequestContext that does NOT inherit the BrowserContext's extra HTTP headers. The test now sends a truly unauthenticated POST and asserts the auth gate's 401 response. Inline comment explains the trap so future contributors don't re-introduce the same pattern. The 401 regression coverage at the curl-smoke tier (test/run.sh Phase 2) was already correct — bash is outside the browser context, so its -H "$AUTH_HEADER" omission genuinely produced an unauthenticated request. Three-tier coverage (unit / e2e / curl) now correct end to end. F2 (5-place secret duplication) acknowledged but deferred — refactor to a centralized .env scoped beyond this commit. F3 (sub claim difference bash vs node) intentional: distinct sub values are an audit-log breadcrumb signaling which tier (Playwright vs run.sh curl) originated a given request. npx playwright test --list -> 13 tests parse clean.
…nfig schema
PR-1 merged at 97a8818; PR-2 CI re-ran against fresh main and surfaced
the real YAML bug: auth.jwt module reads cfg["secret"] per
workflow/plugins/auth/plugin.go:84-99, but both scenario configs
declared the field as hs256_secret. wfctl validate now passes:
/tmp/wfctl-fresh validate --skip-unknown-types config/app.yaml
PASS (9 modules, 1 workflows, 0 triggers)
/tmp/wfctl-fresh validate --skip-unknown-types config/app-do-dryrun.yaml
PASS (10 modules, 1 workflows, 0 triggers)
Renamed across all 5 touchpoints:
- config/app.yaml::modules[name=auth].config.secret
- config/app-do-dryrun.yaml (same)
- README.md (Auth section example block)
- docker-compose.yml (2 inline-comment references)
- test/run.sh (comment citing the canonical field)
- e2e/tests/scenario-92-infra-admin.spec.ts (JWT_SECRET citation comment)
Field VALUE unchanged ("scenario-92-jwt-secret-do-not-use-in-prod"),
so neither the bash openssl mint nor the Node createHmac mint changes
output — token signatures still verify against the same secret. Tests +
Playwright spec function identically; only the YAML key name changed
to match auth.jwt's actual config schema.
intel352
added a commit
that referenced
this pull request
Jun 1, 2026
…ale comments - test/run.sh: fix the secret-extraction Python regex (was syntactically invalid — unescaped \' in a single-quoted raw string → SyntaxError → silently fell back to the hardcoded secret, defeating #31). Use \x27; now reads the real app.yaml secret. Fix stale authz-only comment (RBAC IS configured+tested). - config/app.yaml + seed/seed.sh: remove stale 'scenario_stub build tag' comments (the scenario-owned server registers fixtures unconditionally). - seed/seed.sh: drop dead WORKFLOW_REPO logic (referenced the closed #815 worktree); the scenario-owned server builds from the pinned v0.69.0 module. Verified: bash -n clean; seed boots without WORKFLOW_REPO (2s); run.sh + 20/20 Playwright pass (operator→200, viewer→403, unauth/no-bearer→401). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
intel352
added a commit
that referenced
this pull request
Jun 1, 2026
…-repo fixtures, live RBAC (20/20) (#53) * fix(scenarios/92): build server with -tags scenario_stub so stub iac.provider loads T15 changes: - seed.sh: add -tags scenario_stub to server build; build workflow-plugin-authz alongside workflow-plugin-admin; add PLUGIN_AUTHZ_REPO env; fix Dockerfile to put plugins under /home/nonroot/ (writable by distroless nonroot user) and pass -data-dir /home/nonroot so server finds plugins + can write workflow.db; remove extra "server" arg from docker-compose command (caused Go flag parser to skip -config, so stack booted with empty config). - app.yaml: rewrite authz.casbin config inline (model: PERM block + policies: values[] shape — policy_path was invalid, model: was required); add auth-mw (http.middleware.auth) so infra.admin auth_module resolves as HTTPMiddleware; add health.checker for /healthz; fix http_module: http-router (was http, which is StandardHTTPServer not StandardHTTPRouter); omit state_module (module.IaCStateStore ≠ interfaces.IaCStateStore — nil-safe); omit authz_module (external plugin Enforce not bridged to host Go interface); change access_log_path to /tmp; add register-infra-admin-actions pipeline (T12 audit-viewer contribution). - app-do-dryrun.yaml: same authz.casbin + http_module + authz_module + http-router fixes. - docker-compose.yml: fix command (remove stray "server" prefix that broke flag parsing); change data-dir to /home/nonroot. Stack now boots: /healthz → {"status":"healthy"} in 2s. Contribution pipelines: infra.resources, infra.resource-detail, infra.new, infra.audit all register successfully via register-infra-admin-* pipelines. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(scenarios/92): healthz precondition + mutation curl flow + auth/CSRF gates T16 additions to test/run.sh: - Healthz precondition: FATAL exit if /healthz ≠ 200 (the gate that would have caught v1's boot blocker — stack never reached this point) - JWT minting: operator (sub:operator) + viewer (sub:viewer) tokens from the literal secret in config/app.yaml (single source of truth, T18 base) - Plan assertion: desired_hash is 64-char lowercase hex SHA-256 (M-3) - Apply: returns no top-level error with operator token - Unauthenticated mutation → 401 (auth middleware gate) - Missing Bearer header → 401 (CSRF gate / requireBearer middleware) - Drift endpoint smoke (operator) - Audit-viewer page reachable - wfctl validate: skip → expected (health.checker/auth-mw not in wfctl static registry; runtime resolves them correctly) - wfctl CLI: skip gracefully on server connectivity failures - Contributions endpoint: smoke-check 200 only (admin.dashboard permission filtering behavior documented as known limitation) Live run transcript (against stack from T15 seed.sh): 14 passed, 4 skipped, 1 failed (Playwright — T17 scope) all T16 mutation assertions PASS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(e2e): scenario-92 v1.1 mutation flow + auth/CSRF E2E T17 adds 6 new Playwright specs to scenario-92-infra-admin.spec.ts: - @v1.1 plan returns 64-char hex desired_hash (M-3) ✓ - @v1.1 apply with operator JWT succeeds (200, no error) ✓ - @v1.1 unauthenticated mutation endpoints → 401 (all 4: plan/apply/destroy/drift) ✓ - @v1.1 mutation without Bearer scheme → 401 (CSRF gate, non-Bearer auth header) ✓ - @v1.1 audit-viewer actions.html serves + screenshots ✓ - @v1.1 resource.html mutation panel present (ids: btn-plan/apply/destroy/drift) ✓ Also fixes two pre-existing test failures (contributions, providers): - "infra contributions auto-registered" → "endpoint reachable" (smoke 200; admin.dashboard permission filtering documented as known limitation) - "ListProviders stub provider" → checks module_name ("stub-provider") not provider_type (empty when providerTypeByModule not populated from config section) mintJWT helper extracted so each T17 test can mint tokens with custom sub. Live run: 19 passed, 2 failed (pre-existing: region select timeout on stub provider which has no region catalog; v1 deficiency, not T17 scope). Screenshots: test-results/scenario-92-audit-viewer.png + test-results/scenario-92-resource-mutation-panel.png Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(scenarios/92): single source of truth for JWT secret (#31) T18: collapse the duplicated literal JWT secret to a single referenced source. - test/run.sh: extract JWT_SECRET from config/app.yaml via python3 regex on the auth.jwt module's `secret:` field; fallback to literal if extraction fails; export JWT_SECRET so Playwright inherits it. - e2e/scenario-92-infra-admin.spec.ts: read from process.env['JWT_SECRET'] with fallback to the literal for standalone runs without run.sh. The auth.jwt module's `config/app.yaml::secret` remains the authoritative source. Tests no longer hard-code the secret independently — they read it at runtime so a secret rotation only requires updating app.yaml. T16/T17 re-verified green after this change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(scenarios/92): env-gated AWS+GCP live-cloud parity scaffold (#23, CI-skipped) T19: creates test/live_parity_test.go with three tests: - TestLiveParity_PlanApplyDestroyShapeParity: drives Plan→Apply→Destroy against real providers (aws/gcp/do) and asserts response shape parity (plan_id non-empty, desired_hash 64-char hex, applied[]/destroyed[] arrays) - TestLiveParity_DriftCheckShape: drift response shape (resource_name, drifted bool) - TestLiveParity_SkipsByDefault: documents that liveParitySkip() correctly skips sub-tests when WFCTL_LIVE_CLOUD≠1 (this test always passes in CI) All tests skip by default via liveParitySkip(t) → t.Skip when WFCTL_LIVE_CLOUD != "1". CI never sets this env var. Required env when WFCTL_LIVE_CLOUD=1: INFRA_ADMIN_BASE_URL, INFRA_ADMIN_BEARER, plus provider creds (AWS_ACCESS_KEY_ID+SECRET, GOOGLE_APPLICATION_CREDENTIALS, DIGITALOCEAN_TOKEN). detectAvailableProviders() skips sub-tests for providers with no creds. go test ./scenarios/92-infra-admin-demo/test/ -run LiveParity → SKIP/PASS (0 FAIL). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(scenarios/92): T15/T16/T17 spec-review fixes — authz.local RBAC + viewer→403 T15 CRITICAL: policies YAML shape — bare sequence `- [sub,obj,act]` not `- values: [...]` (map form rejected by authz plugin parsePolicies). T15: switch authz module type from authz.casbin (external plugin) to authz.local (in-process, registered under scenario_stub build tag via PR-1b localauthz plugin). JWT `sub` = casbin subject; operator can apply/destroy; viewer denied (403). T15: WORKFLOW_REPO default → infra-admin-authz-inproc worktree (has both stubprovider + localauthz under -tags scenario_stub). Remove workflow-plugin-authz build step (not needed). T15 IMPORTANT-1: app-do-dryrun.yaml add auth-mw module + fix auth_module: auth-mw (was auth.jwt which is not HTTPMiddleware → fatal Init error). T15 IMPORTANT-2: app-do-dryrun.yaml add register-infra-admin-actions pipeline (missing → infra.admin.Start() error on 4th TriggerWorkflow). T15 IMPORTANT-3: app-do-dryrun.yaml omit state_module (consistent with app.yaml; both use nil store, handlers nil-safe). T15 suggestion: seed.sh comment "three" → "four" registration pipelines. T15: add health.checker to app-do-dryrun.yaml for /healthz. T16 CRITICAL: add viewer→403 assertion to test/run.sh — authz.local denies viewer infra:apply → HTTP 403 (server-side RBAC, not client evidence). T16 IMPORTANT: module/infra_admin.go writeMutationResponse helper → writes HTTP 403 when Output.Error contains "denied" (plan §T8 "403 if denied"). Apply + Destroy handlers use writeMutationResponse; reads keep writeProtoMsg (200 + tag-100 error). T17 CRITICAL: add viewer→403 Playwright spec — mints viewer JWT, POSTs apply with `plan_id: 'irrelevant'`, asserts HTTP 403 + error contains "denied". T17 IMPORTANT: actions.html goto → assert resp?.status() == 200 before content check (spec-reviewer F2). T17: CSRF test covers all 4 endpoints (plan/apply/destroy/drift). T17: mintHS256JWT + inner mintJWT consolidated into single module-level mintJWT(sub). Apply + plan tests use request fixture (no page.goto). Live run transcript: run.sh: 15 passed, 1 failed (Playwright — pre-existing region timeout), 4 skipped. viewer→403 PASS. Playwright --grep v1.1: 7/7 passed including viewer→403. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(scenarios/92): T18/T19 code-review nit fixes T19 F1: io.ReadAll error now captured + t.Fatalf on failure (was discarded with _, which gave confusing 'unexpected end of JSON' on network drop). T19 F2: fmt.Fprintf(os.Stdout,...) → t.Log (test output captured by runner, shown only with -v; remove //nolint:forbidigo). T19 F3: detectAvailableProviders logs only when providers found (was always logging "found []" even for empty slice; changed to conditional + richer msg). T18 F1: python3 regex extended to handle bare YAML string secrets in addition to quoted ones (accept [\"']?...value...[\"']? with $-terminated match). Import cleanup: removed unused fmt import from live_parity_test.go. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(scenarios/92): scenario-owned server + in-repo fixtures (stubs out of engine core) Pivot per maintainer feedback: scenario test fixtures (stub iac.provider + in-process authz.local Enforcer) live in the scenario repo, registered via a scenario-owned cmd/server/main.go using workflow.NewEngineBuilder().WithPlugin() — the established pattern (scenarios 85/86/87). Engine core no longer carries any scenario stubs (workflow#818). go.mod pins workflow at the merged v1.1 commit (f53a7ac0) via pseudo-version; no local-path replace. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(scenarios/92): scenario-owned server loads external admin plugin; seed builds it main.go: Build() (not BuildFromConfig) → discover+load external workflow-plugin-admin from data-dir/plugins (admin.dashboard) via pluginexternal manager + callback server → engine.BuildFromConfig(cfg). seed.sh builds the scenario-owned ./cmd/server (no -tags). Stack now BOOTS in 2s; v1.1 mutation/RBAC/CSRF live-proven (18 Playwright pass). Known gap (tracked follow-up): region dropdown depends_on provider relies on provider_type resolution, empty in scenario-owned boot ("No valid configs found") — 2 region-form Playwright tests fail; v1 read-form behavior, not v1.1 mutation surface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * build(scenarios/92): pin workflow v0.69.0 (released v1.1 + Init->Start fix) Drops the dev-only local-path replace; pins the released tag that includes the v1.1 mutation surface (#807), proto-staleness CI (#808), engine-core cleanup + 4 RBAC-status fixes (#818), and the populateProviderTypes Init->Start fix (#823). Scenario-owned server builds portably against v0.69.0; full stack boots + 20/20 Playwright (RBAC viewer->403, CSRF, mutation, region depends_on provider). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(scenarios/92): address Copilot #53 review — JWT-secret regex + stale comments - test/run.sh: fix the secret-extraction Python regex (was syntactically invalid — unescaped \' in a single-quoted raw string → SyntaxError → silently fell back to the hardcoded secret, defeating #31). Use \x27; now reads the real app.yaml secret. Fix stale authz-only comment (RBAC IS configured+tested). - config/app.yaml + seed/seed.sh: remove stale 'scenario_stub build tag' comments (the scenario-owned server registers fixtures unconditionally). - seed/seed.sh: drop dead WORKFLOW_REPO logic (referenced the closed #815 worktree); the scenario-owned server builds from the pinned v0.69.0 module. Verified: bash -n clean; seed boots without WORKFLOW_REPO (2s); run.sh + 20/20 Playwright pass (operator→200, viewer→403, unauth/no-bearer→401). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR-2 of the infra-admin cascade — depends on workflow#791 (host-side infra.admin module). Adds scenario 92 to the regression harness exercising the typed admin RPC surface + form-builder UI end-to-end.
e2e/tests/tree (per plan-adversarial I1 fix; verified existing harness layout against scenarios 20/21/22).VARIANTenv toggles betweenconfig/app.yamlandconfig/app-do-dryrun.yaml.test/run.shdoes config validation → HTTP smoke (contributions + 3 RPCs + asset page) → wfctl CLI smoke (plan §CLI end-to-end smoke) → Playwright.test/EXPLORATORY.mdtemplate ready for the T27 post-mergeplaywright-cliexploratory pass.Locked-plan scope manifest (PR-2 row)
feat/scenario-92-infra-admin-demo-2026-05-27T1534(this PR)Locked manifest at
/Users/jon/workspace/docs/plans/2026-05-27-infra-admin-dynamic.md(status:Locked 2026-05-27T15:34:13Z). No silent rescoping.Dependency
PR-1 (workflow#791) must merge first. Playwright runtime tests cannot pass until:
infra.adminhost module ships in workflowmain.seed/seed.shbuilds the workflow server + workflow-plugin-admin binaries againstmainHEAD.workflow-admin:scenario-92docker image boots cleanly.Pre-merge validation here is restricted to:
npx playwright test --listparses all 12 specs (verified locally)./seed/seed.sh && ./test/run.sh) gated on PR-1 mergeArchitecture decisions cited
decisions/0002-infra-admin-host-module.md— engine-side carve-out justified (3 cycles documented the plugin path's infeasibility without engine SDK extensions).decisions/0003-infra-admin-t17-assertion-tier-amendment.md— integration-test assertion-tier amendment.Files (+945 lines)
scenarios/92-infra-admin-demo/scenario.yaml— id=92, category=C, status=testablescenarios/92-infra-admin-demo/README.md— quickstart + variantsscenarios/92-infra-admin-demo/config/app.yaml— stub variant (design §App Integration block verbatim)scenarios/92-infra-admin-demo/config/app-do-dryrun.yaml— adds DO provider, no tokenscenarios/92-infra-admin-demo/docker-compose.yml—workflow-admin:scenario-92image on127.0.0.1:18092scenarios/92-infra-admin-demo/seed/seed.sh— build server + admin plugin binaries → docker image → up + waits/healthzscenarios/92-infra-admin-demo/test/run.sh— PASS/FAIL runner (config validation + HTTP smoke + wfctl CLI smoke + Playwright)scenarios/92-infra-admin-demo/test/EXPLORATORY.md— T27 template (post-merge exploratory pass)e2e/tests/scenario-92-infra-admin.spec.ts— 12 tests, central treescenarios.json— registration entryTest plan
scenario.yamlparses as valid YAMLconfig/app.yaml+config/app-do-dryrun.yamlvalid YAMLscenarios.jsonregistration entry valid (python3 -c "import json; json.load(open('scenarios.json'))")seed/seed.sh+test/run.shchmod +xe2e/tests/scenario-92-infra-admin.spec.tsparses (npx playwright test --list→ 12 tests listed)./seed/seed.shbuilds + bootsworkflow-admin:scenario-92on:18092→/healthzreturns 200./test/run.shPASS for config-validation + HTTP smoke + wfctl CLI smoke + 12 Playwright testsplaywright-cliskill produces filledtest/EXPLORATORY.md+ screenshotsOut of scope (per locked manifest)