diff --git a/.github/workflows/browser-ux.yml b/.github/workflows/browser-ux.yml new file mode 100644 index 00000000..7509ae59 --- /dev/null +++ b/.github/workflows/browser-ux.yml @@ -0,0 +1,84 @@ +name: Browser UX + +on: + pull_request: + paths: + - automation/playwright/** + - deploy/bootstrap/profiles/** + - src/Agentstration.Web/** + - src/Agentstration.Web.Components/** + - src/Agentstration.Web.FlowDesigner/** + - src/Agentstration.Workplace.*/** + - global.json + - Directory.Build.props + - Directory.Build.targets + - Directory.Packages.props + - .github/workflows/browser-ux.yml + push: + branches: [main] + paths: + - automation/playwright/** + - deploy/bootstrap/profiles/** + - src/Agentstration.Web/** + - src/Agentstration.Web.Components/** + - src/Agentstration.Web.FlowDesigner/** + - src/Agentstration.Workplace.*/** + - global.json + - Directory.Build.props + - Directory.Build.targets + - Directory.Packages.props + - .github/workflows/browser-ux.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: browser-ux-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + browser-ux: + name: browser-ux + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Set up .NET + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: automation/playwright/package-lock.json + - name: Restore browser hosts + run: | + dotnet restore src/Agentstration.Web/Agentstration.Web.csproj -p:NuGetAudit=true -p:NuGetAuditMode=all + dotnet restore src/Agentstration.Workplace.Web/Agentstration.Workplace.Web.csproj -p:NuGetAudit=true -p:NuGetAuditMode=all + dotnet restore src/Agentstration.Extensions.Ollama/Agentstration.Extensions.Ollama.csproj -p:NuGetAudit=true -p:NuGetAuditMode=all + - name: Install browser automation dependencies + working-directory: automation/playwright + run: npm ci + - name: Audit browser automation dependencies + working-directory: automation/playwright + run: npm audit --audit-level=high + - name: Install Chromium + working-directory: automation/playwright + run: npx playwright install --with-deps chromium + - name: Run browser UX smoke tests + working-directory: automation/playwright + run: npm run test:smoke + - name: Upload browser diagnostics + if: failure() + uses: actions/upload-artifact@v6 + with: + name: browser-ux-diagnostics + path: | + automation/playwright/test-results + automation/playwright/playwright-report + automation/playwright/.work/*/*.log + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 2cfb7a2a..9b666400 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ deploy/compose/.env.postgresql docs/site/node_modules/ docs/site/build/ docs/site/.docusaurus/ +automation/playwright/node_modules/ +automation/playwright/test-results/ +automation/playwright/playwright-report/ +automation/playwright/.work/ diff --git a/README.md b/README.md index 72265af3..17702c8e 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,8 @@ dotnet build Agentstration.slnx --configuration Release dotnet test Agentstration.slnx --configuration Release ``` +Browser-level UX smoke tests and the reusable capture runner live under `automation/playwright`. They start isolated local Console and Workplace hosts with deterministic AI. See [Browser automation](docs/contributing/browser-automation.md) for setup and commands. + Warnings are treated as errors, .NET analyzers are enabled and NuGet audit findings fail restore. The default tests are designed to remain offline and cost-free; real-provider smoke tests are opt-in. ## Documentation diff --git a/automation/playwright/AGENTS.md b/automation/playwright/AGENTS.md new file mode 100644 index 00000000..236691ff --- /dev/null +++ b/automation/playwright/AGENTS.md @@ -0,0 +1,22 @@ +# Playwright automation instructions + +These instructions apply to `automation/playwright`. + +## Stable contracts + +- Declare every Playwright-owned `data-testid` value in `src/contracts/test-ids.ts` and use those constants from page objects. Razor markup remains the contract provider and must use the matching literal because TypeScript constants are not a runtime product dependency. +- Prefer accessible roles and labels for locale-specific semantic tests. Add a Test ID only for cross-locale journeys, ambiguous controls, stable capture targets, or non-visible readiness state. +- Declare journey checkpoint names in `src/contracts/checkpoints.ts`. Checkpoint values are external capture contracts: do not rename or remove one without updating example plans, documentation, and consumers. Add a new semantic state instead of encoding step numbers or layout positions. +- Keep independent localization expectations in `src/locales/expected-text.ts`. Never derive these expectations from product `.resx` files; doing so would make localization assertions circular. Add every supported locale value when extending the contract. + +## Page objects and journeys + +- Page objects own all locators and expose semantic operations or stable capture targets. Journeys must not contain raw Test ID strings, CSS selectors, or localized product text. +- A checkpoint represents a stable user-visible or domain state after the relevant wait condition succeeds. Emit checkpoints after meaningful blocks, not after arbitrary sleeps or individual DOM operations. +- Workspace creation and selection belong to the workspace page object and journey. Other journeys may select a prepared campaign workspace, but must not silently create, delete, or replace one. +- Treat a campaign Workspace as test-data isolation, not as localization state. Locale, theme, viewport, product revision, and output evidence remain separate campaign dimensions. + +## Validation + +- Add or update a Playwright test with every journey behavior change. +- Run `npm exec tsc -- --noEmit` and the affected Playwright test. Run `npm run test:smoke` before handoff when product hosts or shared page objects change. diff --git a/automation/playwright/README.md b/automation/playwright/README.md new file mode 100644 index 00000000..cdee95dd --- /dev/null +++ b/automation/playwright/README.md @@ -0,0 +1,63 @@ +# Agentstration browser automation + +This workspace owns the Playwright page objects and journeys used by product UX tests and reproducible external capture. + +```powershell +npm ci +npm run install:browsers +npm run test:smoke +``` + +To use Playwright UI against an already-running instance: + +```powershell +$env:AGENTSTRATION_CONSOLE_URL = "http://localhost:53400" +$env:AGENTSTRATION_WORKPLACE_URL = "http://localhost:53401" # Optional for Console-only tests. +$env:AGENTSTRATION_USERNAME = "admin" +$env:AGENTSTRATION_PASSWORD = "admin" +$env:AGENTSTRATION_PLAYWRIGHT_CHANNEL = "chrome" # Optional local fallback. +npx playwright test --ui +``` + +When `AGENTSTRATION_CONSOLE_URL` is present, the test fixture does not start or stop product hosts and requires the username/password pair. Journey input can explicitly override both values. Without an external URL, tests keep using dynamically allocated local ports, isolated SQLite data, and the public Development fixture `admin / admin`. An omitted Workplace URL falls back to the Console URL so Console-only journeys remain usable; set it explicitly before running a Workplace journey. External tests mutate the selected instance, so use a disposable instance or dedicated campaign Workspace. + +To exercise the capture contract: + +```powershell +npm run capture -- --plan examples/console-home.capture-plan.json --output .work/example-capture +``` + +The welcome-agent plan replays the first agent created in the handoff demo: + +```powershell +npm run capture -- --plan examples/create-welcome-agent.capture-plan.json --output .work/welcome-agent +``` + +Create and select a dedicated campaign workspace with: + +```powershell +npm run capture -- --plan examples/create-campaign-workspace.capture-plan.json --output .work/campaign-workspace +``` + +The solution-discovery video Flow and Entry are captured with: + +```powershell +npm run capture -- --plan examples/create-solution-discovery-flow.capture-plan.json --output .work/solution-discovery-flow --console-url https://agentstration.example.com +npm run capture -- --plan examples/create-solution-discovery-entry.capture-plan.json --output .work/solution-discovery-entry --console-url https://agentstration.example.com +``` + +Run these commands against the same persistent instance and campaign Workspace. The Flow plan expects the four agent technical names declared in its `participants` input. The Entry plan expects that Flow to have been published and activated first. + +To run that plan against an existing Console without starting local product hosts, override its URL from the command line: + +```powershell +npm run capture -- --plan examples/create-welcome-agent.capture-plan.json --output .work/welcome-agent --console-url https://agentstration.example.com +``` + +Command-line URLs take precedence over plan values. `--workplace-url` is optional for Console-only journeys and can be supplied when a journey also uses Workplace. + +On a persistent external instance, run the workspace plan first and set `workspaceName` in subsequent journey input. The target workspace must be prepared explicitly with every resource required by those journeys, such as model and runtime profiles. Local capture commands start isolated product state, so separate commands do not share a workspace. + +Profile inputs may be an ordered array when equivalent environments use different resource names. The journey selects the first available candidate and reports the available options immediately when none match. + +See [Browser automation](../../docs/contributing/browser-automation.md), [ADR-0109](../../docs/decisions/0109-product-owned-browser-journeys.md), and [ADR-0110](../../docs/decisions/0110-browser-campaigns-use-dedicated-workspaces.md) for ownership, extension, and external-consumption rules. diff --git a/automation/playwright/cli/capture.ts b/automation/playwright/cli/capture.ts new file mode 100644 index 00000000..0e181316 --- /dev/null +++ b/automation/playwright/cli/capture.ts @@ -0,0 +1,81 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { chromium } from '@playwright/test'; +import { parseCaptureCliOptions, resolveCaptureAddresses } from '../src/capture/capture-cli-options.js'; +import { readCapturePlan } from '../src/capture/capture-plan.js'; +import { createScreenshotRecorder, type CapturedAsset } from '../src/capture/screenshot-recorder.js'; +import { startProductHosts, type ProductHosts } from '../src/fixtures/product-hosts.js'; +import { repositoryRoot } from '../src/fixtures/repository.js'; +import { journeys } from '../src/journeys/registry.js'; +import { ProductPages } from '../src/pages/product.pages.js'; + +const executeFile = promisify(execFile); +const options = parseCaptureCliOptions(process.argv.slice(2)); +const outputDirectory = path.resolve(options.outputDirectory); +const plan = await readCapturePlan(path.resolve(options.planFile)); +const journey = journeys[plan.journey]; +if (!journey) throw new Error(`Unknown journey '${plan.journey}'. Available journeys: ${Object.keys(journeys).join(', ')}`); +const { stdout: headCommit } = await executeFile('git', ['rev-parse', 'HEAD'], { cwd: repositoryRoot }); +const productCommit = headCommit.trim(); +if (plan.productRef) { + const { stdout: requestedCommit } = await executeFile('git', ['rev-parse', `${plan.productRef}^{commit}`], { cwd: repositoryRoot }); + if (requestedCommit.trim() !== productCommit) { + throw new Error(`Capture plan requires ${plan.productRef} (${requestedCommit.trim()}) but the checkout is ${productCommit}.`); + } +} + +let product: ProductHosts | undefined; +const externalAddresses = resolveCaptureAddresses(options, plan); +const addresses = externalAddresses ?? (product = await startProductHosts()); + +const browser = await chromium.launch({ + headless: true, + channel: process.env.AGENTSTRATION_PLAYWRIGHT_CHANNEL, +}); +const assets: CapturedAsset[] = []; +try { + const context = await browser.newContext({ + locale: plan.locale ?? 'en-US', + colorScheme: plan.theme ?? 'dark', + viewport: plan.viewport ?? { width: 1440, height: 1000 }, + }); + const page = await context.newPage(); + page.setDefaultTimeout(120_000); + page.setDefaultNavigationTimeout(120_000); + await journey({ + ...addresses, + pages: new ProductPages(page), + theme: plan.theme ?? 'dark', + checkpoint: createScreenshotRecorder(plan, outputDirectory, assets), + }, plan.input ?? {}); + await context.close(); + + if (assets.length !== plan.captures.length) { + const captured = new Set(assets.map(value => value.checkpoint)); + const missing = plan.captures.filter(value => !captured.has(value.checkpoint)).map(value => value.checkpoint); + throw new Error(`Journey did not reach requested checkpoints: ${missing.join(', ')}`); + } + + await fs.mkdir(outputDirectory, { recursive: true }); + const { stdout: status } = await executeFile('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: repositoryRoot }); + const manifest = { + productRef: plan.productRef, + productCommit, + productDirty: status.trim().length > 0, + journey: plan.journey, + playwrightVersion: (await import('@playwright/test/package.json', { with: { type: 'json' } })).default.version, + browser: process.env.AGENTSTRATION_PLAYWRIGHT_CHANNEL ?? 'chromium', + browserVersion: browser.version(), + locale: plan.locale ?? 'en-US', + theme: plan.theme ?? 'dark', + viewport: plan.viewport ?? { width: 1440, height: 1000 }, + assets, + }; + await fs.writeFile(path.join(outputDirectory, 'capture-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + console.log(JSON.stringify(manifest, null, 2)); +} finally { + await browser.close(); + await product?.stop(); +} diff --git a/automation/playwright/examples/console-home.capture-plan.json b/automation/playwright/examples/console-home.capture-plan.json new file mode 100644 index 00000000..a1f33c3f --- /dev/null +++ b/automation/playwright/examples/console-home.capture-plan.json @@ -0,0 +1,17 @@ +{ + "journey": "authenticate-console", + "locale": "en-US", + "theme": "dark", + "viewport": { + "width": 1440, + "height": 1000 + }, + "captures": [ + { + "checkpoint": "console-home", + "file": "console-home.png", + "scope": "page", + "fullPage": true + } + ] +} diff --git a/automation/playwright/examples/create-campaign-workspace.capture-plan.json b/automation/playwright/examples/create-campaign-workspace.capture-plan.json new file mode 100644 index 00000000..4c142c72 --- /dev/null +++ b/automation/playwright/examples/create-campaign-workspace.capture-plan.json @@ -0,0 +1,37 @@ +{ + "journey": "create-workspace", + "input": { + "name": "capture-handoff", + "displayName": "Handoff capture campaign" + }, + "locale": "en-US", + "theme": "dark", + "viewport": { + "width": 1920, + "height": 1080 + }, + "captures": [ + { + "checkpoint": "workspace-form-empty", + "file": "01-workspace-form-empty.png", + "scope": "page", + "fullPage": false + }, + { + "checkpoint": "workspace-identity-complete", + "file": "02-workspace-identity-complete.png", + "scope": "target" + }, + { + "checkpoint": "workspace-created", + "file": "03-workspace-created.png", + "scope": "target" + }, + { + "checkpoint": "workspace-selected", + "file": "04-workspace-selected.png", + "scope": "page", + "fullPage": false + } + ] +} diff --git a/automation/playwright/examples/create-solution-discovery-entry.capture-plan.json b/automation/playwright/examples/create-solution-discovery-entry.capture-plan.json new file mode 100644 index 00000000..b48bd1cc --- /dev/null +++ b/automation/playwright/examples/create-solution-discovery-entry.capture-plan.json @@ -0,0 +1,57 @@ +{ + "journey": "create-entry", + "input": { + "workspaceName": "capture-handoff", + "name": "solution-discovery", + "displayName": "Découvrir Agentstration", + "description": "Échangez avec une équipe d’agents spécialisés pour découvrir la solution et poser vos questions.", + "presentationKind": "Prompt", + "placeholder": "Que souhaitez-vous savoir sur Agentstration ?", + "icon": "briefcase", + "participantsVisibility": "Hidden", + "progressVisibility": "Compact", + "taskDisplay": "Auto", + "resultsDisplay": "Auto", + "field": { + "name": "request", + "label": "Votre question", + "description": "Décrivez votre besoin ou posez une question sur la solution.", + "placeholder": "Que souhaitez-vous savoir sur Agentstration ?", + "type": "Prompt", + "required": true, + "primary": true + }, + "targetFlow": "solution-discovery", + "targetNamespace": "default", + "taskCreationMode": "Automatic", + "allowConversation": true, + "streamResponse": true, + "suggestions": [ + { + "label": "Plusieurs agents", + "value": "Nous cherchons une solution pour faire collaborer plusieurs agents spécialisés. Est-ce qu’Agentstration peut répondre à ce besoin ?" + }, + { + "label": "Exécution locale", + "value": "Certaines de nos données doivent rester entièrement sur notre infrastructure. Peut-on utiliser des modèles locaux ?" + }, + { + "label": "Outils internes", + "value": "Comment nos agents peuvent-ils utiliser nos outils internes ou appeler nos propres services ?" + } + ] + }, + "locale": "fr-FR", + "theme": "dark", + "viewport": { "width": 1920, "height": 1080 }, + "captures": [ + { "checkpoint": "entry-identity-complete", "file": "01-entry-identity-complete.png", "scope": "target" }, + { "checkpoint": "entry-appearance-complete", "file": "02-entry-appearance-complete.png", "scope": "target" }, + { "checkpoint": "entry-interaction-complete", "file": "03-entry-interaction-complete.png", "scope": "target" }, + { "checkpoint": "entry-field-complete", "file": "04-entry-field-complete.png", "scope": "target" }, + { "checkpoint": "entry-binding-complete", "file": "05-entry-binding-complete.png", "scope": "target" }, + { "checkpoint": "entry-suggestions-complete", "file": "06-entry-suggestions-complete.png", "scope": "target" }, + { "checkpoint": "entry-ready-to-publish", "file": "07-entry-ready-to-publish.png", "scope": "page", "fullPage": false }, + { "checkpoint": "entry-published", "file": "08-entry-published.png", "scope": "page", "fullPage": false } + ] +} diff --git a/automation/playwright/examples/create-solution-discovery-flow.capture-plan.json b/automation/playwright/examples/create-solution-discovery-flow.capture-plan.json new file mode 100644 index 00000000..dbe508ad --- /dev/null +++ b/automation/playwright/examples/create-solution-discovery-flow.capture-plan.json @@ -0,0 +1,45 @@ +{ + "journey": "create-handoff-flow", + "input": { + "workspaceName": "capture-handoff", + "name": "solution-discovery", + "displayName": "Découvrir Agentstration", + "description": "Une équipe de spécialistes se transmet la conversation selon les sujets abordés par l’utilisateur.", + "version": "0.1.3", + "enabled": true, + "participants": [ + "accueil", + "conseiller-solution", + "expert-technique", + "expert-integration" + ], + "initialParticipant": "accueil", + "routes": [ + { "from": "accueil", "to": "conseiller-solution" }, + { "from": "accueil", "to": "expert-technique" }, + { "from": "accueil", "to": "expert-integration" }, + { "from": "conseiller-solution", "to": "expert-technique" }, + { "from": "conseiller-solution", "to": "expert-integration" }, + { "from": "conseiller-solution", "to": "accueil" }, + { "from": "expert-technique", "to": "conseiller-solution" }, + { "from": "expert-technique", "to": "expert-integration" }, + { "from": "expert-technique", "to": "accueil" }, + { "from": "expert-integration", "to": "conseiller-solution" }, + { "from": "expert-integration", "to": "expert-technique" }, + { "from": "expert-integration", "to": "accueil" } + ], + "autonomous": true, + "maximumTurnsPerParticipant": 4, + "terminationPhrase": "[[HANDOFF_COMPLETE]]" + }, + "locale": "fr-FR", + "theme": "dark", + "viewport": { "width": 1920, "height": 1080 }, + "captures": [ + { "checkpoint": "flow-identity-complete", "file": "01-flow-identity-complete.png", "scope": "target" }, + { "checkpoint": "flow-participants-complete", "file": "02-flow-participants-complete.png", "scope": "target" }, + { "checkpoint": "flow-strategy-complete", "file": "03-flow-strategy-complete.png", "scope": "target" }, + { "checkpoint": "flow-created", "file": "04-flow-created.png", "scope": "page", "fullPage": false }, + { "checkpoint": "flow-published", "file": "05-flow-published.png", "scope": "page", "fullPage": false } + ] +} diff --git a/automation/playwright/examples/create-welcome-agent.capture-plan.json b/automation/playwright/examples/create-welcome-agent.capture-plan.json new file mode 100644 index 00000000..d28dfaff --- /dev/null +++ b/automation/playwright/examples/create-welcome-agent.capture-plan.json @@ -0,0 +1,45 @@ +{ + "journey": "create-agent", + "input": { + "name": "accueil-capture", + "displayName": "Agent d’accueil", + "description": "Accueille l’utilisateur, comprend sa demande et l’oriente vers le spécialiste adapté.", + "instructions": "Tu es l’Agent d’accueil de l’expérience « Découvrir Agentstration ». Pour chaque nouveau message, identifie le besoin principal. N’apporte pas toi-même la réponse de fond et n’annonce pas le transfert.\n\nChoisis conseiller-solution pour la collaboration entre agents, les usages, la valeur, l’adoption, le positionnement ou une première approche.\nChoisis expert-technique pour l’architecture, l’orchestration, les Flows, les modèles locaux, Ollama, les données locales ou le déploiement.\nChoisis expert-integration pour les Tools, MCP, AEP, API, connecteurs ou systèmes externes.\n\nIdentifie le spécialiste le plus pertinent et transfère-lui la demande. Si une autre expertise est ensuite nécessaire, le spécialiste actif pourra poursuivre le handoff vers l’agent approprié. Réponds toujours en français.", + "modelProfile": [ + "default:default-reasoning", + "default:reasoning-default" + ], + "runtimeProfile": "default:maf-builtin" + }, + "locale": "fr-FR", + "theme": "dark", + "viewport": { + "width": 1920, + "height": 1080 + }, + "captures": [ + { + "checkpoint": "agent-form-empty", + "file": "01-agent-form-empty.png", + "scope": "page", + "fullPage": false + }, + { + "checkpoint": "agent-identity-complete", + "file": "02-agent-identity-complete.png", + "scope": "target" + }, + { + "checkpoint": "agent-ready-to-create", + "file": "03-agent-ready-to-create.png", + "scope": "page", + "fullPage": false + }, + { + "checkpoint": "agent-created", + "file": "04-agent-created.png", + "scope": "page", + "fullPage": false + } + ] +} diff --git a/automation/playwright/package-lock.json b/automation/playwright/package-lock.json new file mode 100644 index 00000000..be28b91e --- /dev/null +++ b/automation/playwright/package-lock.json @@ -0,0 +1,976 @@ +{ + "name": "agentstration-browser-automation", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agentstration-browser-automation", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "22.18.6", + "tsx": "4.23.13", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "22.18.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", + "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/automation/playwright/package.json b/automation/playwright/package.json new file mode 100644 index 00000000..294f6cab --- /dev/null +++ b/automation/playwright/package.json @@ -0,0 +1,21 @@ +{ + "name": "agentstration-browser-automation", + "private": true, + "version": "0.1.0", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "install:browsers": "playwright install chromium", + "test": "playwright test", + "test:smoke": "playwright test --grep @smoke", + "capture": "tsx cli/capture.ts" + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "22.18.6", + "tsx": "4.23.13", + "typescript": "7.0.2" + } +} diff --git a/automation/playwright/playwright.config.ts b/automation/playwright/playwright.config.ts new file mode 100644 index 00000000..5dde5050 --- /dev/null +++ b/automation/playwright/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 120_000, + expect: { timeout: 15_000 }, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + channel: process.env.AGENTSTRATION_PLAYWRIGHT_CHANNEL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + locale: 'en-US', + colorScheme: 'dark', + viewport: { width: 1440, height: 1000 }, + }, + outputDir: 'test-results', +}); diff --git a/automation/playwright/src/capture/capture-cli-options.ts b/automation/playwright/src/capture/capture-cli-options.ts new file mode 100644 index 00000000..0a79fb2c --- /dev/null +++ b/automation/playwright/src/capture/capture-cli-options.ts @@ -0,0 +1,58 @@ +import type { CapturePlan } from './capture-plan.js'; +import { normalizeProductUrl } from '../fixtures/product-addresses.js'; + +export interface CaptureCliOptions { + planFile: string; + outputDirectory: string; + consoleUrl?: string; + workplaceUrl?: string; +} + +export interface CaptureAddresses { + consoleUrl: string; + workplaceUrl: string; +} + +const supportedArguments = new Set(['plan', 'output', 'console-url', 'workplace-url']); + +export function parseCaptureCliOptions(values: string[]): CaptureCliOptions { + const argumentsMap = new Map(); + for (let index = 0; index < values.length; index += 2) { + const key = values[index]; + const value = values[index + 1]; + if (!key?.startsWith('--') || !value) { + throw new Error('Expected --plan --output [--console-url ] [--workplace-url ].'); + } + + const name = key.slice(2); + if (!supportedArguments.has(name)) throw new Error(`Unknown argument '--${name}'.`); + if (argumentsMap.has(name)) throw new Error(`Argument '--${name}' was supplied more than once.`); + argumentsMap.set(name, value); + } + + return { + planFile: required(argumentsMap, 'plan'), + outputDirectory: required(argumentsMap, 'output'), + consoleUrl: argumentsMap.get('console-url'), + workplaceUrl: argumentsMap.get('workplace-url'), + }; +} + +export function resolveCaptureAddresses(options: CaptureCliOptions, plan: CapturePlan): CaptureAddresses | undefined { + const consoleUrl = options.consoleUrl ?? plan.consoleUrl; + const workplaceUrl = options.workplaceUrl ?? (options.consoleUrl ? undefined : plan.workplaceUrl); + if (!consoleUrl && workplaceUrl) throw new Error('A Workplace URL requires --console-url or consoleUrl in the plan.'); + if (!consoleUrl) return undefined; + + const normalizedConsoleUrl = normalizeProductUrl(consoleUrl, 'Console'); + return { + consoleUrl: normalizedConsoleUrl, + workplaceUrl: workplaceUrl ? normalizeProductUrl(workplaceUrl, 'Workplace') : normalizedConsoleUrl, + }; +} + +function required(values: Map, name: string): string { + const value = values.get(name); + if (!value) throw new Error(`--${name} is required.`); + return value; +} diff --git a/automation/playwright/src/capture/capture-plan.ts b/automation/playwright/src/capture/capture-plan.ts new file mode 100644 index 00000000..8fa889bc --- /dev/null +++ b/automation/playwright/src/capture/capture-plan.ts @@ -0,0 +1,33 @@ +import fs from 'node:fs/promises'; + +export interface CaptureRequest { + checkpoint: string; + file: string; + scope?: 'page' | 'target'; + fullPage?: boolean; +} + +export interface CapturePlan { + productRef?: string; + journey: string; + input?: Record; + locale?: string; + theme?: 'light' | 'dark'; + viewport?: { width: number; height: number }; + consoleUrl?: string; + workplaceUrl?: string; + captures: CaptureRequest[]; +} + +export async function readCapturePlan(file: string): Promise { + const value: unknown = JSON.parse(await fs.readFile(file, 'utf8')); + if (!value || typeof value !== 'object') throw new Error('Capture plan must be a JSON object.'); + const plan = value as Partial; + if (!plan.journey || typeof plan.journey !== 'string') throw new Error('Capture plan journey is required.'); + if (!Array.isArray(plan.captures) || plan.captures.length === 0) throw new Error('Capture plan must request at least one checkpoint.'); + for (const capture of plan.captures) { + if (!capture.checkpoint || !capture.file) throw new Error('Every capture requires checkpoint and file values.'); + } + if (!plan.consoleUrl && plan.workplaceUrl) throw new Error('workplaceUrl requires consoleUrl.'); + return plan as CapturePlan; +} diff --git a/automation/playwright/src/capture/screenshot-recorder.ts b/automation/playwright/src/capture/screenshot-recorder.ts new file mode 100644 index 00000000..83efdfdf --- /dev/null +++ b/automation/playwright/src/capture/screenshot-recorder.ts @@ -0,0 +1,39 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { JourneyCheckpoint } from '../journeys/journey.js'; +import type { CapturePlan } from './capture-plan.js'; + +export interface CapturedAsset { + checkpoint: string; + file: string; + sha256: string; +} + +export function createScreenshotRecorder(plan: CapturePlan, outputDirectory: string, assets: CapturedAsset[]) { + const pending = new Map(plan.captures.map(value => [value.checkpoint, value])); + return async (checkpoint: JourneyCheckpoint): Promise => { + const request = pending.get(checkpoint.name); + if (!request) return; + + const destination = path.resolve(outputDirectory, request.file); + const relative = path.relative(path.resolve(outputDirectory), destination); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Capture file must stay inside the output directory: ${request.file}`); + } + await fs.mkdir(path.dirname(destination), { recursive: true }); + if (request.scope === 'target') { + if (!checkpoint.target) throw new Error(`Checkpoint ${checkpoint.name} has no target.`); + await checkpoint.target.screenshot({ path: destination }); + } else { + await checkpoint.page.screenshot({ path: destination, fullPage: request.fullPage ?? true }); + } + const bytes = await fs.readFile(destination); + assets.push({ + checkpoint: checkpoint.name, + file: request.file, + sha256: createHash('sha256').update(bytes).digest('hex'), + }); + pending.delete(checkpoint.name); + }; +} diff --git a/automation/playwright/src/contracts/checkpoints.ts b/automation/playwright/src/contracts/checkpoints.ts new file mode 100644 index 00000000..7b7ebfd1 --- /dev/null +++ b/automation/playwright/src/contracts/checkpoints.ts @@ -0,0 +1,38 @@ +export const Checkpoints = { + console: { + home: 'console-home', + }, + createAgent: { + formEmpty: 'agent-form-empty', + identityComplete: 'agent-identity-complete', + behaviorComplete: 'agent-behavior-complete', + readyToCreate: 'agent-ready-to-create', + created: 'agent-created', + }, + createFlow: { + formInitial: 'flow-form-initial', + identityComplete: 'flow-identity-complete', + participantsComplete: 'flow-participants-complete', + strategyComplete: 'flow-strategy-complete', + created: 'flow-created', + published: 'flow-published', + }, + createEntry: { + formInitial: 'entry-form-initial', + identityComplete: 'entry-identity-complete', + appearanceComplete: 'entry-appearance-complete', + interactionComplete: 'entry-interaction-complete', + fieldComplete: 'entry-field-complete', + bindingComplete: 'entry-binding-complete', + behaviorComplete: 'entry-behavior-complete', + suggestionsComplete: 'entry-suggestions-complete', + readyToPublish: 'entry-ready-to-publish', + published: 'entry-published', + }, + createWorkspace: { + formEmpty: 'workspace-form-empty', + identityComplete: 'workspace-identity-complete', + created: 'workspace-created', + selected: 'workspace-selected', + }, +} as const; diff --git a/automation/playwright/src/contracts/test-ids.ts b/automation/playwright/src/contracts/test-ids.ts new file mode 100644 index 00000000..4a82927a --- /dev/null +++ b/automation/playwright/src/contracts/test-ids.ts @@ -0,0 +1,106 @@ +export const TestIds = { + common: { + iconPicker: 'icon-picker', + }, + console: { + shell: 'console-shell', + platformOverview: 'platform-overview', + themeToggle: 'theme-toggle', + workspaceSelector: 'workspace-selector', + }, + login: { + username: 'login-username', + password: 'login-password', + submit: 'login-submit', + }, + agentEditor: { + form: 'agent-editor-form', + identitySection: 'agent-identity-section', + behaviorSection: 'agent-behavior-section', + name: 'agent-name', + displayName: 'agent-display-name', + description: 'agent-description', + modelProfile: 'model-profile-select', + runtimeProfile: 'agent-runtime-profile', + instructions: 'agent-instructions', + createAndDeploy: 'agent-create-and-deploy', + }, + flowEditor: { + form: 'flow-create-form', + identitySection: 'flow-identity-section', + name: 'flow-name', + displayName: 'flow-display-name', + description: 'flow-description', + version: 'flow-version', + orchestrationKind: 'flow-kind-orchestration', + continue: 'flow-continue', + configuration: 'flow-orchestration-configuration', + enabled: 'flow-enabled', + participantsSection: 'flow-participants-section', + participant: 'flow-participant', + strategySection: 'flow-strategy-section', + strategy: 'flow-strategy', + initialParticipant: 'flow-initial-participant', + addRoute: 'flow-add-route', + route: 'flow-handoff-route', + routeFrom: 'flow-handoff-from', + routeTo: 'flow-handoff-to', + autonomous: 'flow-autonomous-handoffs', + maximumTurns: 'flow-maximum-turns', + terminationPhrase: 'flow-termination-phrase', + preview: 'flow-execution-preview', + create: 'flow-create', + orchestrationEditor: 'flow-orchestration-editor', + publish: 'flow-publish-and-activate', + statusMessage: 'flow-status-message', + }, + entryEditor: { + status: 'entry-status', + definitionFields: 'entry-definition-fields', + identitySection: 'entry-identity-section', + name: 'entry-name', + displayName: 'entry-display-name', + description: 'entry-description', + appearanceSection: 'entry-appearance-section', + presentationKind: 'presentation-kind', + placeholder: 'entry-placeholder', + interactionSection: 'entry-interaction-section', + participantsVisibility: 'participants-visibility', + progressVisibility: 'progress-visibility', + taskDisplay: 'task-display', + resultsDisplay: 'results-display', + fieldsSection: 'entry-fields-section', + fieldCard: 'entry-field-card', + fieldName: 'entry-field-name', + fieldLabel: 'entry-field-label', + fieldDescription: 'entry-field-description', + fieldPlaceholder: 'entry-field-placeholder', + fieldType: 'entry-field-type', + fieldRequired: 'entry-field-required', + fieldPrimary: 'entry-field-primary', + bindingSection: 'entry-binding-section', + targetKind: 'target-kind', + targetResource: 'target-resource', + behaviorSection: 'entry-behavior-section', + taskMode: 'entry-task-mode', + allowConversation: 'entry-allow-conversation', + streamResponse: 'entry-stream-response', + suggestionsSection: 'entry-suggestions-section', + addSuggestion: 'entry-add-suggestion', + suggestionCard: 'entry-suggestion-card', + suggestionLabel: 'entry-suggestion-label', + suggestionValue: 'entry-suggestion-value', + removeSuggestion: 'remove-suggestion', + save: 'entry-save-draft', + validate: 'entry-validate', + publish: 'entry-publish', + message: 'entry-message', + }, + organizationWorkspaces: { + createForm: 'workspace-create-form', + displayName: 'workspace-display-name', + technicalName: 'workspace-technical-name', + create: 'workspace-create', + row: 'workspace-row', + }, +} as const; diff --git a/automation/playwright/src/fixtures/campaign-workspace.ts b/automation/playwright/src/fixtures/campaign-workspace.ts new file mode 100644 index 00000000..2fe6090f --- /dev/null +++ b/automation/playwright/src/fixtures/campaign-workspace.ts @@ -0,0 +1,21 @@ +export const campaignWorkspaceEnvironment = { + name: 'AGENTSTRATION_WORKSPACE_NAME', +} as const; + +export function resolveCampaignWorkspaceName( + explicitName: string | undefined, + environment: NodeJS.ProcessEnv = process.env, +): string | undefined { + if (explicitName !== undefined) { + const normalized = optional(explicitName); + if (!normalized) throw new Error("Journey workspaceName must not be empty."); + return normalized; + } + + return optional(environment[campaignWorkspaceEnvironment.name]); +} + +function optional(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} diff --git a/automation/playwright/src/fixtures/external-product.ts b/automation/playwright/src/fixtures/external-product.ts new file mode 100644 index 00000000..d52c6bc5 --- /dev/null +++ b/automation/playwright/src/fixtures/external-product.ts @@ -0,0 +1,27 @@ +import type { ProductAddresses } from './product-hosts.js'; +import { normalizeProductUrl } from './product-addresses.js'; + +export const externalProductEnvironment = { + consoleUrl: 'AGENTSTRATION_CONSOLE_URL', + workplaceUrl: 'AGENTSTRATION_WORKPLACE_URL', +} as const; + +export function resolveExternalProductAddresses(environment: NodeJS.ProcessEnv): ProductAddresses | undefined { + const consoleUrl = optional(environment[externalProductEnvironment.consoleUrl]); + const workplaceUrl = optional(environment[externalProductEnvironment.workplaceUrl]); + if (!consoleUrl && workplaceUrl) { + throw new Error(`${externalProductEnvironment.workplaceUrl} requires ${externalProductEnvironment.consoleUrl}.`); + } + if (!consoleUrl) return undefined; + + const normalizedConsoleUrl = normalizeProductUrl(consoleUrl, 'Console'); + return { + consoleUrl: normalizedConsoleUrl, + workplaceUrl: workplaceUrl ? normalizeProductUrl(workplaceUrl, 'Workplace') : normalizedConsoleUrl, + }; +} + +function optional(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} diff --git a/automation/playwright/src/fixtures/login-credentials.ts b/automation/playwright/src/fixtures/login-credentials.ts new file mode 100644 index 00000000..23092013 --- /dev/null +++ b/automation/playwright/src/fixtures/login-credentials.ts @@ -0,0 +1,42 @@ +export interface LoginCredentials { + username: string; + password: string; +} + +export const loginCredentialEnvironment = { + username: 'AGENTSTRATION_USERNAME', + password: 'AGENTSTRATION_PASSWORD', +} as const; + +export function resolveLoginCredentials( + explicitUsername: string | undefined, + explicitPassword: string | undefined, + environment: NodeJS.ProcessEnv = process.env, +): LoginCredentials { + const hasExplicitCredentials = explicitUsername !== undefined || explicitPassword !== undefined; + if (hasExplicitCredentials) { + if (!explicitUsername || !explicitPassword) throw new Error('Journey login credentials require both username and password.'); + return { username: explicitUsername, password: explicitPassword }; + } + + const username = optional(environment[loginCredentialEnvironment.username]); + const password = optional(environment[loginCredentialEnvironment.password]); + if (username || password) { + if (!username || !password) { + throw new Error(`${loginCredentialEnvironment.username} and ${loginCredentialEnvironment.password} must be provided together.`); + } + return { username, password }; + } + + if (optional(environment.AGENTSTRATION_CONSOLE_URL)) { + throw new Error( + `External Playwright instances require ${loginCredentialEnvironment.username} and ${loginCredentialEnvironment.password}.`, + ); + } + + return { username: 'admin', password: 'admin' }; +} + +function optional(value: string | undefined): string | undefined { + return value && value.length > 0 ? value : undefined; +} diff --git a/automation/playwright/src/fixtures/product-addresses.ts b/automation/playwright/src/fixtures/product-addresses.ts new file mode 100644 index 00000000..e7ebc5f3 --- /dev/null +++ b/automation/playwright/src/fixtures/product-addresses.ts @@ -0,0 +1,12 @@ +export function normalizeProductUrl(value: string, label: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${label} URL '${value}' is not a valid absolute URL.`); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`${label} URL must use http or https.`); + } + return value.replace(/\/+$/, ''); +} diff --git a/automation/playwright/src/fixtures/product-hosts.ts b/automation/playwright/src/fixtures/product-hosts.ts new file mode 100644 index 00000000..11c0f337 --- /dev/null +++ b/automation/playwright/src/fixtures/product-hosts.ts @@ -0,0 +1,237 @@ +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { createWriteStream } from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import net from 'node:net'; +import path from 'node:path'; +import type { Readable } from 'node:stream'; +import { automationRoot, repositoryRoot } from './repository.js'; + +export interface ProductAddresses { + consoleUrl: string; + workplaceUrl: string; +} + +export interface ProductHosts extends ProductAddresses { + stop(): Promise; +} + +interface ManagedProcess { + child: ChildProcessByStdio; + output: string[]; +} + +interface FakeOllama { + url: string; + stop(): Promise; +} + +const startupTimeoutMilliseconds = 180_000; + +export async function startProductHosts(): Promise { + const runId = `${Date.now()}-${process.pid}`; + const workDirectory = path.join(automationRoot, '.work', runId); + const dataDirectory = path.join(workDirectory, 'data'); + await fs.mkdir(dataDirectory, { recursive: true }); + + const [consolePort, workplacePort, extensionPort] = await Promise.all([freePort(), freePort(), freePort()]); + const consoleUrl = `http://127.0.0.1:${consolePort}`; + const workplaceUrl = `http://127.0.0.1:${workplacePort}`; + const extensionUrl = `http://127.0.0.1:${extensionPort}`; + const bootstrapPath = path.join(repositoryRoot, 'deploy', 'bootstrap', 'profiles'); + + const fakeOllama = await startFakeOllama(); + const modelExtension = runDotnet('src/Agentstration.Extensions.Ollama/Agentstration.Extensions.Ollama.csproj', path.join(workDirectory, 'model-extension.log'), { + ASPNETCORE_ENVIRONMENT: 'Development', + ASPNETCORE_URLS: extensionUrl, + Logging__EventLog__LogLevel__Default: 'None', + Ollama__Endpoint: fakeOllama.url, + }); + + try { + await waitUntilHealthy(`${extensionUrl}/health`, modelExtension); + } catch (error) { + await stopProcess(modelExtension); + await fakeOllama.stop(); + throw error; + } + + const consoleHost = runDotnet('src/Agentstration.Web/Agentstration.Web.csproj', path.join(workDirectory, 'console.log'), { + ASPNETCORE_ENVIRONMENT: 'Development', + ASPNETCORE_URLS: consoleUrl, + Logging__EventLog__LogLevel__Default: 'None', + Data__Directory: dataDirectory, + Data__ControlPlanePath: path.join(dataDirectory, 'control-plane.db'), + Data__WorkPlanePath: path.join(dataDirectory, 'work-plane.db'), + Data__FlowPath: path.join(dataDirectory, 'flow-plane.db'), + Data__RuntimePath: path.join(dataDirectory, 'runtime-plane.db'), + AI__Provider: 'Deterministic', + Agentstration__Authentication__Mode: 'Development', + Agentstration__Bootstrap__Path: bootstrapPath, + Agentstration__Bootstrap__InitialBootstrapEnabled: 'true', + Agentstration__Bootstrap__InitialProfiles__0: 'development', + Agentstration__ManagementApi__BaseAddress: `${consoleUrl}/`, + Agentstration__RuntimeApi__BaseAddress: `${consoleUrl}/`, + Agentstration__WorkApi__BaseAddress: `${consoleUrl}/`, + Agentstration__FlowApi__BaseAddress: `${consoleUrl}/`, + Agentstration__WorkplaceBaseUrl: `${workplaceUrl}/`, + Agentstration__Extensions__DiscoverOnStartup: 'false', + }, [`--Agentstration:Extensions:Agentstration.Extensions.Ollama:Endpoint=${extensionUrl}`]); + + let workplaceHost: ManagedProcess | undefined; + try { + await waitUntilHealthy(`${consoleUrl}/health/ready`, consoleHost); + workplaceHost = runDotnet('src/Agentstration.Workplace.Web/Agentstration.Workplace.Web.csproj', path.join(workDirectory, 'workplace.log'), { + ASPNETCORE_ENVIRONMENT: 'Development', + ASPNETCORE_URLS: workplaceUrl, + Logging__EventLog__LogLevel__Default: 'None', + Agentstration__ApiBaseUrl: `${consoleUrl}/`, + Agentstration__WorkplaceHubUrl: `${consoleUrl}/hubs/workplace`, + }); + await waitUntilHealthy(`${workplaceUrl}/health`, workplaceHost); + } catch (error) { + await stopProcess(workplaceHost); + await stopProcess(consoleHost); + await stopProcess(modelExtension); + await fakeOllama.stop(); + throw error; + } + + return { + consoleUrl, + workplaceUrl, + async stop() { + await stopProcess(workplaceHost); + await stopProcess(consoleHost); + await stopProcess(modelExtension); + await fakeOllama.stop(); + }, + }; +} + +async function startFakeOllama(): Promise { + const server = createServer((request, response) => { + if (request.method === 'GET' && request.url === '/') { + response.setHeader('Content-Type', 'text/plain'); + response.end('Ollama is running'); + return; + } + response.setHeader('Content-Type', 'application/json'); + if (request.method === 'GET' && request.url === '/api/version') { + response.end(JSON.stringify({ version: '0.0.0-browser-fixture' })); + return; + } + if (request.method === 'GET' && request.url === '/api/tags') { + response.end(JSON.stringify({ + models: ['qwen3:1.7b', 'deterministic'].map(name => ({ + name, + model: name, + modified_at: '2026-01-01T00:00:00Z', + size: 1, + digest: `sha256:browser-fixture-${name}`, + details: { parameter_size: 'test', quantization_level: 'test' }, + })), + })); + return; + } + response.statusCode = 404; + response.end(JSON.stringify({ error: 'Not found in browser fixture.' })); + }); + const port = await listenOnLoopback(server); + return { + url: `http://127.0.0.1:${port}`, + stop: async () => await closeServer(server), + }; +} + +async function listenOnLoopback(server: Server): Promise { + return await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Could not allocate the fake Ollama endpoint.')); + return; + } + resolve(address.port); + }); + }); +} + +async function closeServer(server: Server): Promise { + if (!server.listening) return; + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); +} + +function runDotnet(project: string, logFile: string, environment: NodeJS.ProcessEnv, applicationArguments: string[] = []): ManagedProcess { + const argumentsList = [ + 'run', + '--project', project, + '--configuration', process.env.AGENTSTRATION_PLAYWRIGHT_CONFIGURATION ?? 'Release', + '--no-launch-profile', + ]; + if (process.env.AGENTSTRATION_PLAYWRIGHT_NO_BUILD === 'true') argumentsList.push('--no-build'); + if (applicationArguments.length > 0) argumentsList.push('--', ...applicationArguments); + + const child = spawn('dotnet', argumentsList, { + cwd: repositoryRoot, + env: { ...process.env, ...environment }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const output: string[] = []; + const log = createWriteStream(logFile, { flags: 'a' }); + const record = (chunk: Buffer) => { + output.push(chunk.toString()); + if (output.length > 200) output.shift(); + log.write(chunk); + }; + child.stdout.on('data', record); + child.stderr.on('data', record); + child.once('exit', () => log.end()); + return { child, output }; +} + +async function waitUntilHealthy(url: string, process: ManagedProcess): Promise { + const deadline = Date.now() + startupTimeoutMilliseconds; + while (Date.now() < deadline) { + if (process.child.exitCode !== null) { + throw new Error(`Host exited with code ${process.child.exitCode}.\n${process.output.join('')}`); + } + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + // The host is still starting. + } + await new Promise(resolve => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${url}.\n${process.output.join('')}`); +} + +async function stopProcess(process: ManagedProcess | undefined): Promise { + if (!process || process.child.exitCode !== null) return; + process.child.kill('SIGTERM'); + await Promise.race([ + new Promise(resolve => process.child.once('exit', () => resolve())), + new Promise(resolve => setTimeout(resolve, 5_000)), + ]); + if (process.child.exitCode === null) process.child.kill('SIGKILL'); +} + +async function freePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject(new Error('Could not allocate a local port.')); + return; + } + const { port } = address; + server.close(error => error ? reject(error) : resolve(port)); + }); + }); +} diff --git a/automation/playwright/src/fixtures/repository.ts b/automation/playwright/src/fixtures/repository.ts new file mode 100644 index 00000000..2f7dfa19 --- /dev/null +++ b/automation/playwright/src/fixtures/repository.ts @@ -0,0 +1,10 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); + +export const repositoryRoot = path.resolve( + process.env.AGENTSTRATION_REPOSITORY ?? path.join(currentDirectory, '../../../..'), +); + +export const automationRoot = path.join(repositoryRoot, 'automation', 'playwright'); diff --git a/automation/playwright/src/fixtures/test.ts b/automation/playwright/src/fixtures/test.ts new file mode 100644 index 00000000..f239955e --- /dev/null +++ b/automation/playwright/src/fixtures/test.ts @@ -0,0 +1,26 @@ +import { test as base } from '@playwright/test'; +import { resolveExternalProductAddresses } from './external-product.js'; +import { startProductHosts, type ProductHosts } from './product-hosts.js'; + +interface AgentstrationWorkerFixtures { + product: ProductHosts; +} + +export const test = base.extend<{}, AgentstrationWorkerFixtures>({ + product: [async ({}, use) => { + const external = resolveExternalProductAddresses(process.env); + if (external) { + await use({ ...external, stop: async () => {} }); + return; + } + + const product = await startProductHosts(); + try { + await use(product); + } finally { + await product.stop(); + } + }, { scope: 'worker' }], +}); + +export { expect } from '@playwright/test'; diff --git a/automation/playwright/src/journeys/authenticate-console.journey.ts b/automation/playwright/src/journeys/authenticate-console.journey.ts new file mode 100644 index 00000000..98c7b65d --- /dev/null +++ b/automation/playwright/src/journeys/authenticate-console.journey.ts @@ -0,0 +1,22 @@ +import type { Journey } from './journey.js'; +import { Checkpoints } from '../contracts/checkpoints.js'; +import { TestIds } from '../contracts/test-ids.js'; + +export interface AuthenticateConsoleInput { + username?: string; + password?: string; +} + +export const authenticateConsole: Journey = async (context, input) => { + await context.pages.login.signIn(context.consoleUrl, input.username, input.password); + await context.pages.ensureTheme(context.theme ?? 'dark'); + await context.pages.page.getByTestId(TestIds.console.shell).waitFor({ state: 'visible' }); + const overview = context.pages.readyPlatformOverview; + await overview.waitFor({ state: 'visible' }); + await overview.locator('[aria-busy="true"]').waitFor({ state: 'detached' }); + await context.checkpoint({ + name: Checkpoints.console.home, + page: context.pages.page, + target: overview, + }); +}; diff --git a/automation/playwright/src/journeys/create-agent.journey.ts b/automation/playwright/src/journeys/create-agent.journey.ts new file mode 100644 index 00000000..2fe24b65 --- /dev/null +++ b/automation/playwright/src/journeys/create-agent.journey.ts @@ -0,0 +1,60 @@ +import type { AgentDefinition } from '../pages/agent-editor.page.js'; +import { Checkpoints } from '../contracts/checkpoints.js'; +import type { Journey } from './journey.js'; +import { prepareConsoleJourney } from './prepare-console.journey.js'; + +export type CreateAgentInput = AgentDefinition & { + username?: string; + password?: string; + workspaceName?: string; +}; + +export const createAgent: Journey = async (context, input) => { + validate(input); + await prepareConsoleJourney(context, input); + await context.pages.agentEditor.openNew(context.consoleUrl); + await context.checkpoint({ + name: Checkpoints.createAgent.formEmpty, + page: context.pages.page, + target: context.pages.agentEditor.form, + }); + + await context.pages.agentEditor.fillIdentity(input); + await context.checkpoint({ + name: Checkpoints.createAgent.identityComplete, + page: context.pages.page, + target: context.pages.agentEditor.identitySection, + }); + + await context.pages.agentEditor.configureBehavior(input); + await context.checkpoint({ + name: Checkpoints.createAgent.behaviorComplete, + page: context.pages.page, + target: context.pages.agentEditor.behaviorSection, + }); + await context.checkpoint({ + name: Checkpoints.createAgent.readyToCreate, + page: context.pages.page, + target: context.pages.agentEditor.form, + }); + + await context.pages.agentEditor.createAndDeploy(input.name); + await context.checkpoint({ + name: Checkpoints.createAgent.created, + page: context.pages.page, + target: context.pages.agentEditor.form, + }); +}; + +function validate(input: CreateAgentInput): void { + for (const property of ['name', 'displayName', 'description', 'instructions'] as const) { + if (!input[property]?.trim()) throw new Error(`Create agent journey input '${property}' is required.`); + } + for (const property of ['modelProfile', 'runtimeProfile'] as const) { + const value = input[property]; + const candidates = typeof value === 'string' ? [value] : value; + if (!candidates?.length || candidates.some(candidate => !candidate.trim())) { + throw new Error(`Create agent journey input '${property}' requires at least one non-empty candidate.`); + } + } +} diff --git a/automation/playwright/src/journeys/create-entry.journey.ts b/automation/playwright/src/journeys/create-entry.journey.ts new file mode 100644 index 00000000..93d92240 --- /dev/null +++ b/automation/playwright/src/journeys/create-entry.journey.ts @@ -0,0 +1,48 @@ +import { Checkpoints } from '../contracts/checkpoints.js'; +import type { EntryDefinition } from '../pages/entry-editor.page.js'; +import type { Journey } from './journey.js'; +import { prepareConsoleJourney } from './prepare-console.journey.js'; + +export type CreateEntryInput = EntryDefinition & { + username?: string; + password?: string; + workspaceName?: string; +}; + +export const createEntry: Journey = async (context, input) => { + validate(input); + await prepareConsoleJourney(context, input); + + const editor = context.pages.entryEditor; + await editor.openNew(context.consoleUrl); + await context.checkpoint({ name: Checkpoints.createEntry.formInitial, page: context.pages.page, target: editor.definitionFields }); + await editor.fillIdentity(input); + await context.checkpoint({ name: Checkpoints.createEntry.identityComplete, page: context.pages.page, target: editor.identitySection }); + await editor.configureAppearance(input); + await context.checkpoint({ name: Checkpoints.createEntry.appearanceComplete, page: context.pages.page, target: editor.appearanceSection }); + await editor.configureInteraction(input); + await context.checkpoint({ name: Checkpoints.createEntry.interactionComplete, page: context.pages.page, target: editor.interactionSection }); + await editor.configurePrimaryField(input.field); + await context.checkpoint({ name: Checkpoints.createEntry.fieldComplete, page: context.pages.page, target: editor.fieldsSection }); + await editor.bindToFlow(input.targetFlow, input.targetNamespace); + await context.checkpoint({ name: Checkpoints.createEntry.bindingComplete, page: context.pages.page, target: editor.bindingSection }); + await editor.configureBehavior(input); + await context.checkpoint({ name: Checkpoints.createEntry.behaviorComplete, page: context.pages.page, target: editor.behaviorSection }); + await editor.configureSuggestions(input.suggestions); + await context.checkpoint({ name: Checkpoints.createEntry.suggestionsComplete, page: context.pages.page, target: editor.suggestionsSection }); + await context.checkpoint({ name: Checkpoints.createEntry.readyToPublish, page: context.pages.page, target: editor.definitionFields }); + await editor.publish(); + await context.checkpoint({ name: Checkpoints.createEntry.published, page: context.pages.page, target: editor.status }); +}; + +function validate(input: CreateEntryInput): void { + for (const property of ['name', 'displayName', 'description', 'placeholder', 'targetFlow'] as const) { + if (!input[property]?.trim()) throw new Error(`Create Entry input '${property}' is required.`); + } + for (const property of ['name', 'label', 'description', 'placeholder'] as const) { + if (!input.field[property]?.trim()) throw new Error(`Create Entry primary field '${property}' is required.`); + } + for (const suggestion of input.suggestions) { + if (!suggestion.label.trim() || !suggestion.value.trim()) throw new Error('Every Entry suggestion requires a label and value.'); + } +} diff --git a/automation/playwright/src/journeys/create-handoff-flow.journey.ts b/automation/playwright/src/journeys/create-handoff-flow.journey.ts new file mode 100644 index 00000000..666ad987 --- /dev/null +++ b/automation/playwright/src/journeys/create-handoff-flow.journey.ts @@ -0,0 +1,47 @@ +import { Checkpoints } from '../contracts/checkpoints.js'; +import type { HandoffFlowDefinition } from '../pages/flow-editor.page.js'; +import type { Journey } from './journey.js'; +import { prepareConsoleJourney } from './prepare-console.journey.js'; + +export type CreateHandoffFlowInput = HandoffFlowDefinition & { + username?: string; + password?: string; + workspaceName?: string; +}; + +export const createHandoffFlow: Journey = async (context, input) => { + validate(input); + await prepareConsoleJourney(context, input); + + const editor = context.pages.flowEditor; + await editor.openNew(context.consoleUrl); + await context.checkpoint({ name: Checkpoints.createFlow.formInitial, page: context.pages.page, target: editor.form }); + await editor.fillIdentity(input); + await context.checkpoint({ name: Checkpoints.createFlow.identityComplete, page: context.pages.page, target: editor.identitySection }); + await editor.chooseOrchestration(); + await editor.selectParticipants(input.participants); + await context.checkpoint({ name: Checkpoints.createFlow.participantsComplete, page: context.pages.page, target: editor.participantsSection }); + await editor.configureHandoff(input); + await context.checkpoint({ name: Checkpoints.createFlow.strategyComplete, page: context.pages.page, target: editor.strategySection }); + await editor.create(input.name); + await context.checkpoint({ name: Checkpoints.createFlow.created, page: context.pages.page, target: editor.orchestrationEditor }); + await editor.publish(input.version); + await context.checkpoint({ name: Checkpoints.createFlow.published, page: context.pages.page, target: editor.orchestrationEditor }); +}; + +function validate(input: CreateHandoffFlowInput): void { + for (const property of ['name', 'displayName', 'description', 'version', 'initialParticipant', 'terminationPhrase'] as const) { + if (!String(input[property] ?? '').trim()) throw new Error(`Create handoff Flow input '${property}' is required.`); + } + if (input.participants.length < 2) throw new Error('A handoff Flow requires at least two participants.'); + if (!input.participants.includes(input.initialParticipant)) throw new Error('The initial participant must belong to the Flow.'); + if (input.routes.length === 0) throw new Error('A handoff Flow requires at least one route.'); + for (const route of input.routes) { + if (!input.participants.includes(route.from) || !input.participants.includes(route.to) || route.from === route.to) { + throw new Error(`Invalid handoff route '${route.from}' -> '${route.to}'.`); + } + } + if (!Number.isInteger(input.maximumTurnsPerParticipant) || input.maximumTurnsPerParticipant < 1) { + throw new Error('maximumTurnsPerParticipant must be a positive integer.'); + } +} diff --git a/automation/playwright/src/journeys/create-workspace.journey.ts b/automation/playwright/src/journeys/create-workspace.journey.ts new file mode 100644 index 00000000..e417a958 --- /dev/null +++ b/automation/playwright/src/journeys/create-workspace.journey.ts @@ -0,0 +1,46 @@ +import { Checkpoints } from '../contracts/checkpoints.js'; +import type { WorkspaceDefinition } from '../pages/organization-workspaces.page.js'; +import type { Journey } from './journey.js'; + +export type CreateWorkspaceInput = WorkspaceDefinition & { + username?: string; + password?: string; +}; + +export const createWorkspace: Journey = async (context, input) => { + validate(input); + await context.pages.login.signIn(context.consoleUrl, input.username, input.password); + await context.pages.ensureTheme(context.theme ?? 'dark'); + await context.pages.organizationWorkspaces.open(context.consoleUrl); + await context.checkpoint({ + name: Checkpoints.createWorkspace.formEmpty, + page: context.pages.page, + target: context.pages.organizationWorkspaces.createForm, + }); + + await context.pages.organizationWorkspaces.fillIdentity(input); + await context.checkpoint({ + name: Checkpoints.createWorkspace.identityComplete, + page: context.pages.page, + target: context.pages.organizationWorkspaces.createForm, + }); + + await context.pages.organizationWorkspaces.create(input); + await context.checkpoint({ + name: Checkpoints.createWorkspace.created, + page: context.pages.page, + target: context.pages.organizationWorkspaces.row(input.name), + }); + + await context.pages.organizationWorkspaces.selectByName(input.name); + await context.checkpoint({ + name: Checkpoints.createWorkspace.selected, + page: context.pages.page, + target: context.pages.organizationWorkspaces.createForm, + }); +}; + +function validate(input: CreateWorkspaceInput): void { + if (!input.name?.trim()) throw new Error("Create workspace journey input 'name' is required."); + if (!input.displayName?.trim()) throw new Error("Create workspace journey input 'displayName' is required."); +} diff --git a/automation/playwright/src/journeys/journey.ts b/automation/playwright/src/journeys/journey.ts new file mode 100644 index 00000000..b2fce4a9 --- /dev/null +++ b/automation/playwright/src/journeys/journey.ts @@ -0,0 +1,19 @@ +import type { Locator, Page } from '@playwright/test'; +import type { ProductAddresses } from '../fixtures/product-hosts.js'; +import type { ProductPages } from '../pages/product.pages.js'; + +export interface JourneyCheckpoint { + name: string; + page: Page; + target?: Locator; +} + +export interface JourneyContext extends ProductAddresses { + pages: ProductPages; + theme?: 'light' | 'dark'; + checkpoint(checkpoint: JourneyCheckpoint): Promise; +} + +export type Journey = (context: JourneyContext, input: TInput) => Promise; + +export const ignoreCheckpoints = async (): Promise => {}; diff --git a/automation/playwright/src/journeys/prepare-console.journey.ts b/automation/playwright/src/journeys/prepare-console.journey.ts new file mode 100644 index 00000000..5ca127c8 --- /dev/null +++ b/automation/playwright/src/journeys/prepare-console.journey.ts @@ -0,0 +1,17 @@ +import { resolveCampaignWorkspaceName } from '../fixtures/campaign-workspace.js'; +import type { JourneyContext } from './journey.js'; + +export interface ConsoleJourneySetup { + username?: string; + password?: string; + workspaceName?: string; +} + +export async function prepareConsoleJourney(context: JourneyContext, setup: ConsoleJourneySetup): Promise { + await context.pages.login.signIn(context.consoleUrl, setup.username, setup.password); + + const workspaceName = resolveCampaignWorkspaceName(setup.workspaceName); + if (workspaceName) await context.pages.organizationWorkspaces.selectByName(workspaceName); + + await context.pages.ensureTheme(context.theme ?? 'dark'); +} diff --git a/automation/playwright/src/journeys/registry.ts b/automation/playwright/src/journeys/registry.ts new file mode 100644 index 00000000..b44ac49e --- /dev/null +++ b/automation/playwright/src/journeys/registry.ts @@ -0,0 +1,14 @@ +import { authenticateConsole } from './authenticate-console.journey.js'; +import { createAgent, type CreateAgentInput } from './create-agent.journey.js'; +import { createHandoffFlow, type CreateHandoffFlowInput } from './create-handoff-flow.journey.js'; +import { createEntry, type CreateEntryInput } from './create-entry.journey.js'; +import { createWorkspace, type CreateWorkspaceInput } from './create-workspace.journey.js'; +import type { Journey } from './journey.js'; + +export const journeys: Readonly>>> = { + 'authenticate-console': authenticateConsole as Journey>, + 'create-agent': (context, input) => createAgent(context, input as unknown as CreateAgentInput), + 'create-handoff-flow': (context, input) => createHandoffFlow(context, input as unknown as CreateHandoffFlowInput), + 'create-entry': (context, input) => createEntry(context, input as unknown as CreateEntryInput), + 'create-workspace': (context, input) => createWorkspace(context, input as unknown as CreateWorkspaceInput), +}; diff --git a/automation/playwright/src/locales/expected-text.ts b/automation/playwright/src/locales/expected-text.ts new file mode 100644 index 00000000..24921a10 --- /dev/null +++ b/automation/playwright/src/locales/expected-text.ts @@ -0,0 +1,54 @@ +export const SupportedTestLocales = ['en-US', 'fr-FR'] as const; +export type SupportedTestLocale = typeof SupportedTestLocales[number]; + +export interface ExpectedText { + organizationWorkspaces: { + title: string; + createWorkspace: string; + displayName: string; + technicalName: string; + currentWorkspace: string; + }; + flowEditor: { + createOrchestration: string; + }; + entryEditor: { + createEntry: string; + publishPinnedVersion: string; + }; +} + +export const ExpectedTextByLocale = { + 'en-US': { + organizationWorkspaces: { + title: 'Workspaces', + createWorkspace: 'Create Workspace', + displayName: 'Display name', + technicalName: 'Technical name', + currentWorkspace: 'Current workspace', + }, + flowEditor: { + createOrchestration: 'Create Orchestration', + }, + entryEditor: { + createEntry: 'Create entry', + publishPinnedVersion: 'Publish pinned version', + }, + }, + 'fr-FR': { + organizationWorkspaces: { + title: 'Espaces de travail', + createWorkspace: 'Créer un espace de travail', + displayName: 'Nom affiché', + technicalName: 'Nom technique', + currentWorkspace: 'Espace de travail actuel', + }, + flowEditor: { + createOrchestration: 'Créer l’orchestration', + }, + entryEditor: { + createEntry: 'Créer une entrée', + publishPinnedVersion: 'Publier la version épinglée', + }, + }, +} as const satisfies Record; diff --git a/automation/playwright/src/pages/agent-editor.page.ts b/automation/playwright/src/pages/agent-editor.page.ts new file mode 100644 index 00000000..b2f6efd9 --- /dev/null +++ b/automation/playwright/src/pages/agent-editor.page.ts @@ -0,0 +1,49 @@ +import type { Locator, Page } from '@playwright/test'; +import { TestIds } from '../contracts/test-ids.js'; +import { fillAndCommit, selectFirstAvailable, type ControlSelection } from './controls.js'; + +export type ProfileSelection = ControlSelection; + +export interface AgentDefinition { + name: string; + displayName: string; + description: string; + instructions: string; + modelProfile: ProfileSelection; + runtimeProfile: ProfileSelection; +} + +export class AgentEditorPage { + public constructor(private readonly page: Page) {} + + public async openNew(consoleUrl: string): Promise { + const response = await this.page.goto(`${consoleUrl}/agents/new`, { waitUntil: 'domcontentloaded' }); + if (!response?.ok()) throw new Error(`New agent page returned HTTP ${response?.status() ?? 'no response'}.`); + await this.page.locator(`[data-testid="${TestIds.agentEditor.form}"][data-interactive="true"]`).waitFor({ state: 'visible' }); + } + + public async fillIdentity(agent: Pick): Promise { + await fillAndCommit(this.page.getByTestId(TestIds.agentEditor.name), agent.name); + await fillAndCommit(this.page.getByTestId(TestIds.agentEditor.displayName), agent.displayName); + await fillAndCommit(this.page.getByTestId(TestIds.agentEditor.description), agent.description); + } + + public async configureBehavior(agent: Pick): Promise { + await selectFirstAvailable(this.page.getByTestId(TestIds.agentEditor.modelProfile), agent.modelProfile, 'Model profile'); + await selectFirstAvailable(this.page.getByTestId(TestIds.agentEditor.runtimeProfile), agent.runtimeProfile, 'Runtime profile'); + await fillAndCommit(this.page.getByTestId(TestIds.agentEditor.instructions), agent.instructions); + } + + public async createAndDeploy(name: string): Promise { + await Promise.all([ + this.page.waitForURL(url => url.pathname === `/agents/${encodeURIComponent(name)}`), + this.page.getByTestId(TestIds.agentEditor.createAndDeploy).click(), + ]); + await this.page.getByTestId(TestIds.agentEditor.form).waitFor({ state: 'visible' }); + await this.page.getByTestId(TestIds.agentEditor.name).waitFor({ state: 'visible' }); + } + + public get form(): Locator { return this.page.getByTestId(TestIds.agentEditor.form); } + public get identitySection(): Locator { return this.page.getByTestId(TestIds.agentEditor.identitySection); } + public get behaviorSection(): Locator { return this.page.getByTestId(TestIds.agentEditor.behaviorSection); } +} diff --git a/automation/playwright/src/pages/controls.ts b/automation/playwright/src/pages/controls.ts new file mode 100644 index 00000000..9f86ba7c --- /dev/null +++ b/automation/playwright/src/pages/controls.ts @@ -0,0 +1,24 @@ +import type { Locator } from '@playwright/test'; + +export type ControlSelection = string | readonly string[]; + +export async function fillAndCommit(locator: Locator, value: string): Promise { + await locator.fill(value); + await locator.blur(); +} + +export async function selectFirstAvailable(locator: Locator, selection: ControlSelection, label: string): Promise { + await locator.waitFor({ state: 'visible' }); + const candidates = typeof selection === 'string' ? [selection] : [...selection]; + const options = await locator.locator('option').evaluateAll(elements => elements.map(element => ({ + value: (element as HTMLOptionElement).value, + label: element.textContent?.trim() ?? '', + disabled: (element as HTMLOptionElement).disabled, + }))); + const selected = candidates.find(candidate => options.some(option => option.value === candidate && !option.disabled)); + if (!selected) { + const available = options.filter(option => option.value && !option.disabled).map(option => `${option.value} (${option.label})`); + throw new Error(`${label} candidates [${candidates.join(', ')}] are unavailable. Available options: ${available.join(', ') || 'none'}.`); + } + await locator.selectOption(selected); +} diff --git a/automation/playwright/src/pages/entry-editor.page.ts b/automation/playwright/src/pages/entry-editor.page.ts new file mode 100644 index 00000000..11779767 --- /dev/null +++ b/automation/playwright/src/pages/entry-editor.page.ts @@ -0,0 +1,122 @@ +import type { Locator, Page } from '@playwright/test'; +import { TestIds } from '../contracts/test-ids.js'; +import { fillAndCommit, selectFirstAvailable } from './controls.js'; + +export interface EntryFieldDefinition { + name: string; + label: string; + description: string; + placeholder: string; + type: 'Prompt' | 'Text' | 'Textarea' | 'Conversation'; + required: boolean; + primary: boolean; +} + +export interface EntrySuggestionDefinition { + label: string; + value: string; +} + +export interface EntryDefinition { + name: string; + displayName: string; + description: string; + presentationKind: 'Prompt' | 'Form'; + placeholder: string; + icon?: string; + participantsVisibility: 'Hidden' | 'Visible'; + progressVisibility: 'Hidden' | 'Compact' | 'Detailed'; + taskDisplay: 'Auto' | 'Hidden' | 'Visible'; + resultsDisplay: 'Auto' | 'Hidden' | 'Visible'; + field: EntryFieldDefinition; + targetFlow: string; + targetNamespace?: string; + taskCreationMode: 'Automatic' | 'OnDemand' | 'Never'; + allowConversation: boolean; + streamResponse: boolean; + suggestions: readonly EntrySuggestionDefinition[]; +} + +export class EntryEditorPage { + public constructor(private readonly page: Page) {} + + public async openNew(consoleUrl: string): Promise { + const response = await this.page.goto(`${consoleUrl}/entries/new?view=definition`, { waitUntil: 'domcontentloaded' }); + if (!response?.ok()) throw new Error(`New Entry page returned HTTP ${response?.status() ?? 'no response'}.`); + await this.page.locator( + `[data-testid="${TestIds.entryEditor.definitionFields}"][data-interactive="true"]`, + ).waitFor({ state: 'visible' }); + } + + public async fillIdentity(entry: Pick): Promise { + await fillAndCommit(this.page.getByTestId(TestIds.entryEditor.name), entry.name); + await fillAndCommit(this.page.getByTestId(TestIds.entryEditor.displayName), entry.displayName); + await fillAndCommit(this.page.getByTestId(TestIds.entryEditor.description), entry.description); + } + + public async configureAppearance(entry: Pick): Promise { + await this.page.getByTestId(TestIds.entryEditor.presentationKind).selectOption(entry.presentationKind); + await fillAndCommit(this.page.getByTestId(TestIds.entryEditor.placeholder), entry.placeholder); + if (entry.icon) { + const picker = this.appearanceSection.getByTestId(TestIds.common.iconPicker); + await picker.locator('input[type="search"]').fill(entry.icon); + await picker.getByRole('option', { name: entry.icon, exact: true }).click(); + } + } + + public async configureInteraction(entry: Pick): Promise { + await this.page.getByTestId(TestIds.entryEditor.participantsVisibility).selectOption(entry.participantsVisibility); + await this.page.getByTestId(TestIds.entryEditor.progressVisibility).selectOption(entry.progressVisibility); + await this.page.getByTestId(TestIds.entryEditor.taskDisplay).selectOption(entry.taskDisplay); + await this.page.getByTestId(TestIds.entryEditor.resultsDisplay).selectOption(entry.resultsDisplay); + } + + public async configurePrimaryField(field: EntryFieldDefinition): Promise { + const card = this.page.getByTestId(TestIds.entryEditor.fieldCard).first(); + await fillAndCommit(card.getByTestId(TestIds.entryEditor.fieldName), field.name); + await fillAndCommit(card.getByTestId(TestIds.entryEditor.fieldLabel), field.label); + await fillAndCommit(card.getByTestId(TestIds.entryEditor.fieldDescription), field.description); + await fillAndCommit(card.getByTestId(TestIds.entryEditor.fieldPlaceholder), field.placeholder); + await card.getByTestId(TestIds.entryEditor.fieldType).selectOption(field.type); + await card.getByTestId(TestIds.entryEditor.fieldRequired).setChecked(field.required); + if (field.primary) await card.getByTestId(TestIds.entryEditor.fieldPrimary).check(); + } + + public async bindToFlow(flowName: string, namespace = 'default'): Promise { + await this.page.getByTestId(TestIds.entryEditor.targetKind).selectOption('Flow'); + const target = this.page.getByTestId(TestIds.entryEditor.targetResource); + await target.locator('option').nth(1).waitFor({ state: 'attached' }); + await selectFirstAvailable(target, [`${namespace}:${flowName}`, flowName], 'Entry target Flow'); + } + + public async configureBehavior(entry: Pick): Promise { + await this.page.getByTestId(TestIds.entryEditor.taskMode).selectOption(entry.taskCreationMode); + await this.page.getByTestId(TestIds.entryEditor.allowConversation).setChecked(entry.allowConversation); + await this.page.getByTestId(TestIds.entryEditor.streamResponse).setChecked(entry.streamResponse); + } + + public async configureSuggestions(suggestions: readonly EntrySuggestionDefinition[]): Promise { + for (const suggestion of suggestions) { + await this.page.getByTestId(TestIds.entryEditor.addSuggestion).click(); + const card = this.page.getByTestId(TestIds.entryEditor.suggestionCard).last(); + await fillAndCommit(card.getByTestId(TestIds.entryEditor.suggestionLabel), suggestion.label); + await fillAndCommit(card.getByTestId(TestIds.entryEditor.suggestionValue), suggestion.value); + } + } + + public async publish(): Promise { + await this.page.getByTestId(TestIds.entryEditor.publish).click(); + await this.page.locator(`[data-testid="${TestIds.entryEditor.status}"][data-state="published"]`).waitFor({ state: 'visible' }); + } + + public get definitionFields(): Locator { return this.page.getByTestId(TestIds.entryEditor.definitionFields); } + public get identitySection(): Locator { return this.page.getByTestId(TestIds.entryEditor.identitySection); } + public get appearanceSection(): Locator { return this.page.getByTestId(TestIds.entryEditor.appearanceSection); } + public get interactionSection(): Locator { return this.page.getByTestId(TestIds.entryEditor.interactionSection); } + public get fieldsSection(): Locator { return this.page.getByTestId(TestIds.entryEditor.fieldsSection); } + public get bindingSection(): Locator { return this.page.getByTestId(TestIds.entryEditor.bindingSection); } + public get behaviorSection(): Locator { return this.page.getByTestId(TestIds.entryEditor.behaviorSection); } + public get suggestionsSection(): Locator { return this.page.getByTestId(TestIds.entryEditor.suggestionsSection); } + public get status(): Locator { return this.page.getByTestId(TestIds.entryEditor.status); } +} diff --git a/automation/playwright/src/pages/flow-editor.page.ts b/automation/playwright/src/pages/flow-editor.page.ts new file mode 100644 index 00000000..a5c69887 --- /dev/null +++ b/automation/playwright/src/pages/flow-editor.page.ts @@ -0,0 +1,107 @@ +import type { Locator, Page } from '@playwright/test'; +import { TestIds } from '../contracts/test-ids.js'; +import { fillAndCommit } from './controls.js'; + +export interface HandoffRouteDefinition { + from: string; + to: string; +} + +export interface HandoffFlowDefinition { + name: string; + displayName: string; + description: string; + version: string; + enabled: boolean; + participants: readonly string[]; + initialParticipant: string; + routes: readonly HandoffRouteDefinition[]; + autonomous: boolean; + maximumTurnsPerParticipant: number; + terminationPhrase: string; +} + +export class FlowEditorPage { + public constructor(private readonly page: Page) {} + + public async openNew(consoleUrl: string): Promise { + const response = await this.page.goto(`${consoleUrl}/flows/new`, { waitUntil: 'domcontentloaded' }); + if (!response?.ok()) throw new Error(`New flow page returned HTTP ${response?.status() ?? 'no response'}.`); + await this.page.locator(`[data-testid="${TestIds.flowEditor.form}"][data-interactive="true"]`).waitFor({ state: 'visible' }); + } + + public async fillIdentity(flow: Pick): Promise { + await fillAndCommit(this.page.getByTestId(TestIds.flowEditor.name), flow.name); + await fillAndCommit(this.page.getByTestId(TestIds.flowEditor.displayName), flow.displayName); + await fillAndCommit(this.page.getByTestId(TestIds.flowEditor.version), flow.version); + await fillAndCommit(this.page.getByTestId(TestIds.flowEditor.description), flow.description); + } + + public async chooseOrchestration(): Promise { + await this.page.getByTestId(TestIds.flowEditor.orchestrationKind).click(); + await this.page.getByTestId(TestIds.flowEditor.continue).click(); + await this.configuration.waitFor({ state: 'visible' }); + } + + public async selectParticipants(participantIds: readonly string[]): Promise { + for (const participantId of participantIds) { + const participant = this.page.locator( + `[data-testid="${TestIds.flowEditor.participant}"][data-agent-id="${attributeValue(participantId)}"]`, + ); + if (await participant.count() === 0) { + const available = await this.page.getByTestId(TestIds.flowEditor.participant) + .evaluateAll(elements => elements.map(element => element.getAttribute('data-agent-id')).filter(Boolean)); + throw new Error(`Flow participant '${participantId}' is unavailable. Available participants: ${available.join(', ') || 'none'}.`); + } + await participant.check(); + } + } + + public async configureHandoff(flow: Pick): Promise { + await this.page.getByTestId(TestIds.flowEditor.enabled).setChecked(flow.enabled); + await this.page.getByTestId(TestIds.flowEditor.strategy).selectOption('Handoff'); + await this.page.getByTestId(TestIds.flowEditor.initialParticipant).selectOption(flow.initialParticipant); + + const routes = this.page.getByTestId(TestIds.flowEditor.route); + while (await routes.count() < flow.routes.length) await this.page.getByTestId(TestIds.flowEditor.addRoute).click(); + if (await routes.count() > flow.routes.length) throw new Error('The new Flow contains more default handoff routes than requested.'); + for (let index = 0; index < flow.routes.length; index++) { + const route = routes.nth(index); + await route.getByTestId(TestIds.flowEditor.routeFrom).selectOption(flow.routes[index]!.from); + await route.getByTestId(TestIds.flowEditor.routeTo).selectOption(flow.routes[index]!.to); + } + + await this.page.getByTestId(TestIds.flowEditor.autonomous).setChecked(flow.autonomous); + await fillAndCommit(this.page.getByTestId(TestIds.flowEditor.maximumTurns), String(flow.maximumTurnsPerParticipant)); + await fillAndCommit(this.page.getByTestId(TestIds.flowEditor.terminationPhrase), flow.terminationPhrase); + } + + public async create(name: string): Promise { + await Promise.all([ + this.page.waitForURL(url => url.pathname === `/flows/${encodeURIComponent(name)}` && url.searchParams.get('view') === 'definition'), + this.page.getByTestId(TestIds.flowEditor.create).click(), + ]); + await this.orchestrationEditor.waitFor({ state: 'visible' }); + } + + public async publish(version: string): Promise { + await this.page.getByTestId(TestIds.flowEditor.publish).click(); + await this.page.locator( + `[data-testid="${TestIds.flowEditor.statusMessage}"][data-published-version="${attributeValue(version)}"]`, + ).waitFor({ state: 'visible' }); + } + + public get form(): Locator { return this.page.getByTestId(TestIds.flowEditor.form); } + public get identitySection(): Locator { return this.page.getByTestId(TestIds.flowEditor.identitySection); } + public get configuration(): Locator { return this.page.getByTestId(TestIds.flowEditor.configuration); } + public get participantsSection(): Locator { return this.page.getByTestId(TestIds.flowEditor.participantsSection); } + public get strategySection(): Locator { return this.page.getByTestId(TestIds.flowEditor.strategySection); } + public get preview(): Locator { return this.page.getByTestId(TestIds.flowEditor.preview); } + public get orchestrationEditor(): Locator { return this.page.getByTestId(TestIds.flowEditor.orchestrationEditor); } +} + +function attributeValue(value: string): string { + if (!/^[a-zA-Z0-9_.:/-]+$/.test(value)) throw new Error(`Unsupported identifier '${value}' in a browser locator.`); + return value; +} diff --git a/automation/playwright/src/pages/login.page.ts b/automation/playwright/src/pages/login.page.ts new file mode 100644 index 00000000..0a6a1928 --- /dev/null +++ b/automation/playwright/src/pages/login.page.ts @@ -0,0 +1,24 @@ +import type { Page } from '@playwright/test'; +import { TestIds } from '../contracts/test-ids.js'; +import { resolveLoginCredentials } from '../fixtures/login-credentials.js'; + +export class LoginPage { + public constructor(private readonly page: Page) {} + + public async signIn(consoleUrl: string, username?: string, password?: string): Promise { + const response = await this.page.goto(consoleUrl, { waitUntil: 'domcontentloaded' }); + if (!response?.ok()) throw new Error(`Console returned HTTP ${response?.status() ?? 'no response'}.`); + if (!this.page.url().includes('/login')) { + await this.page.getByTestId(TestIds.console.shell).waitFor({ state: 'visible' }); + return; + } + + const credentials = resolveLoginCredentials(username, password); + await this.page.getByTestId(TestIds.login.username).fill(credentials.username); + await this.page.getByTestId(TestIds.login.password).fill(credentials.password); + await Promise.all([ + this.page.waitForURL(url => !url.pathname.startsWith('/login')), + this.page.getByTestId(TestIds.login.submit).click(), + ]); + } +} diff --git a/automation/playwright/src/pages/organization-workspaces.page.ts b/automation/playwright/src/pages/organization-workspaces.page.ts new file mode 100644 index 00000000..3b3236bc --- /dev/null +++ b/automation/playwright/src/pages/organization-workspaces.page.ts @@ -0,0 +1,80 @@ +import type { Locator, Page } from '@playwright/test'; +import { TestIds } from '../contracts/test-ids.js'; + +export interface WorkspaceDefinition { + name: string; + displayName: string; +} + +export class OrganizationWorkspacesPage { + public constructor(private readonly page: Page) {} + + public async open(consoleUrl: string): Promise { + const response = await this.page.goto(`${consoleUrl}/settings/organization/workspaces`, { waitUntil: 'domcontentloaded' }); + if (!response?.ok()) throw new Error(`Organization workspaces page returned HTTP ${response?.status() ?? 'no response'}.`); + await this.page.locator(`[data-testid="${TestIds.organizationWorkspaces.createForm}"][data-interactive="true"]`).waitFor({ state: 'visible' }); + } + + public async fillIdentity(workspace: WorkspaceDefinition): Promise { + await this.page.getByTestId(TestIds.organizationWorkspaces.displayName).fill(workspace.displayName); + await this.page.getByTestId(TestIds.organizationWorkspaces.technicalName).fill(workspace.name); + } + + public async create(workspace: WorkspaceDefinition): Promise { + const existingId = await this.workspaceId(workspace.name); + if (existingId) throw new Error(`Workspace '${workspace.name}' already exists.`); + + const row = this.row(workspace.name); + await this.page.getByTestId(TestIds.organizationWorkspaces.create).click(); + await row.waitFor({ state: 'visible' }); + const workspaceId = await row.getAttribute('data-workspace-id'); + if (!workspaceId) throw new Error(`Workspace '${workspace.name}' was created without a visible identifier.`); + return workspaceId; + } + + public async select(workspaceId: string): Promise { + const selector = this.page.getByTestId(TestIds.console.workspaceSelector); + await selector.waitFor({ state: 'visible' }); + await selector.selectOption(workspaceId); + await this.page.locator(`[data-testid="${TestIds.console.shell}"][data-workspace-id="${workspaceId}"]`).waitFor({ state: 'visible' }); + } + + public async selectByName(name: string): Promise { + const shell = this.page.getByTestId(TestIds.console.shell); + await shell.waitFor({ state: 'visible' }); + if (await shell.getAttribute('data-workspace-name') === name) return; + + const selector = this.page.getByTestId(TestIds.console.workspaceSelector); + if (await selector.count() === 0) { + throw new Error(`Campaign workspace '${name}' is not available in the workspace selector.`); + } + + const options = await selector.locator('option').evaluateAll(elements => elements.map(element => ({ + name: element.getAttribute('data-workspace-name'), + value: (element as HTMLOptionElement).value, + }))); + const workspaceId = options.find(option => option.name === name)?.value; + if (!workspaceId) { + const availableNames = options.map(option => option.name).filter(Boolean).join(', '); + throw new Error(`Campaign workspace '${name}' is not available in the workspace selector. Available workspaces: ${availableNames || 'none'}.`); + } + + await this.select(workspaceId); + if (await shell.getAttribute('data-workspace-name') !== name) { + throw new Error(`Workspace selector did not activate campaign workspace '${name}'.`); + } + } + + public row(name: string): Locator { + return this.page.getByTestId(TestIds.organizationWorkspaces.row).filter({ + has: this.page.locator('code').getByText(name, { exact: true }), + }); + } + + public get createForm(): Locator { return this.page.getByTestId(TestIds.organizationWorkspaces.createForm); } + + private async workspaceId(name: string): Promise { + const row = this.row(name); + return await row.count() === 0 ? null : await row.first().getAttribute('data-workspace-id'); + } +} diff --git a/automation/playwright/src/pages/product.pages.ts b/automation/playwright/src/pages/product.pages.ts new file mode 100644 index 00000000..fc4b5f9b --- /dev/null +++ b/automation/playwright/src/pages/product.pages.ts @@ -0,0 +1,35 @@ +import type { Locator, Page } from '@playwright/test'; +import { TestIds } from '../contracts/test-ids.js'; +import { AgentEditorPage } from './agent-editor.page.js'; +import { FlowEditorPage } from './flow-editor.page.js'; +import { EntryEditorPage } from './entry-editor.page.js'; +import { LoginPage } from './login.page.js'; +import { OrganizationWorkspacesPage } from './organization-workspaces.page.js'; + +export class ProductPages { + public readonly agentEditor: AgentEditorPage; + public readonly flowEditor: FlowEditorPage; + public readonly entryEditor: EntryEditorPage; + public readonly login: LoginPage; + public readonly organizationWorkspaces: OrganizationWorkspacesPage; + + public constructor(public readonly page: Page) { + this.agentEditor = new AgentEditorPage(page); + this.flowEditor = new FlowEditorPage(page); + this.entryEditor = new EntryEditorPage(page); + this.login = new LoginPage(page); + this.organizationWorkspaces = new OrganizationWorkspacesPage(page); + } + + public async ensureTheme(theme: 'light' | 'dark'): Promise { + const shell = this.page.getByTestId(TestIds.console.shell); + await this.page.locator(`[data-testid="${TestIds.console.shell}"][data-preferences-ready="true"]`).waitFor({ state: 'visible' }); + if (await shell.evaluate((element, expected) => element.classList.contains(`theme-${expected}`), theme)) return; + await this.page.getByTestId(TestIds.console.themeToggle).click(); + await this.page.locator(`[data-testid="${TestIds.console.shell}"].theme-${theme}`).waitFor({ state: 'visible' }); + } + + public get readyPlatformOverview(): Locator { + return this.page.locator(`[data-testid="${TestIds.console.platformOverview}"][aria-busy="false"]`); + } +} diff --git a/automation/playwright/tests/campaign-workspace.spec.ts b/automation/playwright/tests/campaign-workspace.spec.ts new file mode 100644 index 00000000..0a085b34 --- /dev/null +++ b/automation/playwright/tests/campaign-workspace.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { resolveCampaignWorkspaceName } from '../src/fixtures/campaign-workspace.js'; + +test('journey workspace overrides the campaign environment', () => { + expect(resolveCampaignWorkspaceName(' journey-workspace ', { + AGENTSTRATION_WORKSPACE_NAME: 'environment-workspace', + })).toBe('journey-workspace'); +}); + +test('campaign workspace can be selected from the environment', () => { + expect(resolveCampaignWorkspaceName(undefined, { + AGENTSTRATION_WORKSPACE_NAME: ' environment-workspace ', + })).toBe('environment-workspace'); +}); + +test('workspace selection remains optional without campaign configuration', () => { + expect(resolveCampaignWorkspaceName(undefined, {})).toBeUndefined(); +}); + +test('an explicit empty journey workspace is rejected', () => { + expect(() => resolveCampaignWorkspaceName(' ', { + AGENTSTRATION_WORKSPACE_NAME: 'environment-workspace', + })).toThrow(/must not be empty/); +}); diff --git a/automation/playwright/tests/capture-cli-options.spec.ts b/automation/playwright/tests/capture-cli-options.spec.ts new file mode 100644 index 00000000..60b228d3 --- /dev/null +++ b/automation/playwright/tests/capture-cli-options.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; +import { parseCaptureCliOptions, resolveCaptureAddresses } from '../src/capture/capture-cli-options.js'; +import type { CapturePlan } from '../src/capture/capture-plan.js'; + +const plan: CapturePlan = { + journey: 'create-agent', + consoleUrl: 'https://plan-console.example.test/', + workplaceUrl: 'https://plan-workplace.example.test/', + captures: [{ checkpoint: 'agent-created', file: 'agent-created.png' }], +}; + +test('command-line URLs override capture-plan URLs', () => { + const options = parseCaptureCliOptions([ + '--plan', 'capture.json', + '--output', '.work/capture', + '--console-url', 'https://cli-console.example.test/', + '--workplace-url', 'https://cli-workplace.example.test/', + ]); + + expect(resolveCaptureAddresses(options, plan)).toEqual({ + consoleUrl: 'https://cli-console.example.test', + workplaceUrl: 'https://cli-workplace.example.test', + }); +}); + +test('a Console-only invocation does not require a Workplace URL', () => { + const options = parseCaptureCliOptions([ + '--plan', 'capture.json', + '--output', '.work/capture', + '--console-url', 'https://cli-console.example.test', + ]); + + expect(resolveCaptureAddresses(options, plan)).toEqual({ + consoleUrl: 'https://cli-console.example.test', + workplaceUrl: 'https://cli-console.example.test', + }); +}); diff --git a/automation/playwright/tests/console-authentication.spec.ts b/automation/playwright/tests/console-authentication.spec.ts new file mode 100644 index 00000000..d4688f03 --- /dev/null +++ b/automation/playwright/tests/console-authentication.spec.ts @@ -0,0 +1,16 @@ +import { ProductPages } from '../src/pages/product.pages.js'; +import { TestIds } from '../src/contracts/test-ids.js'; +import { authenticateConsole } from '../src/journeys/authenticate-console.journey.js'; +import { ignoreCheckpoints } from '../src/journeys/journey.js'; +import { expect, test } from '../src/fixtures/test.js'; + +test('an administrator can open the Console @smoke', async ({ page, product }) => { + await authenticateConsole({ + ...product, + pages: new ProductPages(page), + checkpoint: ignoreCheckpoints, + }, {}); + + await expect(page.getByTestId(TestIds.console.shell)).toBeVisible(); + await expect(page).not.toHaveURL(/\/login/); +}); diff --git a/automation/playwright/tests/create-agent.spec.ts b/automation/playwright/tests/create-agent.spec.ts new file mode 100644 index 00000000..d444720c --- /dev/null +++ b/automation/playwright/tests/create-agent.spec.ts @@ -0,0 +1,26 @@ +import { ProductPages } from '../src/pages/product.pages.js'; +import { TestIds } from '../src/contracts/test-ids.js'; +import { createAgent, type CreateAgentInput } from '../src/journeys/create-agent.journey.js'; +import { ignoreCheckpoints } from '../src/journeys/journey.js'; +import { expect, test } from '../src/fixtures/test.js'; + +const agent: CreateAgentInput = { + name: 'playwright-welcome', + displayName: 'Playwright welcome agent', + description: 'Welcomes users during the browser journey.', + instructions: 'Welcome the user and answer concisely.', + modelProfile: ['default:missing-profile', 'default:reasoning-default'], + runtimeProfile: 'default:maf-builtin', +}; + +test('an administrator can create and deploy an agent @smoke', async ({ page, product }) => { + await createAgent({ + ...product, + pages: new ProductPages(page), + checkpoint: ignoreCheckpoints, + }, agent); + + await expect(page).toHaveURL(new RegExp(`/agents/${agent.name}$`)); + await expect(page.getByTestId(TestIds.agentEditor.name)).toHaveValue(agent.name); + await expect(page.getByTestId(TestIds.agentEditor.name)).toBeDisabled(); +}); diff --git a/automation/playwright/tests/create-flow-entry.spec.ts b/automation/playwright/tests/create-flow-entry.spec.ts new file mode 100644 index 00000000..f8133871 --- /dev/null +++ b/automation/playwright/tests/create-flow-entry.spec.ts @@ -0,0 +1,105 @@ +import { TestIds } from '../src/contracts/test-ids.js'; +import { createAgent, type CreateAgentInput } from '../src/journeys/create-agent.journey.js'; +import { createEntry, type CreateEntryInput } from '../src/journeys/create-entry.journey.js'; +import { createHandoffFlow, type CreateHandoffFlowInput } from '../src/journeys/create-handoff-flow.journey.js'; +import { ignoreCheckpoints } from '../src/journeys/journey.js'; +import { ExpectedTextByLocale } from '../src/locales/expected-text.js'; +import { ProductPages } from '../src/pages/product.pages.js'; +import { expect, test } from '../src/fixtures/test.js'; + +const agents: readonly CreateAgentInput[] = [ + { + name: 'playwright-greeter', + displayName: 'Playwright greeter', + description: 'Routes the initial request.', + instructions: 'Route each request to the relevant specialist.', + modelProfile: 'default:reasoning-default', + runtimeProfile: 'default:maf-builtin', + }, + { + name: 'playwright-solution', + displayName: 'Playwright solution specialist', + description: 'Answers product usage questions.', + instructions: 'Answer product usage questions and hand off technical requests.', + modelProfile: 'default:reasoning-default', + runtimeProfile: 'default:maf-builtin', + }, + { + name: 'playwright-technical', + displayName: 'Playwright technical specialist', + description: 'Answers technical questions.', + instructions: 'Answer technical questions and hand off integration requests.', + modelProfile: 'default:reasoning-default', + runtimeProfile: 'default:maf-builtin', + }, + { + name: 'playwright-integration', + displayName: 'Playwright integration specialist', + description: 'Answers integration questions.', + instructions: 'Answer questions about tools and integrations.', + modelProfile: 'default:reasoning-default', + runtimeProfile: 'default:maf-builtin', + }, +]; + +const flow: CreateHandoffFlowInput = { + name: 'playwright-handoff', + displayName: 'Playwright handoff', + description: 'Routes a conversation across a team of specialists.', + version: '0.1.0', + enabled: true, + participants: agents.map(agent => agent.name), + initialParticipant: agents[0]!.name, + routes: agents.flatMap(source => agents + .filter(target => target.name !== source.name) + .map(target => ({ from: source.name, to: target.name }))), + autonomous: true, + maximumTurnsPerParticipant: 2, + terminationPhrase: '[[DONE]]', +}; + +const entry: CreateEntryInput = { + name: 'playwright-review', + displayName: 'Playwright review', + description: 'Starts a reviewed response in Workplace.', + presentationKind: 'Prompt', + placeholder: 'What should the team review?', + icon: 'briefcase', + participantsVisibility: 'Hidden', + progressVisibility: 'Compact', + taskDisplay: 'Auto', + resultsDisplay: 'Auto', + field: { + name: 'request', + label: 'Request', + description: 'Describe the response to prepare.', + placeholder: 'Describe your request', + type: 'Prompt', + required: true, + primary: true, + }, + targetFlow: flow.name, + taskCreationMode: 'Automatic', + allowConversation: true, + streamResponse: true, + suggestions: [ + { label: 'Multiple agents', value: 'How can several specialist agents collaborate?' }, + { label: 'Local execution', value: 'Can this experience use local models?' }, + { label: 'Internal tools', value: 'How can agents call internal tools?' }, + ], +}; + +test('an administrator can publish a handoff Flow and its Entry @smoke', async ({ page, product }) => { + const pages = new ProductPages(page); + const context = { ...product, pages, checkpoint: ignoreCheckpoints }; + for (const agent of agents) await createAgent(context, agent); + + await createHandoffFlow(context, flow); + await expect(page.getByTestId(TestIds.flowEditor.statusMessage)).toHaveAttribute('data-published-version', flow.version); + + await createEntry(context, entry); + await expect(page.getByTestId(TestIds.entryEditor.status)).toHaveAttribute('data-state', 'published'); + await expect(page.getByTestId(TestIds.entryEditor.publish)).toHaveAccessibleName( + ExpectedTextByLocale['en-US'].entryEditor.publishPinnedVersion, + ); +}); diff --git a/automation/playwright/tests/create-workspace.spec.ts b/automation/playwright/tests/create-workspace.spec.ts new file mode 100644 index 00000000..ecbd2cce --- /dev/null +++ b/automation/playwright/tests/create-workspace.spec.ts @@ -0,0 +1,33 @@ +import { TestIds } from '../src/contracts/test-ids.js'; +import { createWorkspace, type CreateWorkspaceInput } from '../src/journeys/create-workspace.journey.js'; +import { ignoreCheckpoints } from '../src/journeys/journey.js'; +import { ProductPages } from '../src/pages/product.pages.js'; +import { ExpectedTextByLocale } from '../src/locales/expected-text.js'; +import { expect, test } from '../src/fixtures/test.js'; + +const workspace: CreateWorkspaceInput = { + name: 'playwright-campaign', + displayName: 'Playwright campaign', +}; + +test('an administrator can create and select a campaign workspace @smoke', async ({ page, product }) => { + const pages = new ProductPages(page); + await createWorkspace({ + ...product, + pages, + checkpoint: ignoreCheckpoints, + }, workspace); + + const row = page.getByTestId(TestIds.organizationWorkspaces.row) + .filter({ has: page.locator('code').getByText(workspace.name, { exact: true }) }); + const workspaceId = await row.getAttribute('data-workspace-id'); + expect(workspaceId).not.toBeNull(); + await expect(page.getByTestId(TestIds.console.shell)).toHaveAttribute('data-workspace-id', workspaceId!); + await expect(page.getByTestId(TestIds.console.shell)).toHaveAttribute('data-workspace-name', workspace.name); + await expect(page.getByTestId(TestIds.organizationWorkspaces.create)).toHaveAccessibleName( + ExpectedTextByLocale['en-US'].organizationWorkspaces.createWorkspace, + ); + await expect(pages.organizationWorkspaces.selectByName('missing-campaign')).rejects.toThrow( + /not available in the workspace selector.*Available workspaces:/, + ); +}); diff --git a/automation/playwright/tests/external-product.spec.ts b/automation/playwright/tests/external-product.spec.ts new file mode 100644 index 00000000..c1499627 --- /dev/null +++ b/automation/playwright/tests/external-product.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; +import { resolveExternalProductAddresses } from '../src/fixtures/external-product.js'; + +test('a Console URL selects an external product instance', () => { + expect(resolveExternalProductAddresses({ + AGENTSTRATION_CONSOLE_URL: ' http://localhost:53400/ ', + })).toEqual({ + consoleUrl: 'http://localhost:53400', + workplaceUrl: 'http://localhost:53400', + }); +}); + +test('an explicit Workplace URL is normalized independently', () => { + expect(resolveExternalProductAddresses({ + AGENTSTRATION_CONSOLE_URL: 'https://console.example.test/', + AGENTSTRATION_WORKPLACE_URL: 'https://workplace.example.test///', + })).toEqual({ + consoleUrl: 'https://console.example.test', + workplaceUrl: 'https://workplace.example.test', + }); +}); + +test('a Workplace URL cannot select an instance by itself', () => { + expect(() => resolveExternalProductAddresses({ + AGENTSTRATION_WORKPLACE_URL: 'https://workplace.example.test', + })).toThrow(/AGENTSTRATION_CONSOLE_URL/); +}); + +test('external product URLs must use HTTP', () => { + expect(() => resolveExternalProductAddresses({ + AGENTSTRATION_CONSOLE_URL: 'file:///agentstration', + })).toThrow(/http or https/); +}); diff --git a/automation/playwright/tests/login-credentials.spec.ts b/automation/playwright/tests/login-credentials.spec.ts new file mode 100644 index 00000000..4ff5bcb1 --- /dev/null +++ b/automation/playwright/tests/login-credentials.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { resolveLoginCredentials } from '../src/fixtures/login-credentials.js'; + +test('journey credentials override environment credentials', () => { + expect(resolveLoginCredentials('journey-user', 'journey-password', { + AGENTSTRATION_USERNAME: 'environment-user', + AGENTSTRATION_PASSWORD: 'environment-password', + AGENTSTRATION_CONSOLE_URL: 'https://console.example.test', + })).toEqual({ username: 'journey-user', password: 'journey-password' }); +}); + +test('environment credentials are used for an external instance', () => { + expect(resolveLoginCredentials(undefined, undefined, { + AGENTSTRATION_USERNAME: 'external-user', + AGENTSTRATION_PASSWORD: 'external-password', + AGENTSTRATION_CONSOLE_URL: 'https://console.example.test', + })).toEqual({ username: 'external-user', password: 'external-password' }); +}); + +test('managed Development hosts retain the public fixture credentials', () => { + expect(resolveLoginCredentials(undefined, undefined, {})).toEqual({ username: 'admin', password: 'admin' }); +}); + +test('credentials must be provided as a complete pair', () => { + expect(() => resolveLoginCredentials(undefined, undefined, { + AGENTSTRATION_USERNAME: 'external-user', + })).toThrow(/must be provided together/); + expect(() => resolveLoginCredentials('journey-user', undefined, {})).toThrow(/both username and password/); +}); + +test('external instances do not silently use Development credentials', () => { + expect(() => resolveLoginCredentials(undefined, undefined, { + AGENTSTRATION_CONSOLE_URL: 'https://console.example.test', + })).toThrow(/External Playwright instances require/); +}); diff --git a/automation/playwright/tsconfig.json b/automation/playwright/tsconfig.json new file mode 100644 index 00000000..fabdb74c --- /dev/null +++ b/automation/playwright/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["playwright.config.ts", "src/**/*.ts", "tests/**/*.ts", "cli/**/*.ts"] +} diff --git a/docs/architecture.md b/docs/architecture.md index 040e62ee..13a3e61b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -454,3 +454,5 @@ SQLite schema evolution for the workspace-scope hardening increment is reset-onl - ADR-0106: ToolDefinitions publish Flow-backed MCP Tools - ADR-0107: notification channels are delivery Flows - ADR-0108: Flow Run causality is a bounded read model +- ADR-0109: product-owned reusable browser journeys +- ADR-0110: dedicated Workspaces for browser campaign data isolation diff --git a/docs/contributing/browser-automation.md b/docs/contributing/browser-automation.md new file mode 100644 index 00000000..d42ebb1d --- /dev/null +++ b/docs/contributing/browser-automation.md @@ -0,0 +1,106 @@ +# Browser automation + +Agentstration owns reusable Playwright journeys for browser-level UX validation and deterministic external capture. The workspace is independent from the .NET solution and lives under `automation/playwright`. + +## Setup + +```powershell +npm --prefix automation/playwright ci +npm --prefix automation/playwright run install:browsers +``` + +Node.js 22 or later is required. The dependency lock and Playwright browser revision are versioned with the product. + +## Run UX smoke tests + +```powershell +npm --prefix automation/playwright run test:smoke +``` + +The default fixture starts the Console and Workplace on available loopback ports. Each worker uses an isolated directory under `automation/playwright/.work`, the Development bootstrap profile, SQLite, and deterministic AI. Ollama, Azure, Docker, and Internet access are not required. + +Set `AGENTSTRATION_PLAYWRIGHT_NO_BUILD=true` only after building both Web projects and the Ollama extension project. Failed tests retain Playwright traces, screenshots, and video under `automation/playwright/test-results`. + +For a local diagnostic when the pinned browser binary cannot be downloaded, an explicitly installed Playwright channel may be selected, for example `$env:AGENTSTRATION_PLAYWRIGHT_CHANNEL = "chrome"`. CI always installs and uses the pinned Chromium revision. + +### Playwright UI against an existing instance + +Set `AGENTSTRATION_CONSOLE_URL` before opening Playwright UI to bypass managed local hosts: + +```powershell +$env:AGENTSTRATION_CONSOLE_URL = "http://localhost:53400" +$env:AGENTSTRATION_WORKPLACE_URL = "http://localhost:53401" +$env:AGENTSTRATION_USERNAME = "admin" +$env:AGENTSTRATION_PASSWORD = "admin" +$env:AGENTSTRATION_WORKSPACE_NAME = "playwright-campaign" +Set-Location automation/playwright +npx playwright test --ui +``` + +`AGENTSTRATION_USERNAME` and `AGENTSTRATION_PASSWORD` are required together when `AGENTSTRATION_CONSOLE_URL` selects external UI-test mode. Explicit `username` and `password` journey input overrides the environment pair. `AGENTSTRATION_WORKSPACE_NAME` selects an existing campaign Workspace by its stable technical name from the Console header; an explicit journey `workspaceName` overrides it. The journey fails immediately if the configured Workspace is not offered by the selector and never creates it implicitly. Managed local hosts retain the public Development fixture `admin / admin`; the environment-selected external mode never falls back to it silently. `AGENTSTRATION_WORKPLACE_URL` is optional for Console-only tests and otherwise falls back to the Console URL. In external mode Playwright neither starts nor stops the product, and it does not isolate or reset its data. Use a disposable instance or a dedicated campaign Workspace, and expect create-only journeys to reject resources left by an earlier run. Remove the environment variables to return to managed isolated hosts. + +## Design rules + +- Page objects own selectors for one product surface. +- Journeys compose page-object actions and emit stable named checkpoints. +- Test specifications add assertions around journeys. +- Capture plans select checkpoints but never contain selectors. +- Prefer accessible role and label selectors. Add `data-testid` only for localized, custom, or otherwise ambiguous controls. +- Wait for URL, health, enabled controls, and visible domain state. Do not add arbitrary sleeps to functional journeys. +- Use typed scenario input. Keep release copy, storyboards, and publication-specific data outside this repository. + +## Stable automation contracts + +The Playwright workspace keeps its shared vocabulary in three explicit catalogs: + +| Contract | Location | Update when | +| --- | --- | --- | +| Test IDs | `src/contracts/test-ids.ts` | A cross-locale or ambiguous control needs a stable locator or capture target. Add the matching literal to the Razor markup and consume only the constant from page objects and tests. | +| Checkpoints | `src/contracts/checkpoints.ts` | A journey exposes a new meaningful user-visible state. Keep existing values stable and update plans that select the new checkpoint. | +| Expected localized text | `src/locales/expected-text.ts` | A semantic localization assertion is added or intentionally changed. Supply every supported locale independently from the product `.resx` files. | + +Use accessible roles and names first. Test IDs are a fallback for cross-locale journeys, ambiguity, capture boundaries, and non-visible readiness state. Page objects own locators; journeys own behavior and checkpoint timing; JSON plans contain neither selectors nor product localization constants. + +The scoped `automation/playwright/AGENTS.md` file is the maintenance contract for coding agents changing this area. + +## Campaign workspaces + +A campaign workspace is the durable data sandbox for a related set of UX tests, screenshots, or video takes. It is not the campaign definition itself: locale, theme, viewport, product revision, scenario data, and output evidence remain explicit plan dimensions. + +The `create-workspace` journey creates and selects a new workspace and fails if its technical name already exists. Other journeys resolve the Workspace from an explicit `workspaceName`, then `AGENTSTRATION_WORKSPACE_NAME`, and otherwise keep the current Workspace. A configured Workspace is selected directly from the global Console header; the journeys do not visit Organization settings and never silently create, replace, reuse, or delete it. Required resources must be seeded or installed explicitly in that workspace before a dependent journey runs. + +For an existing Console instance, a typical campaign begins with: + +```powershell +npm --prefix automation/playwright run capture -- ` + --plan automation/playwright/examples/create-campaign-workspace.capture-plan.json -- ` + --output automation/playwright/.work/campaign-workspace -- ` + --console-url https://agentstration.example.com +``` + +Subsequent plans can select it by adding `"workspaceName": "capture-handoff"` to their journey input. The built-in local capture host is recreated for each command; use one persistent external instance when multiple commands must share campaign state. + +The solution-discovery example is intentionally split into dependency-ordered product journeys: + +1. Create the campaign Workspace. +2. Create and deploy the participant Agents. +3. Run `create-solution-discovery-flow.capture-plan.json` to create, configure, publish, and activate the handoff Flow. +4. Run `create-solution-discovery-entry.capture-plan.json` to configure and publish the Workplace Entry pinned to that Flow. + +Each plan fails when its prerequisites are absent. The Flow journey reports the available Agent technical identifiers when a participant is missing; the Entry journey reports available Flow targets when its binding cannot be resolved. This keeps campaign setup explicit and prevents a capture from silently documenting a different topology. + +## Capture from a plan + +The example plan captures the authenticated Console home page: + +```powershell +npm --prefix automation/playwright run capture -- ` + --plan automation/playwright/examples/console-home.capture-plan.json ` + --output automation/playwright/.work/example-capture +``` + +The runner starts isolated product hosts unless an external Console URL is provided. It writes the requested PNG files and `capture-manifest.json`, which records the exact product commit, checkout cleanliness, browser version, and asset checksums. When a plan supplies `productRef`, the runner rejects a checkout that does not resolve to that exact commit. + +Use `--console-url ` to run against an existing Console; add `--workplace-url ` only when the selected journey uses Workplace. Command-line URLs override values from the plan. Supplying a Console URL disables local product-host startup. + +An external repository should checkout the requested Agentstration tag, run the command from that checkout, and write output into its own workspace. It must not copy the page objects or journeys. diff --git a/docs/contributing/overview.md b/docs/contributing/overview.md index c338c06a..592ab6e5 100644 --- a/docs/contributing/overview.md +++ b/docs/contributing/overview.md @@ -13,3 +13,4 @@ The repository-level [CONTRIBUTING.md](https://github.com/gbaudrit/agentstration Read [Working on the documentation](documentation.md) for the local Docusaurus workflow. Read [GitHub governance](github-governance.md) for branches, checks, security automation, and `main` protection. Read [Development slots](development-slots.md) to run multiple Git worktrees as isolated local Aspire instances. +Read [Browser automation](browser-automation.md) to run or extend product-owned Playwright journeys and deterministic captures. diff --git a/docs/decisions/0109-product-owned-browser-journeys.md b/docs/decisions/0109-product-owned-browser-journeys.md new file mode 100644 index 00000000..2cb9abe3 --- /dev/null +++ b/docs/decisions/0109-product-owned-browser-journeys.md @@ -0,0 +1,26 @@ +# ADR-0109: Browser journeys are product-owned reusable automation assets + +## Context + +Agentstration needs browser-level UX tests for the Console and Workplace. The separate communication repository also needs reproducible screenshots and video source material for a specific released product version. Duplicating navigation scripts would let selectors, fixtures, and user journeys drift away from the UI that owns them. + +Browser tests and editorial production nevertheless have different lifecycle rules. UX tests may block a product change. Communication must never block a product release, and editorial plans and rendered assets do not belong in the product repository. + +## Decision + +Agentstration owns a TypeScript Playwright workspace under `automation/playwright`. It contains the product host fixture, stable page objects, reusable journeys, browser tests, named checkpoints, and a capture CLI. + +A journey describes product interaction and accepts typed scenario data. Test specifications wrap journeys with assertions. The capture CLI wraps the same journeys with a checkpoint recorder. Journeys contain neither editorial copy nor rendering and publication logic. + +The default managed host fixture starts the Console and Workplace against isolated local state, the Development bootstrap account, SQLite, and deterministic AI. Tests remain offline. Semantic accessible selectors are preferred; `data-testid` is reserved for controls whose stable product meaning cannot be selected reliably through accessibility semantics. + +An external consumer invokes the Playwright workspace from a checkout of the exact product tag or commit that it documents. The workspace is not published as an npm package in this increment. Capture plans select a journey and checkpoints but contain no browser selectors. The runner records the product commit and checksums of generated assets. + +## Consequences + +- UI navigation knowledge evolves with the product and is available to both tests and communication. +- A released tag contains the matching automation needed to reproduce its captures. +- Browser tests can remain strict while communication selects publication-quality viewports and checkpoints. +- Communication still owns editorial plans, storyboards, post-processing, and final assets. +- Consumers must prepare a checkout of the requested product revision and run `npm ci` in its Playwright workspace. +- Browser binaries and Node dependencies add a separate CI job, but they do not become dependencies of the .NET solution or runtime product. diff --git a/docs/decisions/0110-browser-campaigns-use-dedicated-workspaces.md b/docs/decisions/0110-browser-campaigns-use-dedicated-workspaces.md new file mode 100644 index 00000000..05391880 --- /dev/null +++ b/docs/decisions/0110-browser-campaigns-use-dedicated-workspaces.md @@ -0,0 +1,22 @@ +# ADR-0110: Browser campaigns use dedicated Workspaces + +## Context + +Browser journeys serve UX validation and communication captures. Both need repeatable data without depending on resource labels translated for the current UI culture. A campaign may replay the same behavior across locales, themes, viewports, and product revisions. Encoding those dimensions into selectors or treating a localized Workspace as the campaign itself would couple product navigation, test data, and editorial output. + +## Decision + +A browser campaign uses a dedicated Agentstration Workspace as its data-isolation boundary. Workspace technical names remain stable and locale-neutral. Locale, theme, viewport, product revision, scenario input, selected checkpoints, and generated evidence remain explicit dimensions outside the Workspace. + +Agentstration owns a reusable `create-workspace` Playwright journey. It creates and selects a new Workspace and fails when its technical name already exists. Other journeys may select a prepared Workspace by technical name, but they do not implicitly create, replace, reuse, or delete one. A caller prepares required profiles, Packs, and other resources explicitly inside that Workspace. + +Playwright automation vocabulary is maintained through three independent TypeScript contracts: Test IDs in `src/contracts/test-ids.ts`, checkpoint names in `src/contracts/checkpoints.ts`, and expected localized text in `src/locales/expected-text.ts`. Localization expectations are intentionally not generated from product RESX resources, so assertions can detect missing or incorrect translations. + +## Consequences + +- The same journey and clean technical identifiers can be reused across locales. +- Parallel or repeated campaigns can isolate product data by choosing distinct Workspace names. +- Capture plans remain reproducible because non-data dimensions are visible in the plan instead of hidden in Workspace state. +- Journeys fail early when campaign setup is missing or collides with existing state. +- Persistent multi-step campaigns require a shared target instance; independently started local capture commands do not share state. +- Callers remain responsible for explicit campaign setup and lifecycle cleanup. diff --git a/docs/decisions/index.md b/docs/decisions/index.md index 15981671..512ff4bd 100644 --- a/docs/decisions/index.md +++ b/docs/decisions/index.md @@ -138,3 +138,5 @@ Use **Proposed** when implementation or repository evidence does not establish a 106. [ADR-0106 — ToolDefinitions publish Flow-backed MCP Tools](0106-tool-definitions-publish-flow-backed-mcp-tools.md) 107. [ADR-0107 — Notification channels are delivery Flows](0107-notification-channels-are-delivery-flows.md) 108. [ADR-0108 — Flow Run causality is a bounded read model](0108-flow-run-causality-is-a-read-model.md) +109. [ADR-0109 — Browser journeys are product-owned reusable automation assets](0109-product-owned-browser-journeys.md) +110. [ADR-0110 — Browser campaigns use dedicated Workspaces](0110-browser-campaigns-use-dedicated-workspaces.md) diff --git a/src/Agentstration.Web.Components/MainLayout.razor b/src/Agentstration.Web.Components/MainLayout.razor index f2ecc2a3..dd10eb29 100644 --- a/src/Agentstration.Web.Components/MainLayout.razor +++ b/src/Agentstration.Web.Components/MainLayout.razor @@ -11,7 +11,7 @@ @inject ConsoleContextState ContextState @inject Microsoft.Extensions.Localization.IStringLocalizer Localizer -
+