From e20a86c3fa575210dfc92c073bfa8ca9915fae74 Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Sat, 5 Sep 2026 16:50:12 +0200 Subject: [PATCH 01/11] feat(testing): add reusable Playwright browser journeys --- .github/workflows/ci.yml | 52 + .gitignore | 4 + README.md | 2 + automation/playwright/README.md | 17 + automation/playwright/cli/capture.ts | 96 ++ .../examples/console-home.capture-plan.json | 17 + automation/playwright/package-lock.json | 976 ++++++++++++++++++ automation/playwright/package.json | 21 + automation/playwright/playwright.config.ts | 21 + .../playwright/src/capture/capture-plan.ts | 35 + .../src/capture/screenshot-recorder.ts | 39 + .../playwright/src/fixtures/product-hosts.ts | 150 +++ .../playwright/src/fixtures/repository.ts | 10 + automation/playwright/src/fixtures/test.ts | 19 + .../journeys/authenticate-console.journey.ts | 19 + automation/playwright/src/journeys/journey.ts | 18 + .../playwright/src/journeys/registry.ts | 6 + automation/playwright/src/pages/login.page.ts | 17 + .../playwright/src/pages/product.pages.ts | 10 + .../tests/console-authentication.spec.ts | 15 + automation/playwright/tsconfig.json | 11 + docs/contributing/browser-automation.md | 48 + docs/contributing/overview.md | 1 + .../0079-product-owned-browser-journeys.md | 26 + docs/decisions/index.md | 1 + .../MainLayout.razor | 2 +- .../Components/LatestRunEvents.razor | 2 +- .../Components/Pages/Home.razor | 2 + src/Agentstration.Web/Pages/Login.cshtml | 6 +- .../WorkplaceLayout.razor | 2 +- 30 files changed, 1639 insertions(+), 6 deletions(-) create mode 100644 automation/playwright/README.md create mode 100644 automation/playwright/cli/capture.ts create mode 100644 automation/playwright/examples/console-home.capture-plan.json create mode 100644 automation/playwright/package-lock.json create mode 100644 automation/playwright/package.json create mode 100644 automation/playwright/playwright.config.ts create mode 100644 automation/playwright/src/capture/capture-plan.ts create mode 100644 automation/playwright/src/capture/screenshot-recorder.ts create mode 100644 automation/playwright/src/fixtures/product-hosts.ts create mode 100644 automation/playwright/src/fixtures/repository.ts create mode 100644 automation/playwright/src/fixtures/test.ts create mode 100644 automation/playwright/src/journeys/authenticate-console.journey.ts create mode 100644 automation/playwright/src/journeys/journey.ts create mode 100644 automation/playwright/src/journeys/registry.ts create mode 100644 automation/playwright/src/pages/login.page.ts create mode 100644 automation/playwright/src/pages/product.pages.ts create mode 100644 automation/playwright/tests/console-authentication.spec.ts create mode 100644 automation/playwright/tsconfig.json create mode 100644 docs/contributing/browser-automation.md create mode 100644 docs/decisions/0079-product-owned-browser-journeys.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e6567ef..2239437f 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,52 @@ 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 + - 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..18c780ba --- /dev/null +++ b/automation/playwright/README.md @@ -0,0 +1,17 @@ +# 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 +``` + +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..5ea53819 --- /dev/null +++ b/automation/playwright/cli/capture.ts @@ -0,0 +1,96 @@ +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 { 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 argumentsMap = parseArguments(process.argv.slice(2)); +const planFile = required(argumentsMap, 'plan'); +const outputDirectory = path.resolve(required(argumentsMap, 'output')); +const plan = await readCapturePlan(path.resolve(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 addresses = plan.consoleUrl && plan.workplaceUrl + ? { consoleUrl: plan.consoleUrl, workplaceUrl: plan.workplaceUrl } + : (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(); + await journey({ + ...addresses, + pages: new ProductPages(page), + 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(); +} + +function parseArguments(values: string[]): Map { + const result = 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 .'); + result.set(key.slice(2), value); + } + return result; +} + +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/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/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-plan.ts b/automation/playwright/src/capture/capture-plan.ts new file mode 100644 index 00000000..7b653376 --- /dev/null +++ b/automation/playwright/src/capture/capture-plan.ts @@ -0,0 +1,35 @@ +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) || (!plan.consoleUrl && plan.workplaceUrl)) { + throw new Error('consoleUrl and workplaceUrl must be supplied together.'); + } + 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/fixtures/product-hosts.ts b/automation/playwright/src/fixtures/product-hosts.ts new file mode 100644 index 00000000..6750f04a --- /dev/null +++ b/automation/playwright/src/fixtures/product-hosts.ts @@ -0,0 +1,150 @@ +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { createWriteStream } from 'node:fs'; +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[]; +} + +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] = await Promise.all([freePort(), freePort()]); + const consoleUrl = `http://127.0.0.1:${consolePort}`; + const workplaceUrl = `http://127.0.0.1:${workplacePort}`; + const bootstrapPath = path.join(repositoryRoot, 'deploy', 'bootstrap', 'profiles'); + + 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, + AI__Provider: 'Deterministic', + 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', + }); + + 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); + throw error; + } + + return { + consoleUrl, + workplaceUrl, + async stop() { + await stopProcess(workplaceHost); + await stopProcess(consoleHost); + }, + }; +} + +function runDotnet(project: string, logFile: string, environment: NodeJS.ProcessEnv): 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'); + + 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..e0eebbb6 --- /dev/null +++ b/automation/playwright/src/journeys/authenticate-console.journey.ts @@ -0,0 +1,19 @@ +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.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/journey.ts b/automation/playwright/src/journeys/journey.ts new file mode 100644 index 00000000..a262d3f1 --- /dev/null +++ b/automation/playwright/src/journeys/journey.ts @@ -0,0 +1,18 @@ +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; + 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..79f9850f --- /dev/null +++ b/automation/playwright/src/journeys/registry.ts @@ -0,0 +1,6 @@ +import { authenticateConsole } from './authenticate-console.journey.js'; +import type { Journey } from './journey.js'; + +export const journeys: Readonly>>> = { + 'authenticate-console': authenticateConsole as Journey>, +}; diff --git a/automation/playwright/src/pages/login.page.ts b/automation/playwright/src/pages/login.page.ts new file mode 100644 index 00000000..26efee53 --- /dev/null +++ b/automation/playwright/src/pages/login.page.ts @@ -0,0 +1,17 @@ +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}/login`, { waitUntil: 'domcontentloaded' }); + if (!response?.ok()) throw new Error(`Login page returned HTTP ${response?.status() ?? 'no response'}.`); + + 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..31ad30fb --- /dev/null +++ b/automation/playwright/src/pages/product.pages.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test'; +import { LoginPage } from './login.page.js'; + +export class ProductPages { + public readonly login: LoginPage; + + public constructor(public readonly page: Page) { + this.login = new LoginPage(page); + } +} 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/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..0a992fdb --- /dev/null +++ b/docs/contributing/browser-automation.md @@ -0,0 +1,48 @@ +# 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. 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 both `consoleUrl` and `workplaceUrl` are provided by the plan. 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. + +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..747f58c9 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 -
+