Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions automation/playwright/README.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 81 additions & 0 deletions automation/playwright/cli/capture.ts
Original file line number Diff line number Diff line change
@@ -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();
}
17 changes: 17 additions & 0 deletions automation/playwright/examples/console-home.capture-plan.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
Original file line number Diff line number Diff line change
@@ -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
}
]
}
Loading
Loading