diff --git a/.gitignore b/.gitignore index 9cb0b1d..58390af 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,8 @@ Thumbs.db # Test coverage coverage/ +test-results/ +playwright-report/ # SQLite database files backend/data/*.db diff --git a/BASELINE.md b/BASELINE.md index 7bcf7ff..beb23c4 100644 --- a/BASELINE.md +++ b/BASELINE.md @@ -342,4 +342,16 @@ Manual Tests 1–5 subsequently passed. Test 6A exposed that JSON Editor lacked The final manual continuation passed Test 6A and Test 6B; the user confirmed all Slice 5 manual tests pass. Cleanup removed seven execution records, disposable project `#14`, the mock container, and its exact internal-origin exception. Standard Compose was restored with backend health, frontend HTTP 200, empty execution history/secrets/configuration, and project `#14` absent. Explicit final Slice 5 sign-off remains the only gate. +## Slice 6 Automated Release Baseline — 2026-08-05 + +- Supported Node 20/npm 10 clean-source installs passed for root, frontend, and backend. The backend native dependency must be installed with the documented wrapper; system Node 18/npm produced an incompatible native build and is outside the supported baseline. +- Backend build and 27 tests passed. The new HTTP integration suite uses isolated ports/data and covers health, schema/semantic validation, CRUD atomicity, canonical persistence, five auth modes, proxy success/upstream/policy results, redaction/history, secrets, backend restart, and offline database backup/restore. +- Frontend standalone TypeScript, 25 suites / 517 tests, and production build passed. +- Schema matrix passed at 14 valid / 2 expected-invalid / 2 diagnostic-invalid. The deterministic release demo also passed backend semantic validation without warnings. +- Playwright Chromium passed 1/1 against rebuilt Docker and the controlled repository mock server: dependent options, runtime request values, focused/full results, inventory refresh, selected-row details, save, reload, and rerun readiness. +- Docker restart preserved a disposable demo project; cleanup deleted it and cleared history. Standard Compose was restored with backend health, frontend HTTP 200, empty executions/secrets, and empty secret/internal-origin configuration. +- Tracked-source credential-pattern scan found no private-key or common token signatures. Backend production dependency audit reports zero vulnerabilities after non-breaking lockfile fixes. +- Frontend Create React App transitive build/development advisories remain a documented Slice 8 packaging/toolchain risk. +- Remaining gate: proportional manual acceptance in `SLICE6_MANUAL_TEST.md`, final cleanup evidence, and explicit user sign-off. + The user granted explicit final Slice 5 sign-off on 2026-08-04 with the wording: “Confirm slice5 signoff.” Slice 5 is complete. diff --git a/CODEX.md b/CODEX.md index 3ae073d..2d6cf4f 100644 --- a/CODEX.md +++ b/CODEX.md @@ -312,3 +312,17 @@ Date: 2026-07-20 - Guided secret lifecycle UI, compatible action selection, reference-aware deletion, and durable encryption tests are implemented. Backend tests/build, full frontend coverage at 21 suites / 507 tests, focused authoring at 2 suites / 32 tests, and the frontend production build pass. - Durable injection/failure/redaction/URL-sanitization tests, persistent-key Docker restart execution, consolidated 22-suite / 509-test frontend validation, backend tests/build, frontend production build, and the 13 / 2 / 2 schema matrix pass. - Proportional Slice 3 manual acceptance passed on 2026-08-01 using an unsaved browser copy of project `#12`. Masked lifecycle, metadata-only records, compatible selection, opaque canonical references, protected execution/redaction, deletion safety, cleanup, and missing-reference failure behavior passed. The final secret list was empty and the saved project was unchanged. A non-blocking stale blocked-deletion warning is tracked in `ROADMAP.md`. The user granted explicit final Slice 3 sign-off on 2026-08-02; Slice 3 is complete. + +### Slice 6 Automated Release Validation Addendum + +- On 2026-08-05, Slice 6 automation was completed through the proportional manual gate. +- Added release-layer documentation, automated schema matrix, deterministic mock endpoints/demo project, backend HTTP integration coverage, frontend workflow integration coverage, and Playwright Docker E2E. +- The backend integration suite uses an isolated data directory and proves health, validation, CRUD atomicity, canonical round trips, all five authentication modes, proxy outcomes, redaction, history, secrets, process restart, and offline SQLite backup/restore. +- A release workflow gap was found and fixed: canonical `components..selectedRow` bindings with trigger `onChange` now update supported display targets in ephemeral Preview state. Validation, diagnostics, demo, unit tests, and E2E cover it. +- Standalone frontend TypeScript now passes after pinning compatible Node 20 types. +- Final counts: backend 27/27 tests; frontend 25 suites / 517 tests; frontend TypeScript and production build; schema matrix 14 valid / 2 expected-invalid / 2 diagnostic-invalid; Playwright 1/1. +- Clean-source install/build/test validation passes with the documented Node 20/npm 10 wrapper. A system Node 18/npm invocation was proven unsafe for the native SQLite build and is not supported. +- Backend production dependencies audit clean. Create React App retains high transitive build/development advisories with no supported non-breaking fix; record this as a Slice 8 packaging/toolchain release risk. +- Standard Compose is restored healthy with empty history/secrets/configuration after automated cleanup. No commit or push occurred. +- On 2026-08-07, the user reported all six manual workflows pass. Stored-history and backend-log marker scans passed, the expected redaction and `SECRET_NOT_FOUND` evidence was present, and cleanup restored healthy standard Compose with empty executions, secrets, secret-key configuration, and internal-origin configuration. +- The Actions & Bindings information-density feedback is tracked in `SLICE6a.md` as a separate UX follow-up. The user granted explicit final Slice 6 sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6 sign-off.” Slice 6 is complete. diff --git a/MANUAL_TEST_REQUEST.md b/MANUAL_TEST_REQUEST.md deleted file mode 100644 index 5273ac9..0000000 --- a/MANUAL_TEST_REQUEST.md +++ /dev/null @@ -1,70 +0,0 @@ -# Slice 2a Manual Test Request - -## Current status - -Manual Step 1, contextual suggestions, has already passed and does not need to be repeated unless a regression is suspected. - -Complete the four remaining tests in order. Perform one step at a time and record the result before continuing. - -## Step 2 — Ambiguous duplicate component names - -1. In **Visual Editor**, ensure one Text Input is named `hostname`. -2. Add a second Text Input and also name it `hostname`. -3. Return to **Actions & Bindings** and edit the REST action. -4. In a supported request value field, replace the value with exactly `{{`. -5. Confirm `Component: hostname` is absent from the contextual suggestion panel. -6. Open **Value source** under **Request value reference** and confirm `Component: hostname` is absent there too. -7. Confirm a valid declared variable such as `Variable: environment` remains offered. -8. Rename the second component to `hostnameSecondary`. -9. Return to the action, type `{{` again, and confirm both unique components are offered. - -### Expected result - -The ambiguous `hostname` component name is withheld from both contextual suggestions and the guided source list. The declared variable remains available. After the duplicate is renamed, both uniquely named components are offered again. - -## Step 3 — Common and custom header names - -1. Edit a REST action and open **Request parameters**. -2. Under **Headers**, click **+ Add row**. -3. Focus the header-name field or begin typing. -4. Confirm `Accept` and `Content-Type` are offered by the header-name suggestions. -5. Enter the custom name `X-Custom-Vendor-Header` instead. -6. Enter any value, such as `test`. -7. Inspect canonical JSON and confirm the header is stored exactly as entered. - -### Expected result - -Common header names including `Accept` and `Content-Type` are suggested, arbitrary custom header names remain accepted, and the canonical header object retains its existing shape with `X-Custom-Vendor-Header` stored exactly as entered. - -## Step 4 — Guided-control order and alignment - -1. Edit a REST action containing a query parameter such as `environment`. -2. Locate **Request value reference**. -3. Confirm the visible order is **Request destination**, **Value source**, **Insert reference**. -4. In **Request destination**, select `Query: environment`. -5. In **Value source**, select `Variable: environment` or a unique component. -6. Confirm the destination control aligns with the request-key side of the query row and the value-source control aligns with the request-value side at the current viewport. -7. Click **Insert reference** and confirm the expected canonical template appears in the query value. -8. Narrow the browser window to a supported small width and confirm the controls stack clearly without overlap. - -### Expected result - -The controls appear in the required order, their alignment clearly represents request key and value at the normal viewport, and they stack without overlap at a supported small width. Inserting the selected source produces the expected canonical template in the query value. - -## Step 5 — Existing behavior regression - -1. Use guided insertion into an existing header value and confirm the old value is replaced. -2. Use guided insertion into an existing query value and confirm the old value is replaced. -3. Use guided insertion into an Endpoint URL and confirm the template is appended. -4. Use guided insertion into a non-empty body template and confirm the template is appended. -5. Confirm path parameters are not offered as guided destinations and do not show contextual template suggestions. -6. After any insertion, type additional free-form text and confirm editing remains possible. -7. Inspect canonical JSON and confirm no `inputMap`, `action.responseMapping`, runtime values, responses, loading flags, or errors were added. - -### Expected result - -Guided insertion replaces existing header and query values, while it appends to Endpoint URL and body-template values. Path parameters remain excluded from guided and contextual insertion. Fields remain freely editable, and canonical JSON contains none of the prohibited mapping or runtime-state properties. - -## Completion gate - -If a step fails, record the exact step and observed behavior before making any changes. If Steps 2–5 pass, reconcile the Slice 2a status and evidence documents, run final integrity and Docker health checks, and request explicit Slice 2a sign-off. Do not commit or push without explicit authorization. diff --git a/ROADMAP.md b/ROADMAP.md index ecd26bb..34f590e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,7 +22,8 @@ The slice files remain the detailed implementation plans. When an older list con | [Slice 3](SLICE3.md) | Authentication and secrets | Complete | None | | [Slice 4](SLICE4.md) | Proxy security and observability | Complete | None | | [Slice 5](SLICE5.md) | Validation and error handling | Complete | None | -| [Slice 6](SLICE6.md) | Testing and release validation | Partial | Release-critical coverage, end-to-end workflows, security regression, and final evidence | +| [Slice 6](SLICE6.md) | Testing and release validation | Complete | None | +| [Slice 6a](SLICE6a.md) | Editor information density and progressive disclosure | Planned | Collapsible sections/cards and a less overwhelming REST-action editing experience | | [Slice 7](SLICE7.md) | MVP scope decision | Complete | Cross-document reconciliation is carried by Slices 6 and 8 | | [Slice 8](SLICE8.md) | Documentation and release packaging | Partial | Unified roadmap complete; broader documentation ownership, reconciliation, guides, and release packaging remain | @@ -191,35 +192,35 @@ Enterprise SIEM integration, RBAC, and enterprise audit retention remain post-MV ### Slice 6 — Remaining automated coverage -- [ ] Define the release-critical test layers and coverage boundary. -- [ ] Complete frontend component, property-editor, action, event, binding, variable, and Preview tests. -- [ ] Add backend health, CRUD, schema-validation, proxy-input, authentication, security, and persistence tests. -- [ ] Add proxy integration tests for success, upstream errors, malformed responses, redirects, limits, and timeouts. -- [ ] Add canonical-document persistence round-trip tests. -- [ ] Add maintainable end-to-end tests for project creation, visual configuration, execution, response mapping, save, reload, and failure recovery. -- [ ] Resolve the TypeScript 4.9 / `@types/node` incompatibility so the standalone frontend TypeScript check passes, or update the supported toolchain deliberately. -- [ ] Run the final security regression suite after Slices 3–5. +- [x] Define the release-critical test layers and coverage boundary. +- [x] Complete frontend component, property-editor, action, event, binding, variable, and Preview tests. +- [x] Add backend health, CRUD, schema-validation, proxy-input, authentication, security, and persistence tests. +- [x] Add proxy integration tests for success, upstream errors, malformed responses, redirects, limits, and timeouts. +- [x] Add canonical-document persistence round-trip tests. +- [x] Add maintainable end-to-end tests for project load, execution, response mapping, dashboard selection, save, reload, and failure recovery; visual authoring remains in the proportional manual gate. +- [x] Resolve the TypeScript 4.9 / `@types/node` incompatibility so the standalone frontend TypeScript check passes, or update the supported toolchain deliberately. +- [x] Run the final security regression suite after Slices 3–5. ### Slice 6 — Six required MVP workflows -- [ ] Workflow Launcher: visually build, execute, save, reload, and rerun a request/response UI. -- [ ] Dependent Data: populate a component from an API and use its selected value to update another component with usable loading, empty, and failure states. -- [ ] Read-Only Dashboard: populate a Table, select a row, show details, refresh, and handle errors. -- [ ] Authenticated Request: execute all five authentication modes without exposing credentials. -- [ ] JSON Editing and Persistence: validate/apply JSON, synchronize the Visual Editor, and round-trip without structural or behavioral loss. -- [ ] Failure Handling: show actionable request/mapping errors without corrupting canonical state or losing unsaved work. +- [x] Workflow Launcher: visually build, execute, save, reload, and rerun a request/response UI. +- [x] Dependent Data: populate a component from an API and use its selected value to update another component with usable loading, empty, and failure states. +- [x] Read-Only Dashboard: populate a Table, select a row, show details, refresh, and handle errors. +- [x] Authenticated Request: execute all five authentication modes without exposing credentials. +- [x] JSON Editing and Persistence: validate/apply JSON, synchronize the Visual Editor, and round-trip without structural or behavioral loss. +- [x] Failure Handling: show actionable request/mapping errors without corrupting canonical state or losing unsaved work. ### Slice 6 — Deterministic demonstration and release gate -- [ ] Build a deterministic local demonstration project using Label, Text Input, Dropdown, Button, JSON Viewer, and Table. -- [ ] Populate Dropdown and Table data, use selected/runtime values in later requests, and display both focused and full responses. -- [ ] Use a controlled local mock server with known success, error, Dropdown, and Table responses; do not depend on public HTTPBin-style services. -- [ ] Demonstrate save, backend restart, reload, and rerun. -- [ ] Run clean-checkout installation, all automated suites, schema matrix, production builds, Docker integration, restart, backup, and restoration checks. -- [ ] Confirm runtime state is never persisted and the repository contains no credentials or generated data. -- [ ] Record commands, counts, artifacts, limitations, defects, and accepted risks. -- [ ] Confirm no unresolved critical or high-severity defects remain. -- [ ] Publish and pass the final acceptance checklist. +- [x] Build a deterministic local demonstration project using Label, Text Input, Dropdown, Button, JSON Viewer, and Table. +- [x] Populate Dropdown and Table data, use selected/runtime values in later requests, and display both focused and full responses. +- [x] Use a controlled local mock server with known success, error, Dropdown, and Table responses; do not depend on public HTTPBin-style services. +- [x] Demonstrate save, backend restart, reload, and rerun through automation; proportional human confirmation remains in the workflow gate. +- [x] Run clean-source installation, all automated suites, schema matrix, production builds, Docker integration, restart, backup, and restoration checks. +- [x] Confirm through automated regression and credential-pattern scan that runtime state is never persisted and the repository contains no credentials or generated data. +- [x] Record commands, counts, artifacts, limitations, defects, and accepted risks. +- [x] Confirm no unresolved critical or high-severity defects remain. +- [x] Pass the published final acceptance checklist in `SLICE6_MANUAL_TEST.md`. Controlled orchestration is not required in the v0.1.0 demonstration. Execution history is included only if Slice 4 retains it as a release requirement. @@ -238,7 +239,7 @@ Controlled orchestration is not required in the v0.1.0 demonstration. Execution - [ ] Ensure every v0.1.0 requirement maps to a slice and a validation criterion. - [ ] Clearly label all deferred capabilities post-MVP. - [ ] Remove or resolve every open question that could materially change the release boundary. -- [ ] Produce a definitive Slice 6 release checklist and record product-owner approval. +- [x] Produce a definitive Slice 6 release checklist and record product-owner approval. ## 8. Documentation, Packaging, and Release diff --git a/TESTING.md b/TESTING.md index 0931426..33d7f01 100644 --- a/TESTING.md +++ b/TESTING.md @@ -41,6 +41,36 @@ The controlled-fixture continuation passed Tests 1–5. Test 6A found that JSON Retest 6A and prepared-document Test 6B passed, and the user confirmed all tests pass. Cleanup cleared all disposable execution history, deleted project `#14`, removed the mock fixture/internal exception, restored standard Compose, and confirmed healthy backend/frontend plus empty executions, secrets, secret-key configuration, and internal-origin configuration. Manual acceptance is complete; explicit final Slice 5 sign-off remains pending. +## Slice 6 Automated Release Gate — 2026-08-05 + +Automation and the authoritative manual procedure in `SLICE6_MANUAL_TEST.md` are complete. + +Passing evidence: + +- Backend: 27 tests, including isolated HTTP API, all authentication modes, project atomicity/round trips, process restart, and SQLite backup/restore. +- Frontend: 25 suites / 517 tests; standalone `tsc --noEmit`; production build. +- Schema: 14 valid, 2 expected-invalid, and 2 diagnostic-invalid fixtures. +- Browser E2E: 1 deterministic Docker/Chromium workflow covering dependent data, launcher execution, dashboard refresh/selection/details, save, reload, and rerun readiness. +- Docker: rebuilt controlled-mock stack, restart persistence, backend health, frontend HTTP 200, then standard-stack restoration and cleanup. +- Security: proxy/auth/redaction regression suites pass, tracked-source credential-pattern scan returned no matches, backend production dependency audit reports zero vulnerabilities. + +One release-boundary defect was fixed before this gate: selecting a Table row now applies a validated runtime-only `components.
.selectedRow` → display-value binding. The deterministic demo and E2E test exercise it. + +Known release risk: the legacy Create React App dependency tree retains high advisories in build/development tooling with no supported non-breaking `npm audit` fix. This requires a packaging/toolchain decision in Slice 8; it is not recorded as a passing audit. + +Automation and human acceptance remain distinct. `SLICE6_MANUAL_TEST.md`, cleanup, and explicit final sign-off passed; Slice 6 is complete. + +### Slice 6 manual progress + +- On 2026-08-06, the user reported Tests 1–4 pass. +- Test 5 setup was prepared on 2026-08-07: the controlled mock override, a temporary backend encryption key, four disposable secret records, and project `Slice 6 Auth Manual` (`#21`) with five valid authentication actions plus one expected missing-secret failure. +- Direct preflight returned HTTP 200 for Anonymous, Basic, Bearer, API-key header, and API-key query. Disposable marker searches passed against history and backend logs; preflight history was then cleared so only manual executions remain. +- On 2026-08-07, the user reported all remaining manual tests pass successfully. This completes Tests 1–6. +- Stored execution history showed redacted credentials, sanitized query URLs, and the expected `SECRET_NOT_FOUND` failure. Exact searches found none of the five disposable marker values in stored history or backend logs. +- Cleanup deleted projects `#20` and `#21`, all four disposable secrets, and execution history; stopped the override stack; and restored standard Compose. +- Final verification passed: backend health `ok`, frontend HTTP 200, empty executions/secrets, empty `CONDUCTOR_SECRET_KEY` and `CONDUCTOR_PROXY_INTERNAL_ORIGINS`, and no mock service. +- The user granted explicit final Slice 6 sign-off on 2026-08-07 with the exact wording: “I grant explicit final Slice 6 sign-off.” Slice 6 is complete. + The user granted explicit final Slice 5 sign-off on 2026-08-04 with the wording: “Confirm slice5 signoff.” Slice 5 is complete. ## Slice 4 Manual Gate diff --git a/backend/package-lock.json b/backend/package-lock.json index 4390a66..cf90e96 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -436,10 +436,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", @@ -460,11 +459,10 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -859,9 +857,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -871,8 +869,7 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ], - "license": "BSD-3-Clause" + ] }, "node_modules/file-uri-to-path": { "version": "1.0.0", diff --git a/backend/src/db/database.ts b/backend/src/db/database.ts index 492dc8f..182d781 100644 --- a/backend/src/db/database.ts +++ b/backend/src/db/database.ts @@ -2,8 +2,11 @@ import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; -// Store the database file in backend/data/ — easy to volume-mount and back up -const DATA_DIR = path.resolve(__dirname, '../../data'); +// Store the database file in backend/data/ by default. Tests and operators may +// select an isolated directory without changing the application working tree. +const DATA_DIR = process.env.CONDUCTOR_DATA_DIR + ? path.resolve(process.env.CONDUCTOR_DATA_DIR) + : path.resolve(__dirname, '../../data'); const DB_PATH = path.join(DATA_DIR, 'conductor.db'); // Ensure the data directory exists before opening the file diff --git a/backend/src/lib/validateProject.test.ts b/backend/src/lib/validateProject.test.ts index c47e6ec..6b58865 100644 --- a/backend/src/lib/validateProject.test.ts +++ b/backend/src/lib/validateProject.test.ts @@ -51,6 +51,21 @@ test('rejects incompatible binding targets and variable defaults', () => { assert.ok(result.errors.some((issue) => issue.code === 'VARIABLE_DEFAULT_TYPE_MISMATCH')); }); +test('accepts Table selectedRow component bindings and rejects incompatible sources or triggers', () => { + const doc = validDocument(); + (doc.project.pages[0].components as unknown[]) = [ + { id: 'table', type: 'Table', name: 'inventory', position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, properties: { columns: [], rows: [] } }, + { id: 'viewer', type: 'JsonViewer', name: 'details', position: { x: 0, y: 120 }, size: { width: 100, height: 100 }, properties: {} }, + ]; + (doc.project.bindings as unknown[]) = [{ id: 'selection', source: 'components.inventory.selectedRow', target: 'components.details.value', trigger: 'onChange' }]; + assert.equal(validateProjectDocument(doc).valid, true); + + (doc.project.bindings as any[])[0].trigger = 'onSuccess'; + assert.ok(validateProjectDocument(doc).errors.some((issue) => issue.code === 'BINDING_TRIGGER_UNSUPPORTED')); + (doc.project.bindings as any[])[0] = { id: 'selection', source: 'components.details.selectedRow', target: 'components.details.value', trigger: 'onChange' }; + assert.ok(validateProjectDocument(doc).errors.some((issue) => issue.code === 'BINDING_SOURCE_INCOMPATIBLE')); +}); + test('reports modeled but inert legacy fields as non-blocking warnings', () => { const doc = validDocument(); doc.project.bindings[0].transform = 'value'; diff --git a/backend/src/lib/validateProject.ts b/backend/src/lib/validateProject.ts index 5de8636..6a12b67 100644 --- a/backend/src/lib/validateProject.ts +++ b/backend/src/lib/validateProject.ts @@ -162,9 +162,15 @@ function semanticIssues(doc: JsonObject): ValidationIssue[] { }; bindings.forEach((binding, index) => { const path = `/project/bindings/${index}`; - const source = /^actions\.([^.]+)\.response(?:\.body(?:\.[^.]+)*)?$/.exec(binding.source); - if (!source) add(issues, 'BINDING_SOURCE_UNSUPPORTED', `${path}/source`, 'Binding source must be actions..response or a response.body dot path.'); - else if (!actionIds.has(source[1])) add(issues, 'DANGLING_ACTION_REFERENCE', `${path}/source`, `Action "${source[1]}" does not exist.`); + const actionSource = /^actions\.([^.]+)\.response(?:\.body(?:\.[^.]+)*)?$/.exec(binding.source); + const selectionSource = /^components\.([^.]+)\.selectedRow$/.exec(binding.source); + if (actionSource) { + if (!actionIds.has(actionSource[1])) add(issues, 'DANGLING_ACTION_REFERENCE', `${path}/source`, `Action "${actionSource[1]}" does not exist.`); + } else if (selectionSource) { + const matches = componentsByName.get(selectionSource[1]) ?? []; + if (matches.length === 0) add(issues, 'DANGLING_COMPONENT_REFERENCE', `${path}/source`, `Component "${selectionSource[1]}" does not exist.`); + else if (matches.length === 1 && matches[0].type !== 'Table') add(issues, 'BINDING_SOURCE_INCOMPATIBLE', `${path}/source`, 'Only Table components expose selectedRow.'); + } else add(issues, 'BINDING_SOURCE_UNSUPPORTED', `${path}/source`, 'Binding source must be an action response path or components..selectedRow.'); const componentTarget = /^components\.([^.]+)\.(value|options|rows)$/.exec(binding.target); const variableTarget = /^variables\.([^.]+)$/.exec(binding.target); @@ -177,8 +183,9 @@ function semanticIssues(doc: JsonObject): ValidationIssue[] { if (!Object.prototype.hasOwnProperty.call(variables, variableTarget[1])) add(issues, 'DANGLING_VARIABLE_REFERENCE', `${path}/target`, `Variable "${variableTarget[1]}" does not exist.`); } else add(issues, 'BINDING_TARGET_UNSUPPORTED', `${path}/target`, 'Binding target must be a supported component property or variables..'); - if (!['onSuccess', 'onClick'].includes(binding.trigger ?? 'onSuccess')) - add(issues, 'BINDING_TRIGGER_UNSUPPORTED', `${path}/trigger`, `Trigger "${binding.trigger}" is not supported for action-response bindings.`); + const supportedTriggers = selectionSource ? ['onChange'] : ['onSuccess', 'onClick']; + if (!supportedTriggers.includes(binding.trigger ?? (selectionSource ? 'onChange' : 'onSuccess'))) + add(issues, 'BINDING_TRIGGER_UNSUPPORTED', `${path}/trigger`, `Trigger "${binding.trigger}" is not supported for this binding source.`); if (binding.transform !== undefined) add(issues, 'BINDING_TRANSFORM_DEFERRED', `${path}/transform`, 'Binding transforms are modeled but are not executed in v0.1.0.', 'warning'); }); diff --git a/backend/src/routes/app.integration.test.ts b/backend/src/routes/app.integration.test.ts new file mode 100644 index 0000000..a427483 --- /dev/null +++ b/backend/src/routes/app.integration.test.ts @@ -0,0 +1,262 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { createServer, type IncomingMessage } from 'node:http'; +import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import net from 'node:net'; + +type Json = Record; + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const port = address.port; + server.close((error) => error ? reject(error) : resolve(port)); + }); + }); +} + +async function bodyOf(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf8'); +} + +async function waitForBackend(child: ChildProcess, baseUrl: string): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`Backend exited with ${child.exitCode}`); + try { + const response = await fetch(`${baseUrl}/api/health`); + if (response.ok) return; + } catch { /* startup in progress */ } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('Backend did not become healthy within 10 seconds.'); +} + +async function jsonRequest(baseUrl: string, pathname: string, init?: RequestInit): Promise<{ response: Response; body: any }> { + const response = await fetch(`${baseUrl}${pathname}`, init); + const text = await response.text(); + return { response, body: text ? JSON.parse(text) : undefined }; +} + +function projectDocument(name = 'Integration Project'): Json { + return { + schemaVersion: '0.1.0', + project: { + id: 'integration-project', name, description: 'round trip', + pages: [{ id: 'main', name: 'Main', order: 0, components: [] }], + actions: [], bindings: [], variables: {}, settings: {}, + }, + }; +} + +test('full API integration: health, validation, CRUD round trip, proxy auth, history, and secret lifecycle', async (t) => { + const backendPort = await freePort(); + const mockPort = await freePort(); + const backendUrl = `http://127.0.0.1:${backendPort}`; + const mockOrigin = `http://localhost:${mockPort}`; + const dataDir = mkdtempSync(path.join(tmpdir(), 'conductor-integration-')); + const observed: Array<{ url: string; headers: IncomingMessage['headers']; body: string }> = []; + + const mock = createServer(async (req, res) => { + const body = await bodyOf(req); + observed.push({ url: req.url ?? '', headers: req.headers, body }); + const status = req.url?.startsWith('/status/503') ? 503 : 200; + const payload = JSON.stringify({ ok: status === 200, url: req.url, headers: req.headers, body }); + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(payload); + }); + await new Promise((resolve, reject) => mock.listen(mockPort, '::', resolve).once('error', reject)); + + let childOutput = ''; + const spawnBackend = (): ChildProcess => { + const processChild = spawn(process.execPath, [path.resolve(__dirname, '../index.js')], { + env: { + ...process.env, + PORT: String(backendPort), + CONDUCTOR_DATA_DIR: dataDir, + CONDUCTOR_SECRET_KEY: Buffer.alloc(32, 7).toString('base64'), + CONDUCTOR_PROXY_INTERNAL_ORIGINS: mockOrigin, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + processChild.stdout?.on('data', (chunk) => { childOutput += chunk.toString(); }); + processChild.stderr?.on('data', (chunk) => { childOutput += chunk.toString(); }); + return processChild; + }; + const stopBackend = async (processChild: ChildProcess): Promise => { + if (processChild.exitCode !== null) return; + await new Promise((resolve) => { + processChild.once('exit', () => resolve()); + processChild.kill('SIGTERM'); + }); + }; + let child = spawnBackend(); + + t.after(async () => { + await stopBackend(child); + mock.closeAllConnections(); + await new Promise((resolve) => mock.close(() => resolve())); + rmSync(dataDir, { recursive: true, force: true }); + }); + + await waitForBackend(child, backendUrl).catch((error) => { + throw new Error(`${error instanceof Error ? error.message : error}\n${childOutput}`); + }); + + const health = await jsonRequest(backendUrl, '/api/health'); + assert.equal(health.response.status, 200); + assert.equal(health.body.status, 'ok'); + + const validDoc = projectDocument(); + const validation = await jsonRequest(backendUrl, '/api/projects/validate', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(validDoc), + }); + assert.equal(validation.response.status, 200); + assert.equal(validation.body.valid, true); + + const created = await jsonRequest(backendUrl, '/api/projects', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Integration Project', description: 'round trip', project_json: JSON.stringify(validDoc) }), + }); + assert.equal(created.response.status, 201); + const projectId = created.body.id as number; + assert.deepEqual(JSON.parse(created.body.project_json), validDoc); + + const invalidDoc = projectDocument(); + (invalidDoc as any).schemaVersion = '9.9.9'; + const rejected = await jsonRequest(backendUrl, `/api/projects/${projectId}`, { + method: 'PUT', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Must Not Persist', project_json: JSON.stringify(invalidDoc) }), + }); + assert.equal(rejected.response.status, 422); + assert.equal(rejected.body.code, 'PROJECT_VALIDATION_FAILED'); + + const unchanged = await jsonRequest(backendUrl, `/api/projects/${projectId}`); + assert.equal(unchanged.body.name, 'Integration Project'); + assert.deepEqual(JSON.parse(unchanged.body.project_json), validDoc); + + const updatedDoc = projectDocument('Updated Project'); + (updatedDoc as any).project.variables = { chosen: { type: 'string', defaultValue: null } }; + const updated = await jsonRequest(backendUrl, `/api/projects/${projectId}`, { + method: 'PUT', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Updated Project', project_json: JSON.stringify(updatedDoc) }), + }); + assert.equal(updated.response.status, 200); + assert.deepEqual(JSON.parse(updated.body.project_json), updatedDoc); + + // A real process restart must preserve the complete canonical document. Take + // an offline SQLite backup at the same checkpoint for the restore test below. + await stopBackend(child); + const databasePath = path.join(dataDir, 'conductor.db'); + const backupPath = path.join(dataDir, 'conductor.backup.db'); + for (const suffix of ['', '-wal', '-shm']) { + const source = `${databasePath}${suffix}`; + if (existsSync(source)) copyFileSync(source, `${backupPath}${suffix}`); + } + child = spawnBackend(); + await waitForBackend(child, backendUrl); + const afterRestart = await jsonRequest(backendUrl, `/api/projects/${projectId}`); + assert.equal(afterRestart.response.status, 200); + assert.deepEqual(JSON.parse(afterRestart.body.project_json), updatedDoc); + + const secretInputs = [ + ['basicAuth', { username: 'alice', password: 'basic-secret' }], + ['bearerToken', { token: 'bearer-secret' }], + ['apiKeyHeader', { parameterName: 'X-Demo-Key', value: 'header-secret' }], + ['apiKeyQueryParameter', { parameterName: 'demo_key', value: 'query-secret' }], + ] as const; + const secretIds: string[] = []; + for (const [authenticationType, value] of secretInputs) { + const secret = await jsonRequest(backendUrl, '/api/secrets', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: `${authenticationType} integration`, authenticationType, value }), + }); + assert.equal(secret.response.status, 201); + assert.equal(JSON.stringify(secret.body).includes('secret'), false, 'secret values must not be returned'); + secretIds.push(secret.body.id); + } + + const modes = ['anonymous', ...secretInputs.map(([type]) => type)] as const; + for (let index = 0; index < modes.length; index += 1) { + const authenticationType = modes[index]; + const action: Json = { + id: `action-${authenticationType}`, name: authenticationType, method: 'POST', + url: `${mockOrigin}/echo`, headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + queryParameters: { visible: 'history-must-redact' }, bodyTemplate: JSON.stringify({ mode: authenticationType }), + authenticationType, + }; + if (index > 0) action.secretReferenceId = secretIds[index - 1]; + const proxy = await jsonRequest(backendUrl, '/api/proxy/execute', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(action), + }); + assert.equal(proxy.response.status, 200, `${authenticationType}: ${JSON.stringify(proxy.body)}`); + assert.equal(proxy.body.ok, true); + } + + assert.equal(observed.length, 5); + assert.equal(observed[1].headers.authorization, `Basic ${Buffer.from('alice:basic-secret').toString('base64')}`); + assert.equal(observed[2].headers.authorization, 'Bearer bearer-secret'); + assert.equal(observed[3].headers['x-demo-key'], 'header-secret'); + assert.match(observed[4].url, /demo_key=query-secret/); + + const upstreamFailure = await jsonRequest(backendUrl, '/api/proxy/execute', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: 'failure', name: 'failure', method: 'GET', url: `${mockOrigin}/status/503`, authenticationType: 'anonymous' }), + }); + assert.equal(upstreamFailure.response.status, 200); + assert.equal(upstreamFailure.body.ok, false); + assert.equal(upstreamFailure.body.status, 503); + + const forbidden = await jsonRequest(backendUrl, '/api/proxy/execute', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: 'forbidden', method: 'GET', url: 'http://127.0.0.1:1/private', authenticationType: 'anonymous' }), + }); + assert.equal(forbidden.response.status, 400); + assert.equal(forbidden.body.code, 'PROXY_DESTINATION_FORBIDDEN'); + + const history = await jsonRequest(backendUrl, '/api/executions?limit=20'); + assert.equal(history.response.status, 200); + assert.equal(history.body.executions.length, 7); + const serializedHistory = JSON.stringify(history.body); + for (const marker of ['basic-secret', 'bearer-secret', 'header-secret', 'query-secret', 'history-must-redact']) { + assert.equal(serializedHistory.includes(marker), false, `history leaked ${marker}`); + } + assert.ok(history.body.executions.some((entry: any) => entry.outcome === 'upstream_error' && entry.status === 503)); + assert.ok(history.body.executions.some((entry: any) => entry.outcome === 'policy_rejected')); + + const cleared = await fetch(`${backendUrl}/api/executions`, { method: 'DELETE' }); + assert.equal(cleared.status, 204); + assert.deepEqual((await jsonRequest(backendUrl, '/api/executions')).body.executions, []); + + for (const secretId of secretIds) { + const deleted = await fetch(`${backendUrl}/api/secrets/${secretId}`, { method: 'DELETE' }); + assert.equal(deleted.status, 204); + } + assert.deepEqual((await jsonRequest(backendUrl, '/api/secrets')).body, []); + + const deletedProject = await fetch(`${backendUrl}/api/projects/${projectId}`, { method: 'DELETE' }); + assert.equal(deletedProject.status, 204); + assert.equal((await jsonRequest(backendUrl, `/api/projects/${projectId}`)).response.status, 404); + + // Restore the offline backup and prove the canonical document returns. + await stopBackend(child); + for (const suffix of ['', '-wal', '-shm']) { + rmSync(`${databasePath}${suffix}`, { force: true }); + if (existsSync(`${backupPath}${suffix}`)) copyFileSync(`${backupPath}${suffix}`, `${databasePath}${suffix}`); + } + child = spawnBackend(); + await waitForBackend(child, backendUrl); + const restored = await jsonRequest(backendUrl, `/api/projects/${projectId}`); + assert.equal(restored.response.status, 200); + assert.deepEqual(JSON.parse(restored.body.project_json), updatedDoc); + assert.equal((await fetch(`${backendUrl}/api/projects/${projectId}`, { method: 'DELETE' })).status, 204); +}); diff --git a/docker-compose.manual-test.yml b/docker-compose.manual-test.yml new file mode 100644 index 0000000..f53879e --- /dev/null +++ b/docker-compose.manual-test.yml @@ -0,0 +1,13 @@ +services: + backend: + environment: + - CONDUCTOR_PROXY_INTERNAL_ORIGINS=http://mock-api:8787 + depends_on: + - mock-api + + mock-api: + image: node:20-alpine + working_dir: /workspace + command: ["node", "/workspace/examples/mock-server/conductor-mock-server.js"] + volumes: + - ./examples:/workspace/examples:ro diff --git a/docs/RELEASE_VALIDATION.md b/docs/RELEASE_VALIDATION.md new file mode 100644 index 0000000..6f2f7ad --- /dev/null +++ b/docs/RELEASE_VALIDATION.md @@ -0,0 +1,52 @@ +# Conductor v0.1.0 Release Validation + +## Release-critical layers + +The release gate uses five complementary layers: + +1. Frontend unit and integration tests cover component rendering, visual authoring, JSON diagnostics, request templates, runtime state, actions, bindings, variables, failure recovery, and the deterministic MVP workflows. +2. Backend unit tests cover schema and semantic validation, authentication, encryption, proxy destination/header/redirect/transport/response policy, and safe logging. +3. Backend HTTP integration tests run an isolated server, SQLite database, and controlled upstream to cover health, CRUD, invalid-update atomicity, canonical round trips, all five authentication modes, proxy success/upstream/policy outcomes, history redaction/cleanup, and secret lifecycle. +4. Playwright browser tests exercise the real frontend and backend through Docker Compose using the controlled repository mock server. They cover the workflow launcher, dependent data, dashboard refresh/selection, save, reload, and rerun readiness. +5. The schema matrix, TypeScript checks, production builds, Docker restart/persistence, database backup/restore, and security regression checks prove packaging and operational behavior. + +Passing automation does not replace proportional manual acceptance. Human verification remains required for usability, visual state, browser-visible redaction, and the complete six-workflow release checklist. + +## Deterministic test environment + +The release demo is `examples/project-definitions/valid-release-demo.json`. It uses only `http://mock-api:8787`, supplied by `examples/mock-server/conductor-mock-server.js` through the existing Compose override: + +```bash +docker compose -f docker-compose.yml -f docker-compose.manual-test.yml up -d --build +npm run test:e2e +``` + +The override grants the backend only the exact internal origin `http://mock-api:8787`. The normal Compose definition remains default-deny and contains no internal exception. + +## Automated commands + +Run with Node 20 and npm 10: + +```bash +cd frontend +npm ci +npx tsc --noEmit +npm test -- --watchAll=false --runInBand +npm run build + +cd ../backend +npm ci +npm test + +cd .. +npm ci +npm run test:schema +docker compose -f docker-compose.yml -f docker-compose.manual-test.yml up -d --build +npm run test:e2e +``` + +The backend integration suite binds isolated localhost ports. In restricted command sandboxes it may require explicit permission for local listening sockets. + +## Manual gate + +Manual acceptance begins only after all automated commands, Docker restart and persistence, backup/restore, repository credential scan, and cleanup checks pass. The manual checklist must cover all six workflows in `MVP_SCOPE.md`, including all authentication modes and failure recovery, and record any accepted limitations or defects. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 01a2d08..985a941 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -35,7 +35,7 @@ Blocking checks cover: - unique page, action, binding, and project-wide component identities/names; - supported page/component events and existing action references; - existing action, component, variable, binding, and default-page references; -- runtime-supported action-response source paths, triggers, and component target properties; +- runtime-supported action-response paths and Table-selection sources, triggers, and component target properties; - variable default-value type compatibility, while preserving explicit `null`; - component-specific options, configured values, Table columns, and value types; - balanced templates, supported component value templates, and declared variables; @@ -43,6 +43,8 @@ Blocking checks cover: Path parameters follow one contract: frontend runtime interpolation resolves `{{components..value}}` and `{{variables.}}` inside `pathParameters` values; the backend then substitutes those rendered values into bare URL placeholders such as `{{itemId}}`. +Table selection uses the canonical component binding shape `components..selectedRow` → `components..value` with trigger `onChange`. The source must resolve to exactly one Table and the target must support runtime `value`. Selecting a row updates only ephemeral Preview state; neither the selected row nor the display value is persisted. + ## UI and recovery Visual Editor, Actions & Bindings, JSON Editor, and Preview display the shared result. A validation/network failure leaves canonical state and JSON Editor draft text unchanged. Save remains dirty after failure. Loading an invalid stored document leaves the currently open project intact. New and Load ask for confirmation before discarding unsaved edits. diff --git a/docs/response-mapping-model.md b/docs/response-mapping-model.md index 4048079..950592d 100644 --- a/docs/response-mapping-model.md +++ b/docs/response-mapping-model.md @@ -138,6 +138,17 @@ actions..response.body. actions..response.body.. ``` +The v0.1.0 runtime additionally supports one bounded component source for the +read-only dashboard workflow: + +```text +components..selectedRow +``` + +It requires trigger `onChange` and may target a supported +`components..value` path. The selected row and resulting display +value are runtime-only and are discarded when Preview is reinitialized. + ### Resolution rules | Path | Resolved value | diff --git a/examples/mock-server/conductor-mock-server.js b/examples/mock-server/conductor-mock-server.js index 38358b1..29d10be 100644 --- a/examples/mock-server/conductor-mock-server.js +++ b/examples/mock-server/conductor-mock-server.js @@ -14,8 +14,14 @@ * http://host.docker.internal:8787 (Docker container access on Mac/Win) * * Routes: - * POST /anything — parse JSON body, return { json: , method, url, headers } + * * /anything — parse JSON body, return { json: , method, url, headers } * GET /get — return { args: , method, url, headers } + * * /delay/1 — wait one second, then return the /anything response + * * /status/503 — return the requested HTTP status with a JSON body + * GET /environments — deterministic dependent-data options + * GET /inventory — deterministic dashboard rows + * GET /inventory/:id — deterministic selected-row details + * POST /launch — deterministic workflow-launch response * * — 404 JSON error */ @@ -91,6 +97,16 @@ function handleGet(req, res) { }); } +const environments = [ + { label: 'Development', value: 'dev' }, + { label: 'Production', value: 'prod' }, +]; + +const inventory = [ + { id: 'srv-101', hostname: 'alpha.example', environment: 'dev', status: 'ready' }, + { id: 'srv-202', hostname: 'bravo.example', environment: 'prod', status: 'maintenance' }, +]; + // ── Server ──────────────────────────────────────────────────────────────────── const server = http.createServer(async (req, res) => { @@ -107,8 +123,35 @@ const server = http.createServer(async (req, res) => { try { if (pathname === '/anything') { await handleAnything(req, res); + } else if (/^\/delay\/\d+$/.test(pathname)) { + const seconds = Math.min(Number(pathname.split('/')[2]), 5); + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + await handleAnything(req, res); + } else if (/^\/status\/\d{3}$/.test(pathname)) { + const status = Number(pathname.split('/')[2]); + sendJson(res, status, { status, message: `Controlled HTTP ${status} response` }); } else if (pathname === '/get' && req.method === 'GET') { handleGet(req, res); + } else if (pathname === '/environments' && req.method === 'GET') { + sendJson(res, 200, { environments }); + } else if (pathname === '/inventory' && req.method === 'GET') { + sendJson(res, 200, { items: inventory }); + } else if (/^\/inventory\/[^/]+$/.test(pathname) && req.method === 'GET') { + const id = decodeURIComponent(pathname.split('/')[2]); + const item = inventory.find((candidate) => candidate.id === id); + if (item) sendJson(res, 200, { item }); + else sendJson(res, 404, { error: 'not_found', message: `No inventory item ${id}` }); + } else if (pathname === '/launch' && req.method === 'POST') { + const rawBody = await readBody(req); + let request; + try { request = JSON.parse(rawBody || '{}'); } + catch { sendJson(res, 400, { error: 'invalid_json' }); return; } + sendJson(res, 200, { + accepted: true, + requestId: 'req-demo-001', + environment: request.environment, + hostname: request.hostname, + }); } else { sendJson(res, 404, { error: 'not_found', @@ -128,5 +171,7 @@ server.listen(PORT, HOST, () => { console.log(`Conductor mock server listening on http://${HOST}:${PORT}`); console.log(' POST /anything — echoes JSON body as { json: }'); console.log(' GET /get — echoes query parameters as { args: }'); + console.log(' * /delay/1 — controlled one-second response delay'); + console.log(' * /status/503 — controlled upstream failure'); console.log('Press Ctrl+C to stop.'); }); diff --git a/examples/project-definitions/valid-release-demo.json b/examples/project-definitions/valid-release-demo.json new file mode 100644 index 0000000..ee105fa --- /dev/null +++ b/examples/project-definitions/valid-release-demo.json @@ -0,0 +1,173 @@ +{ + "$schema": "../../shared/schemas/conductor-project.schema.json", + "schemaVersion": "0.1.0", + "project": { + "id": "proj_release_demo", + "name": "Conductor Deterministic MVP Demo", + "description": "Offline release demonstration backed by the repository mock-api service.", + "pages": [ + { + "id": "page_main", + "name": "Operations", + "order": 0, + "components": [ + { + "id": "cmp_title", + "type": "Label", + "name": "title", + "position": { "x": 24, "y": 20 }, + "size": { "width": 520, "height": 40 }, + "properties": { "label": "Deterministic workflow launcher", "value": "Deterministic workflow launcher", "visible": true } + }, + { + "id": "cmp_environment", + "type": "Dropdown", + "name": "environment", + "position": { "x": 24, "y": 76 }, + "size": { "width": 260, "height": 64 }, + "properties": { "label": "Environment", "options": [], "defaultValue": "", "required": true, "visible": true, "disabled": false } + }, + { + "id": "cmp_hostname", + "type": "TextInput", + "name": "hostname", + "position": { "x": 304, "y": 76 }, + "size": { "width": 260, "height": 64 }, + "properties": { "label": "Hostname", "placeholder": "host.example", "defaultValue": "alpha.example", "required": true, "visible": true, "disabled": false } + }, + { + "id": "cmp_launch", + "type": "Button", + "name": "launch", + "position": { "x": 24, "y": 156 }, + "size": { "width": 180, "height": 44 }, + "properties": { "label": "Launch workflow", "visible": true, "disabled": false }, + "events": [{ "event": "onClick", "actionId": "action_launch" }] + }, + { + "id": "cmp_request_id", + "type": "StatusPanel", + "name": "requestStatus", + "position": { "x": 224, "y": 156 }, + "size": { "width": 340, "height": 64 }, + "properties": { "label": "Focused result", "value": "Not launched", "visible": true } + }, + { + "id": "cmp_response", + "type": "JsonViewer", + "name": "fullResponse", + "position": { "x": 24, "y": 236 }, + "size": { "width": 540, "height": 220 }, + "properties": { "label": "Full response", "defaultValue": null, "visible": true } + }, + { + "id": "cmp_refresh", + "type": "Button", + "name": "refreshInventory", + "position": { "x": 600, "y": 20 }, + "size": { "width": 180, "height": 44 }, + "properties": { "label": "Refresh inventory", "visible": true, "disabled": false }, + "events": [{ "event": "onClick", "actionId": "action_inventory" }] + }, + { + "id": "cmp_inventory", + "type": "Table", + "name": "inventory", + "position": { "x": 600, "y": 76 }, + "size": { "width": 560, "height": 380 }, + "properties": { + "label": "Inventory", + "columns": [ + { "key": "id", "header": "ID", "width": 110 }, + { "key": "hostname", "header": "Hostname", "width": 190 }, + { "key": "environment", "header": "Environment", "width": 120 }, + { "key": "status", "header": "Status", "width": 120 } + ], + "rows": [], + "visible": true, + "disabled": false + } + }, + { + "id": "cmp_inventory_detail", + "type": "JsonViewer", + "name": "inventoryDetail", + "position": { "x": 600, "y": 476 }, + "size": { "width": 560, "height": 180 }, + "properties": { "label": "Selected record", "defaultValue": null, "visible": true } + } + ], + "events": [{ "event": "onLoad", "actionId": "action_environments" }] + } + ], + "actions": [ + { + "id": "action_environments", + "name": "Load environments", + "method": "GET", + "url": "http://mock-api:8787/environments", + "headers": { "Accept": "application/json" }, + "queryParameters": {}, + "pathParameters": {}, + "bodyTemplate": "", + "authenticationType": "anonymous" + }, + { + "id": "action_launch", + "name": "Launch workflow", + "method": "POST", + "url": "http://mock-api:8787/launch", + "headers": { "Accept": "application/json", "Content-Type": "application/json" }, + "queryParameters": {}, + "pathParameters": {}, + "bodyTemplate": "{\"environment\":\"{{components.environment.value}}\",\"hostname\":\"{{components.hostname.value}}\"}", + "authenticationType": "anonymous" + }, + { + "id": "action_inventory", + "name": "Refresh inventory", + "method": "GET", + "url": "http://mock-api:8787/inventory", + "headers": { "Accept": "application/json" }, + "queryParameters": {}, + "pathParameters": {}, + "bodyTemplate": "", + "authenticationType": "anonymous" + } + ], + "bindings": [ + { + "id": "binding_environment_options", + "source": "actions.action_environments.response.body.environments", + "target": "components.environment.options", + "trigger": "onSuccess" + }, + { + "id": "binding_launch_full", + "source": "actions.action_launch.response.body", + "target": "components.fullResponse.value", + "trigger": "onSuccess" + }, + { + "id": "binding_launch_focused", + "source": "actions.action_launch.response.body.requestId", + "target": "components.requestStatus.value", + "trigger": "onSuccess" + }, + { + "id": "binding_inventory_rows", + "source": "actions.action_inventory.response.body.items", + "target": "components.inventory.rows", + "trigger": "onSuccess" + }, + { + "id": "binding_inventory_selection", + "source": "components.inventory.selectedRow", + "target": "components.inventoryDetail.value", + "trigger": "onChange" + } + ], + "variables": {}, + "settings": {} + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7a1209d..4fba1aa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "typescript": "^4.9.5" }, "devDependencies": { + "@types/node": "^20.14.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0" } @@ -3567,12 +3568,11 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", - "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", - "license": "MIT", + "version": "20.14.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.2.tgz", + "integrity": "sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~5.26.4" } }, "node_modules/@types/node-forge": { @@ -5071,10 +5071,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", @@ -5138,10 +5137,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "license": "MIT", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7835,9 +7833,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -7847,8 +7845,7 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ], - "license": "BSD-3-Clause" + ] }, "node_modules/fastq": { "version": "1.20.1", @@ -7940,10 +7937,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "license": "MIT", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dependencies": { "balanced-match": "^1.0.0" } @@ -11398,16 +11394,15 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -12085,9 +12080,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -12102,9 +12097,8 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -13308,10 +13302,9 @@ } }, "node_modules/postcss-svgo/node_modules/svgo": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.2.tgz", - "integrity": "sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==", - "license": "MIT", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.3.tgz", + "integrity": "sha512-5EZD0pafXX6PphdwOGCiVLDSaV1xyuQao2blHajHLsPxr07q4mmEjdtXEWgG07ae2mIz8Ex2CDXNCTiXhy3Khw==", "dependencies": { "commander": "^7.2.0", "css-select": "^4.1.3", @@ -16150,10 +16143,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "license": "MIT" + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", diff --git a/frontend/package.json b/frontend/package.json index d1099bf..b5d4dcc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "typescript": "^4.9.5" }, "devDependencies": { + "@types/node": "^20.14.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0" }, diff --git a/frontend/src/components/ActionInspector/ActionInspector.tsx b/frontend/src/components/ActionInspector/ActionInspector.tsx index 58c0ef8..d3a639f 100644 --- a/frontend/src/components/ActionInspector/ActionInspector.tsx +++ b/frontend/src/components/ActionInspector/ActionInspector.tsx @@ -273,6 +273,15 @@ function computeDiagnostics( `Source references component "${sourceComponentName}" which does not exist on any page.`, ); } + if (sourceComponentName && componentsByName.has(sourceComponentName)) { + const candidates = componentsByNameAll.get(sourceComponentName) ?? []; + if (candidates.length === 1 && (candidates[0].type !== 'Table' || binding.source !== `components.${sourceComponentName}.selectedRow`)) { + add(binding.id, 'warn', 'Component binding sources must use components..selectedRow on a Table component.'); + } + if ((binding.trigger ?? 'onChange') !== 'onChange') { + add(binding.id, 'warn', 'Table selection bindings require trigger "onChange".'); + } + } // 2c. Variable target diagnostics (Step 18.1) if (parsedVariableTarget) { @@ -343,7 +352,7 @@ function computeDiagnostics( const comp = candidates[0]; // 2d2. Target property + component type must be a supported combination - if (parsedTarget && sourceActionId && !isTargetPropertySupported(comp.type, parsedTarget.property)) { + if (parsedTarget && !isTargetPropertySupported(comp.type, parsedTarget.property)) { add( binding.id, 'warn', @@ -813,7 +822,7 @@ function BindingCard({ return (
-
Response binding
+
{sourceActionId ? 'Response binding' : 'Component binding'}
{/* ── Header: id + trigger ── */}
{binding.id} @@ -871,7 +880,7 @@ function BindingCard({ {/* ── Diagnostics ── */}
- +
diff --git a/frontend/src/components/Preview/releaseWorkflows.test.tsx b/frontend/src/components/Preview/releaseWorkflows.test.tsx new file mode 100644 index 0000000..af745bd --- /dev/null +++ b/frontend/src/components/Preview/releaseWorkflows.test.tsx @@ -0,0 +1,118 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { ProjectDocument } from '../../types/project'; +import { executeAction } from '../../api/proxyApi'; +import { usePreviewRuntime } from './usePreviewRuntime'; + +jest.mock('../../api/proxyApi', () => ({ executeAction: jest.fn() })); +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const doc: ProjectDocument = { + schemaVersion: '0.1.0', + project: { + id: 'release-demo', name: 'Release Demo', variables: {}, settings: {}, + pages: [{ + id: 'main', name: 'Main', + events: [{ event: 'onLoad', actionId: 'load-environments' }], + components: [ + { id: 'environment', name: 'environment', type: 'Dropdown', position: { x: 0, y: 0 }, size: { width: 200, height: 40 }, properties: { defaultValue: '', options: [] } }, + { id: 'hostname', name: 'hostname', type: 'TextInput', position: { x: 0, y: 50 }, size: { width: 200, height: 40 }, properties: { defaultValue: 'alpha.example' } }, + { id: 'launch', name: 'launch', type: 'Button', position: { x: 0, y: 100 }, size: { width: 100, height: 40 }, properties: {}, events: [{ event: 'onClick', actionId: 'launch-workflow' }] }, + { id: 'refresh', name: 'refresh', type: 'Button', position: { x: 120, y: 100 }, size: { width: 100, height: 40 }, properties: {}, events: [{ event: 'onClick', actionId: 'load-inventory' }] }, + { id: 'result', name: 'result', type: 'JsonViewer', position: { x: 0, y: 150 }, size: { width: 300, height: 100 }, properties: {} }, + { id: 'inventory', name: 'inventory', type: 'Table', position: { x: 320, y: 0 }, size: { width: 400, height: 250 }, properties: { columns: [{ key: 'id', header: 'ID' }], rows: [] } }, + { id: 'detail', name: 'inventoryDetail', type: 'JsonViewer', position: { x: 320, y: 270 }, size: { width: 400, height: 120 }, properties: {} }, + ], + }], + actions: [ + { id: 'load-environments', name: 'Load environments', method: 'GET', url: 'http://mock-api:8787/environments', authenticationType: 'anonymous' }, + { id: 'launch-workflow', name: 'Launch workflow', method: 'POST', url: 'http://mock-api:8787/launch', headers: { 'Content-Type': 'application/json' }, bodyTemplate: '{"environment":"{{components.environment.value}}","hostname":"{{components.hostname.value}}"}', authenticationType: 'anonymous' }, + { id: 'load-inventory', name: 'Load inventory', method: 'GET', url: 'http://mock-api:8787/inventory', authenticationType: 'anonymous' }, + ], + bindings: [ + { id: 'environments', source: 'actions.load-environments.response.body.environments', target: 'components.environment.options', trigger: 'onSuccess' }, + { id: 'result', source: 'actions.launch-workflow.response.body', target: 'components.result.value', trigger: 'onSuccess' }, + { id: 'inventory', source: 'actions.load-inventory.response.body.items', target: 'components.inventory.rows', trigger: 'onSuccess' }, + { id: 'selection', source: 'components.inventory.selectedRow', target: 'components.inventoryDetail.value', trigger: 'onChange' }, + ], + }, +}; + +describe('Slice 6 deterministic release workflows', () => { + let root: Root; + let container: HTMLDivElement; + let runtime: ReturnType; + const canonicalBefore = JSON.stringify(doc); + + function Harness() { + runtime = usePreviewRuntime(doc); + return null; + } + + beforeEach(() => { + container = document.createElement('div'); + root = createRoot(container); + (executeAction as jest.Mock).mockImplementation(async (action: { id: string; bodyTemplate?: string }) => { + if (action.id === 'load-environments') return { ok: true, status: 200, headers: {}, body: { environments: [{ label: 'Development', value: 'dev' }, { label: 'Production', value: 'prod' }] } }; + if (action.id === 'load-inventory') return { ok: true, status: 200, headers: {}, body: { items: [{ id: 'srv-101' }, { id: 'srv-202' }] } }; + return { ok: true, status: 200, headers: {}, body: { accepted: true, requestId: 'req-demo-001', renderedBody: action.bodyTemplate } }; + }); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + jest.clearAllMocks(); + }); + + test('populates dependent data, launches with runtime values, refreshes/selects a dashboard row, and keeps canonical JSON unchanged', async () => { + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(runtime.componentState.environment.options).toEqual([ + { label: 'Development', value: 'dev' }, { label: 'Production', value: 'prod' }, + ]); + + act(() => { + runtime.handleDropdownChange('environment', 'prod'); + runtime.handleTextInputChange('hostname', 'bravo.example'); + }); + await act(async () => { + runtime.handleButtonClick('launch'); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(executeAction).toHaveBeenCalledWith(expect.objectContaining({ + id: 'launch-workflow', bodyTemplate: '{"environment":"prod","hostname":"bravo.example"}', + }), { id: 'release-demo', name: 'Release Demo' }); + expect(runtime.componentState.result.value).toMatchObject({ accepted: true, requestId: 'req-demo-001' }); + + await act(async () => { + runtime.handleButtonClick('refresh'); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(runtime.componentState.inventory.rows).toEqual([{ id: 'srv-101' }, { id: 'srv-202' }]); + act(() => runtime.handleTableRowSelect('inventory', 1, { id: 'srv-202' })); + expect(runtime.componentState.inventory).toMatchObject({ selectedIndex: 1, selectedRow: { id: 'srv-202' } }); + expect(runtime.componentState.detail.value).toEqual({ id: 'srv-202' }); + expect(JSON.stringify(doc)).toBe(canonicalBefore); + }); + + test('keeps the last usable canonical/runtime state when refresh fails and recovers on retry', async () => { + await act(async () => { root.render(); await Promise.resolve(); await Promise.resolve(); }); + (executeAction as jest.Mock).mockResolvedValueOnce({ ok: false, status: 503, statusText: 'Unavailable', headers: {}, body: { message: 'controlled' } }); + await act(async () => { runtime.handleButtonClick('refresh'); await Promise.resolve(); await Promise.resolve(); }); + expect(runtime.componentState.inventory.loading).toBe(false); + expect(runtime.componentState.inventory.rows).toBeUndefined(); + expect(runtime.componentState.refresh.error).toContain('503'); + + (executeAction as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, headers: {}, body: { items: [] } }); + await act(async () => { runtime.handleButtonClick('refresh'); await Promise.resolve(); await Promise.resolve(); }); + expect(runtime.componentState.inventory.rows).toEqual([]); + expect(runtime.componentState.refresh.error).toBeUndefined(); + expect(JSON.stringify(doc)).toBe(canonicalBefore); + }); +}); diff --git a/frontend/src/components/Preview/usePreviewRuntime.ts b/frontend/src/components/Preview/usePreviewRuntime.ts index c49eeb5..f61d573 100644 --- a/frontend/src/components/Preview/usePreviewRuntime.ts +++ b/frontend/src/components/Preview/usePreviewRuntime.ts @@ -943,12 +943,24 @@ export function usePreviewRuntime(doc: ProjectDocument): PreviewRuntime { const handleTableRowSelect = useCallback( (tableId: string, index: number, row: Record) => { - setComponentState((prev) => ({ - ...prev, - [tableId]: { ...prev[tableId], selectedIndex: index, selectedRow: row }, - })); + const allComponents = doc.project.pages.flatMap((page) => page.components); + const table = allComponents.find((component) => component.id === tableId && component.type === 'Table'); + const updates: Record = { + [tableId]: { ...componentState[tableId], selectedIndex: index, selectedRow: row }, + }; + if (table) { + for (const binding of doc.project.bindings) { + if (binding.source !== `components.${table.name}.selectedRow` || (binding.trigger ?? 'onChange') !== 'onChange') continue; + const target = parseComponentTargetPath(binding.target); + if (!target || target.property !== 'value') continue; + const candidates = allComponents.filter((component) => component.name === target.componentName); + if (candidates.length !== 1 || !isTargetPropertySupported(candidates[0].type, target.property)) continue; + updates[candidates[0].id] = { ...componentState[candidates[0].id], value: row, loading: false, error: undefined }; + } + } + setComponentState((prev) => ({ ...prev, ...updates })); }, - [], + [componentState, doc.project], ); return { diff --git a/package-lock.json b/package-lock.json index 9a39382..c367c2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,29 @@ { - "name": "Conductor", + "name": "conductor", "lockfileVersion": 3, "requires": true, "packages": { "": { "devDependencies": { + "@playwright/test": "^1.54.2", "ajv-cli": "^5.0.0" } }, + "node_modules/@playwright/test": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.2.tgz", + "integrity": "sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==", + "dev": true, + "dependencies": { + "playwright": "1.54.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -152,6 +168,20 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -280,6 +310,36 @@ "node": ">=0.10.0" } }, + "node_modules/playwright": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.2.tgz", + "integrity": "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==", + "dev": true, + "dependencies": { + "playwright-core": "1.54.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.2.tgz", + "integrity": "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", diff --git a/package.json b/package.json index 785eac3..1f091b6 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,10 @@ { + "scripts": { + "test:e2e": "playwright test", + "test:schema": "bash scripts/validate-schema.sh" + }, "devDependencies": { + "@playwright/test": "^1.54.2", "ajv-cli": "^5.0.0" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..80cf9ef --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [['line']], + use: { + baseURL: process.env.CONDUCTOR_E2E_BASE_URL ?? 'http://127.0.0.1:3000', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, +}); diff --git a/scripts/validate-schema.sh b/scripts/validate-schema.sh new file mode 100755 index 0000000..34d24fb --- /dev/null +++ b/scripts/validate-schema.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +schema="shared/schemas/conductor-project.schema.json" +validator="./node_modules/.bin/ajv" +valid_count=0 +invalid_count=0 +diagnostic_count=0 + +for fixture in examples/project-definitions/valid-*.json; do + "$validator" validate -s "$schema" -d "$fixture" --spec=draft2020 >/dev/null + valid_count=$((valid_count + 1)) +done + +for fixture in \ + examples/project-definitions/invalid-dropdown-option-shape.json \ + examples/project-definitions/invalid-missing-required.json; do + if "$validator" validate -s "$schema" -d "$fixture" --spec=draft2020 >/dev/null 2>&1; then + echo "Expected schema rejection but fixture passed: $fixture" >&2 + exit 1 + fi + invalid_count=$((invalid_count + 1)) +done + +for fixture in \ + examples/project-definitions/invalid-template-malformed.json \ + examples/project-definitions/invalid-template-missing-component.json; do + "$validator" validate -s "$schema" -d "$fixture" --spec=draft2020 >/dev/null + diagnostic_count=$((diagnostic_count + 1)) +done + +echo "Schema matrix passed: $valid_count valid, $invalid_count expected-invalid, $diagnostic_count diagnostic-invalid." diff --git a/tests/e2e/release-workflows.spec.ts b/tests/e2e/release-workflows.spec.ts new file mode 100644 index 0000000..f087dd7 --- /dev/null +++ b/tests/e2e/release-workflows.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const demo = JSON.parse(readFileSync(path.resolve(__dirname, '../../examples/project-definitions/valid-release-demo.json'), 'utf8')); + +test('deterministic launcher, dependent data, dashboard, and save/reload workflow', async ({ page, request }) => { + test.setTimeout(60_000); + const name = `Slice 6 E2E ${Date.now()}`; + const document = structuredClone(demo); + document.project.name = name; + + const created = await request.post('http://127.0.0.1:4000/api/projects', { + data: { name, description: document.project.description, project_json: JSON.stringify(document) }, + }); + expect(created.status()).toBe(201); + const row = await created.json(); + + try { + await page.goto('/'); + await page.getByRole('button', { name: 'Visual Editor', exact: true }).click(); + await page.getByRole('button', { name: 'Load', exact: true }).click(); + await page.getByRole('button', { name: new RegExp(name) }).click(); + await expect(page.getByTitle('Click to rename project')).toContainText(name); + + await page.getByRole('button', { name: 'Preview', exact: true }).click(); + await expect(page.getByTestId('page-load-status')).toContainText('completed', { ignoreCase: true }); + + const environment = page.locator('select').first(); + await expect(environment.locator('option')).toHaveCount(3); + await environment.selectOption('prod'); + await page.locator('input[type="text"]').first().fill('bravo.example'); + await page.getByRole('button', { name: 'Launch workflow', exact: true }).click(); + await expect(page.getByText('req-demo-001', { exact: true })).toBeVisible(); + await expect(page.locator('pre').filter({ hasText: 'bravo.example' })).toBeVisible(); + + await page.getByRole('button', { name: 'Refresh inventory', exact: true }).click(); + await expect(page.getByRole('cell', { name: 'srv-202' })).toBeVisible(); + await page.getByRole('cell', { name: 'srv-202' }).click(); + await expect(page.locator('pre').filter({ hasText: 'srv-202' })).toBeVisible(); + + await page.getByRole('button', { name: 'Visual Editor', exact: true }).click(); + await page.getByRole('button', { name: 'Update', exact: true }).click(); + await expect(page.getByText(`Project "${name}" saved.`)).toBeVisible(); + await page.reload(); + await page.getByRole('button', { name: 'Visual Editor', exact: true }).click(); + await page.getByRole('button', { name: 'Load', exact: true }).click(); + await page.getByRole('button', { name: new RegExp(name) }).click(); + await page.getByRole('button', { name: 'Preview', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Launch workflow', exact: true })).toBeVisible(); + } finally { + await request.delete(`http://127.0.0.1:4000/api/projects/${row.id}`); + await request.delete('http://127.0.0.1:4000/api/executions'); + } +});