diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e6567ef..1a9b64f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: dotnet: ${{ steps.classify.outputs.dotnet }} aep: ${{ steps.classify.outputs.aep }} container: ${{ steps.classify.outputs.container }} + ux: ${{ steps.classify.outputs.ux }} steps: - name: Check out repository uses: actions/checkout@v7 @@ -62,15 +63,20 @@ jobs: $container = $runEverything -or @($changedFiles | Where-Object { $_ -match '^(src/|aep/src/|deploy/|Dockerfile$|\.dockerignore$|global\.json$|Agentstration\.slnx$|Directory\.(Build|Packages)\.(props|targets)$|\.github/workflows/ci\.yml$)' }).Count -gt 0 + $ux = $runEverything -or @($changedFiles | Where-Object { + $_ -match '^(automation/playwright/|deploy/bootstrap/profiles/|src/Agentstration\.Web/|src/Agentstration\.Web\.Components/|src/Agentstration\.Web\.FlowDesigner/|src/Agentstration\.Workplace\.|global\.json$|Directory\.(Build|Packages)\.(props|targets)$|\.github/workflows/ci\.yml$)' + }).Count -gt 0 "dotnet=$($dotnet.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT "aep=$($aep.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT "container=$($container.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + "ux=$($ux.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT Write-Host "Changed files: $($changedFiles.Count)" Write-Host "Run .NET validation: $dotnet" Write-Host "Run complete AEP validation: $aep" Write-Host "Build container: $container" + Write-Host "Run browser UX validation: $ux" windows-host-lifecycle: name: windows-host-lifecycle @@ -99,6 +105,53 @@ jobs: - name: Verify Windows host lifecycle run: dotnet tests/Agentstration.Web.Tests/bin/Release/net10.0/Agentstration.Web.Tests.dll --filter "FullyQualifiedName~QuartzHostLifecycleTests|FullyQualifiedName~StartupDoesNotCreateLegacyDataJson" --progress off + browser-ux: + name: browser-ux + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.ux == 'true' + 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 + build-and-test: name: build-and-test runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 375dc471..d4b8debe 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ artifacts/ 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 112e1609..269eae5f 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,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/README.md b/automation/playwright/README.md new file mode 100644 index 00000000..c2f8083a --- /dev/null +++ b/automation/playwright/README.md @@ -0,0 +1,33 @@ +# 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 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 +``` + +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. + +During a capture, elements whose computed position is `sticky` are temporarily rendered in normal document flow. This prevents sticky toolbars from covering content in page and target screenshots without changing their behavior in the running product. + +See [Browser automation](../../docs/contributing/browser-automation.md) and [ADR-0079](../../docs/decisions/0079-product-owned-browser-journeys.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-welcome-agent.capture-plan.json b/automation/playwright/examples/create-welcome-agent.capture-plan.json new file mode 100644 index 00000000..c60fe0ab --- /dev/null +++ b/automation/playwright/examples/create-welcome-agent.capture-plan.json @@ -0,0 +1,43 @@ +{ + "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: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": "page", + "fullPage": false + }, + { + "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..7288e175 --- /dev/null +++ b/automation/playwright/src/capture/capture-cli-options.ts @@ -0,0 +1,70 @@ +import type { CapturePlan } from './capture-plan.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 = normalizeHttpUrl(consoleUrl, 'Console'); + return { + consoleUrl: normalizedConsoleUrl, + workplaceUrl: workplaceUrl ? normalizeHttpUrl(workplaceUrl, 'Workplace') : normalizedConsoleUrl, + }; +} + +function normalizeHttpUrl(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(/\/+$/, ''); +} + +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..993cd421 --- /dev/null +++ b/automation/playwright/src/capture/screenshot-recorder.ts @@ -0,0 +1,64 @@ +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'; + +const captureStickyAttribute = 'data-agentstration-capture-sticky'; +const captureStyle = `[${captureStickyAttribute}] { position: static !important; }`; + +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' && !checkpoint.target) { + throw new Error(`Checkpoint ${checkpoint.name} has no target.`); + } + + await markStickyElements(checkpoint); + try { + if (request.scope === 'target') { + await checkpoint.target!.screenshot({ path: destination, style: captureStyle }); + } else { + await checkpoint.page.screenshot({ path: destination, fullPage: request.fullPage ?? true, style: captureStyle }); + } + } finally { + await restoreStickyElements(checkpoint); + } + 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); + }; +} + +async function markStickyElements(checkpoint: JourneyCheckpoint): Promise { + await checkpoint.page.locator('*').evaluateAll((elements, attribute) => { + for (const element of elements) { + if (getComputedStyle(element).position === 'sticky') element.setAttribute(attribute, ''); + } + }, captureStickyAttribute); +} + +async function restoreStickyElements(checkpoint: JourneyCheckpoint): Promise { + await checkpoint.page.locator(`[${captureStickyAttribute}]`).evaluateAll((elements, attribute) => { + for (const element of elements) element.removeAttribute(attribute); + }, captureStickyAttribute); +} 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..ef2736d3 --- /dev/null +++ b/automation/playwright/src/fixtures/test.ts @@ -0,0 +1,19 @@ +import { test as base } from '@playwright/test'; +import { startProductHosts, type ProductHosts } from './product-hosts.js'; + +interface AgentstrationWorkerFixtures { + product: ProductHosts; +} + +export const test = base.extend<{}, AgentstrationWorkerFixtures>({ + product: [async ({}, use) => { + 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..27e25b0c --- /dev/null +++ b/automation/playwright/src/journeys/authenticate-console.journey.ts @@ -0,0 +1,20 @@ +import type { Journey } from './journey.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('console-shell').waitFor({ state: 'visible' }); + const overview = context.pages.page.locator('[data-testid="platform-overview"][aria-busy="false"]'); + await overview.waitFor({ state: 'visible' }); + await overview.locator('[aria-busy="true"]').waitFor({ state: 'detached' }); + await context.checkpoint({ + name: '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..513b323b --- /dev/null +++ b/automation/playwright/src/journeys/create-agent.journey.ts @@ -0,0 +1,46 @@ +import type { AgentDefinition } from '../pages/agent-editor.page.js'; +import type { Journey } from './journey.js'; + +export type CreateAgentInput = AgentDefinition & { + username?: string; + password?: string; +}; + +export const createAgent: 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.agentEditor.openNew(context.consoleUrl); + await context.checkpoint({ + name: 'agent-form-empty', + page: context.pages.page, + target: context.pages.page.getByTestId('agent-editor-form'), + }); + + await context.pages.agentEditor.fillIdentity(input); + await context.checkpoint({ + name: 'agent-identity-complete', + page: context.pages.page, + target: context.pages.page.getByTestId('agent-identity-section'), + }); + + await context.pages.agentEditor.configureBehavior(input); + await context.checkpoint({ + name: 'agent-ready-to-create', + page: context.pages.page, + target: context.pages.page.getByTestId('agent-editor-form'), + }); + + await context.pages.agentEditor.createAndDeploy(input.name); + await context.checkpoint({ + name: 'agent-created', + page: context.pages.page, + target: context.pages.page.getByTestId('agent-editor-form'), + }); +}; + +function validate(input: CreateAgentInput): void { + for (const property of ['name', 'displayName', 'description', 'instructions', 'modelProfile', 'runtimeProfile'] as const) { + if (!input[property]?.trim()) throw new Error(`Create agent journey input '${property}' 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/registry.ts b/automation/playwright/src/journeys/registry.ts new file mode 100644 index 00000000..3f581a8e --- /dev/null +++ b/automation/playwright/src/journeys/registry.ts @@ -0,0 +1,8 @@ +import { authenticateConsole } from './authenticate-console.journey.js'; +import { createAgent, type CreateAgentInput } from './create-agent.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), +}; 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..fb0de208 --- /dev/null +++ b/automation/playwright/src/pages/agent-editor.page.ts @@ -0,0 +1,46 @@ +import type { Page } from '@playwright/test'; + +export interface AgentDefinition { + name: string; + displayName: string; + description: string; + instructions: string; + modelProfile: string; + runtimeProfile: string; +} + +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="agent-editor-form"][data-interactive="true"]').waitFor({ state: 'visible' }); + } + + public async fillIdentity(agent: Pick): Promise { + await fillAndCommit(this.page.getByTestId('agent-name'), agent.name); + await fillAndCommit(this.page.getByTestId('agent-display-name'), agent.displayName); + await fillAndCommit(this.page.getByTestId('agent-description'), agent.description); + } + + public async configureBehavior(agent: Pick): Promise { + await this.page.getByTestId('model-profile-select').selectOption(agent.modelProfile); + await this.page.getByTestId('agent-runtime-profile').selectOption(agent.runtimeProfile); + await fillAndCommit(this.page.getByTestId('agent-instructions'), agent.instructions); + } + + public async createAndDeploy(name: string): Promise { + await Promise.all([ + this.page.waitForURL(url => url.pathname === `/agents/${encodeURIComponent(name)}`), + this.page.getByTestId('agent-create-and-deploy').click(), + ]); + await this.page.getByTestId('agent-editor-form').waitFor({ state: 'visible' }); + await this.page.getByTestId('agent-name').waitFor({ state: 'visible' }); + } +} + +async function fillAndCommit(locator: ReturnType, value: string): Promise { + await locator.fill(value); + await locator.blur(); +} diff --git a/automation/playwright/src/pages/login.page.ts b/automation/playwright/src/pages/login.page.ts new file mode 100644 index 00000000..58f58922 --- /dev/null +++ b/automation/playwright/src/pages/login.page.ts @@ -0,0 +1,21 @@ +import type { Page } from '@playwright/test'; + +export class LoginPage { + public constructor(private readonly page: Page) {} + + public async signIn(consoleUrl: string, username = 'admin', password = 'admin'): 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('console-shell').waitFor({ state: 'visible' }); + return; + } + + await this.page.getByTestId('login-username').fill(username); + await this.page.getByTestId('login-password').fill(password); + await Promise.all([ + this.page.waitForURL(url => !url.pathname.startsWith('/login')), + this.page.getByTestId('login-submit').click(), + ]); + } +} diff --git a/automation/playwright/src/pages/product.pages.ts b/automation/playwright/src/pages/product.pages.ts new file mode 100644 index 00000000..6c38350a --- /dev/null +++ b/automation/playwright/src/pages/product.pages.ts @@ -0,0 +1,21 @@ +import type { Page } from '@playwright/test'; +import { AgentEditorPage } from './agent-editor.page.js'; +import { LoginPage } from './login.page.js'; + +export class ProductPages { + public readonly agentEditor: AgentEditorPage; + public readonly login: LoginPage; + + public constructor(public readonly page: Page) { + this.agentEditor = new AgentEditorPage(page); + this.login = new LoginPage(page); + } + + public async ensureTheme(theme: 'light' | 'dark'): Promise { + const shell = this.page.getByTestId('console-shell'); + await this.page.locator('[data-testid="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('theme-toggle').click(); + await this.page.locator(`[data-testid="console-shell"].theme-${theme}`).waitFor({ state: 'visible' }); + } +} 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..7cef428a --- /dev/null +++ b/automation/playwright/tests/console-authentication.spec.ts @@ -0,0 +1,15 @@ +import { ProductPages } from '../src/pages/product.pages.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('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..2d3a5264 --- /dev/null +++ b/automation/playwright/tests/create-agent.spec.ts @@ -0,0 +1,25 @@ +import { ProductPages } from '../src/pages/product.pages.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: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('agent-name')).toHaveValue(agent.name); + await expect(page.getByTestId('agent-name')).toBeDisabled(); +}); diff --git a/automation/playwright/tests/screenshot-recorder.spec.ts b/automation/playwright/tests/screenshot-recorder.spec.ts new file mode 100644 index 00000000..6dfca5aa --- /dev/null +++ b/automation/playwright/tests/screenshot-recorder.spec.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; +import type { CapturePlan } from '../src/capture/capture-plan.js'; +import { createScreenshotRecorder } from '../src/capture/screenshot-recorder.js'; + +test('captures keep sticky elements in document flow and restore the page afterwards', async ({ page }, testInfo) => { + await page.setContent(` +
+
+ +
+
+ `); + const outputDirectory = testInfo.outputPath('capture'); + const plan: CapturePlan = { + journey: 'test', + captures: [ + { checkpoint: 'sticky-target', file: 'sticky-target.png', scope: 'target' }, + { checkpoint: 'sticky-page', file: 'sticky-page.png', scope: 'page', fullPage: true }, + ], + }; + const assets: Array<{ checkpoint: string; file: string; sha256: string }> = []; + const recorder = createScreenshotRecorder(plan, outputDirectory, assets); + + await recorder({ + name: 'sticky-target', + page, + target: page.getByTestId('capture-target'), + }); + + await recorder({ name: 'sticky-page', page }); + + await expect(page.getByTestId('sticky-action')).toHaveCSS('position', 'sticky'); + await expect(page.getByTestId('sticky-action')).not.toHaveAttribute('data-agentstration-capture-sticky'); + await expect.poll(async () => fs.stat(path.join(outputDirectory, 'sticky-target.png')).then(value => value.size)).toBeGreaterThan(0); + await expect.poll(async () => fs.stat(path.join(outputDirectory, 'sticky-page.png')).then(value => value.size)).toBeGreaterThan(0); + expect(assets).toHaveLength(2); +}); 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/contributing/browser-automation.md b/docs/contributing/browser-automation.md new file mode 100644 index 00000000..5cf0eb3e --- /dev/null +++ b/docs/contributing/browser-automation.md @@ -0,0 +1,50 @@ +# 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. + +## 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. + +## 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/0079-product-owned-browser-journeys.md b/docs/decisions/0079-product-owned-browser-journeys.md new file mode 100644 index 00000000..ff51a211 --- /dev/null +++ b/docs/decisions/0079-product-owned-browser-journeys.md @@ -0,0 +1,26 @@ +# ADR-0079: 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/index.md b/docs/decisions/index.md index 24a065e7..b6f51b9d 100644 --- a/docs/decisions/index.md +++ b/docs/decisions/index.md @@ -108,3 +108,4 @@ Use **Proposed** when implementation or repository evidence does not establish a 76. [ADR-0076 — UI localization uses RESX and Principal culture preferences](0076-ui-localization-uses-resx-and-principal-culture-preferences.md) 77. [ADR-0077 — Bootstrap profiles are explicit administrative applications](0077-bootstrap-profiles-are-explicit-administrative-applications.md) 78. [ADR-0078 — PostgreSQL is an optional server storage profile](0078-postgresql-is-an-optional-server-storage-profile.md) +79. [ADR-0079 — Browser journeys are product-owned reusable automation assets](0079-product-owned-browser-journeys.md) diff --git a/src/Agentstration.Web.Components/MainLayout.razor b/src/Agentstration.Web.Components/MainLayout.razor index f2ecc2a3..530554f4 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 -
+
@code { private readonly CancellationTokenSource cancellation = new(); private PlatformDashboardLoad? load; diff --git a/src/Agentstration.Web/Pages/Login.cshtml b/src/Agentstration.Web/Pages/Login.cshtml index f6b70c95..97da6737 100644 --- a/src/Agentstration.Web/Pages/Login.cshtml +++ b/src/Agentstration.Web/Pages/Login.cshtml @@ -16,13 +16,13 @@ - + - + - + } diff --git a/src/Agentstration.Workplace.Components/WorkplaceLayout.razor b/src/Agentstration.Workplace.Components/WorkplaceLayout.razor index 504822f0..d571fdff 100644 --- a/src/Agentstration.Workplace.Components/WorkplaceLayout.razor +++ b/src/Agentstration.Workplace.Components/WorkplaceLayout.razor @@ -6,7 +6,7 @@ @inject WorkplaceContextState WorkplaceContext @inject IRecentConversationNavigationProvider RecentConversationNavigation @inject IJSRuntime JS -
+