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
84 changes: 84 additions & 0 deletions .github/workflows/browser-ux.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: Browser UX

on:
pull_request:
paths:
- automation/playwright/**
- deploy/bootstrap/profiles/**
- src/Agentstration.Web/**
- src/Agentstration.Web.Components/**
- src/Agentstration.Web.FlowDesigner/**
- src/Agentstration.Workplace.*/**
- global.json
- Directory.Build.props
- Directory.Build.targets
- Directory.Packages.props
- .github/workflows/browser-ux.yml
push:
branches: [main]
paths:
- automation/playwright/**
- deploy/bootstrap/profiles/**
- src/Agentstration.Web/**
- src/Agentstration.Web.Components/**
- src/Agentstration.Web.FlowDesigner/**
- src/Agentstration.Workplace.*/**
- global.json
- Directory.Build.props
- Directory.Build.targets
- Directory.Packages.props
- .github/workflows/browser-ux.yml
workflow_dispatch:

permissions:
contents: read

concurrency:
group: browser-ux-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
browser-ux:
name: browser-ux
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Check out repository
uses: actions/checkout@v7
- name: Set up .NET
uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: automation/playwright/package-lock.json
- name: Restore browser hosts
run: |
dotnet restore src/Agentstration.Web/Agentstration.Web.csproj -p:NuGetAudit=true -p:NuGetAuditMode=all
dotnet restore src/Agentstration.Workplace.Web/Agentstration.Workplace.Web.csproj -p:NuGetAudit=true -p:NuGetAuditMode=all
dotnet restore src/Agentstration.Extensions.Ollama/Agentstration.Extensions.Ollama.csproj -p:NuGetAudit=true -p:NuGetAuditMode=all
- name: Install browser automation dependencies
working-directory: automation/playwright
run: npm ci
- name: Audit browser automation dependencies
working-directory: automation/playwright
run: npm audit --audit-level=high
- name: Install Chromium
working-directory: automation/playwright
run: npx playwright install --with-deps chromium
- name: Run browser UX smoke tests
working-directory: automation/playwright
run: npm run test:smoke
- name: Upload browser diagnostics
if: failure()
uses: actions/upload-artifact@v6
with:
name: browser-ux-diagnostics
path: |
automation/playwright/test-results
automation/playwright/playwright-report
automation/playwright/.work/*/*.log
if-no-files-found: ignore
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ deploy/compose/.env.postgresql
docs/site/node_modules/
docs/site/build/
docs/site/.docusaurus/
automation/playwright/node_modules/
automation/playwright/test-results/
automation/playwright/playwright-report/
automation/playwright/.work/
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ dotnet build Agentstration.slnx --configuration Release
dotnet test Agentstration.slnx --configuration Release
```

Browser-level UX smoke tests and the reusable capture runner live under `automation/playwright`. They start isolated local Console and Workplace hosts with deterministic AI. See [Browser automation](docs/contributing/browser-automation.md) for setup and commands.

Warnings are treated as errors, .NET analyzers are enabled and NuGet audit findings fail restore. The default tests are designed to remain offline and cost-free; real-provider smoke tests are opt-in.

## Documentation
Expand Down
22 changes: 22 additions & 0 deletions automation/playwright/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Playwright automation instructions

These instructions apply to `automation/playwright`.

## Stable contracts

- Declare every Playwright-owned `data-testid` value in `src/contracts/test-ids.ts` and use those constants from page objects. Razor markup remains the contract provider and must use the matching literal because TypeScript constants are not a runtime product dependency.
- Prefer accessible roles and labels for locale-specific semantic tests. Add a Test ID only for cross-locale journeys, ambiguous controls, stable capture targets, or non-visible readiness state.
- Declare journey checkpoint names in `src/contracts/checkpoints.ts`. Checkpoint values are external capture contracts: do not rename or remove one without updating example plans, documentation, and consumers. Add a new semantic state instead of encoding step numbers or layout positions.
- Keep independent localization expectations in `src/locales/expected-text.ts`. Never derive these expectations from product `.resx` files; doing so would make localization assertions circular. Add every supported locale value when extending the contract.

## Page objects and journeys

- Page objects own all locators and expose semantic operations or stable capture targets. Journeys must not contain raw Test ID strings, CSS selectors, or localized product text.
- A checkpoint represents a stable user-visible or domain state after the relevant wait condition succeeds. Emit checkpoints after meaningful blocks, not after arbitrary sleeps or individual DOM operations.
- Workspace creation and selection belong to the workspace page object and journey. Other journeys may select a prepared campaign workspace, but must not silently create, delete, or replace one.
- Treat a campaign Workspace as test-data isolation, not as localization state. Locale, theme, viewport, product revision, and output evidence remain separate campaign dimensions.

## Validation

- Add or update a Playwright test with every journey behavior change.
- Run `npm exec tsc -- --noEmit` and the affected Playwright test. Run `npm run test:smoke` before handoff when product hosts or shared page objects change.
63 changes: 63 additions & 0 deletions automation/playwright/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Agentstration browser automation

This workspace owns the Playwright page objects and journeys used by product UX tests and reproducible external capture.

```powershell
npm ci
npm run install:browsers
npm run test:smoke
```

To use Playwright UI against an already-running instance:

```powershell
$env:AGENTSTRATION_CONSOLE_URL = "http://localhost:53400"
$env:AGENTSTRATION_WORKPLACE_URL = "http://localhost:53401" # Optional for Console-only tests.
$env:AGENTSTRATION_USERNAME = "admin"
$env:AGENTSTRATION_PASSWORD = "admin"
$env:AGENTSTRATION_PLAYWRIGHT_CHANNEL = "chrome" # Optional local fallback.
npx playwright test --ui
```

When `AGENTSTRATION_CONSOLE_URL` is present, the test fixture does not start or stop product hosts and requires the username/password pair. Journey input can explicitly override both values. Without an external URL, tests keep using dynamically allocated local ports, isolated SQLite data, and the public Development fixture `admin / admin`. An omitted Workplace URL falls back to the Console URL so Console-only journeys remain usable; set it explicitly before running a Workplace journey. External tests mutate the selected instance, so use a disposable instance or dedicated campaign Workspace.

To exercise the capture contract:

```powershell
npm run capture -- --plan examples/console-home.capture-plan.json --output .work/example-capture
```

The welcome-agent plan replays the first agent created in the handoff demo:

```powershell
npm run capture -- --plan examples/create-welcome-agent.capture-plan.json --output .work/welcome-agent
```

Create and select a dedicated campaign workspace with:

```powershell
npm run capture -- --plan examples/create-campaign-workspace.capture-plan.json --output .work/campaign-workspace
```

The solution-discovery video Flow and Entry are captured with:

```powershell
npm run capture -- --plan examples/create-solution-discovery-flow.capture-plan.json --output .work/solution-discovery-flow --console-url https://agentstration.example.com
npm run capture -- --plan examples/create-solution-discovery-entry.capture-plan.json --output .work/solution-discovery-entry --console-url https://agentstration.example.com
```

Run these commands against the same persistent instance and campaign Workspace. The Flow plan expects the four agent technical names declared in its `participants` input. The Entry plan expects that Flow to have been published and activated first.

To run that plan against an existing Console without starting local product hosts, override its URL from the command line:

```powershell
npm run capture -- --plan examples/create-welcome-agent.capture-plan.json --output .work/welcome-agent --console-url https://agentstration.example.com
```

Command-line URLs take precedence over plan values. `--workplace-url` is optional for Console-only journeys and can be supplied when a journey also uses Workplace.

On a persistent external instance, run the workspace plan first and set `workspaceName` in subsequent journey input. The target workspace must be prepared explicitly with every resource required by those journeys, such as model and runtime profiles. Local capture commands start isolated product state, so separate commands do not share a workspace.

Profile inputs may be an ordered array when equivalent environments use different resource names. The journey selects the first available candidate and reports the available options immediately when none match.

See [Browser automation](../../docs/contributing/browser-automation.md), [ADR-0109](../../docs/decisions/0109-product-owned-browser-journeys.md), and [ADR-0110](../../docs/decisions/0110-browser-campaigns-use-dedicated-workspaces.md) for ownership, extension, and external-consumption rules.
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,37 @@
{
"journey": "create-workspace",
"input": {
"name": "capture-handoff",
"displayName": "Handoff capture campaign"
},
"locale": "en-US",
"theme": "dark",
"viewport": {
"width": 1920,
"height": 1080
},
"captures": [
{
"checkpoint": "workspace-form-empty",
"file": "01-workspace-form-empty.png",
"scope": "page",
"fullPage": false
},
{
"checkpoint": "workspace-identity-complete",
"file": "02-workspace-identity-complete.png",
"scope": "target"
},
{
"checkpoint": "workspace-created",
"file": "03-workspace-created.png",
"scope": "target"
},
{
"checkpoint": "workspace-selected",
"file": "04-workspace-selected.png",
"scope": "page",
"fullPage": false
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"journey": "create-entry",
"input": {
"workspaceName": "capture-handoff",
"name": "solution-discovery",
"displayName": "Découvrir Agentstration",
"description": "Échangez avec une équipe d’agents spécialisés pour découvrir la solution et poser vos questions.",
"presentationKind": "Prompt",
"placeholder": "Que souhaitez-vous savoir sur Agentstration ?",
"icon": "briefcase",
"participantsVisibility": "Hidden",
"progressVisibility": "Compact",
"taskDisplay": "Auto",
"resultsDisplay": "Auto",
"field": {
"name": "request",
"label": "Votre question",
"description": "Décrivez votre besoin ou posez une question sur la solution.",
"placeholder": "Que souhaitez-vous savoir sur Agentstration ?",
"type": "Prompt",
"required": true,
"primary": true
},
"targetFlow": "solution-discovery",
"targetNamespace": "default",
"taskCreationMode": "Automatic",
"allowConversation": true,
"streamResponse": true,
"suggestions": [
{
"label": "Plusieurs agents",
"value": "Nous cherchons une solution pour faire collaborer plusieurs agents spécialisés. Est-ce qu’Agentstration peut répondre à ce besoin ?"
},
{
"label": "Exécution locale",
"value": "Certaines de nos données doivent rester entièrement sur notre infrastructure. Peut-on utiliser des modèles locaux ?"
},
{
"label": "Outils internes",
"value": "Comment nos agents peuvent-ils utiliser nos outils internes ou appeler nos propres services ?"
}
]
},
"locale": "fr-FR",
"theme": "dark",
"viewport": { "width": 1920, "height": 1080 },
"captures": [
{ "checkpoint": "entry-identity-complete", "file": "01-entry-identity-complete.png", "scope": "target" },
{ "checkpoint": "entry-appearance-complete", "file": "02-entry-appearance-complete.png", "scope": "target" },
{ "checkpoint": "entry-interaction-complete", "file": "03-entry-interaction-complete.png", "scope": "target" },
{ "checkpoint": "entry-field-complete", "file": "04-entry-field-complete.png", "scope": "target" },
{ "checkpoint": "entry-binding-complete", "file": "05-entry-binding-complete.png", "scope": "target" },
{ "checkpoint": "entry-suggestions-complete", "file": "06-entry-suggestions-complete.png", "scope": "target" },
{ "checkpoint": "entry-ready-to-publish", "file": "07-entry-ready-to-publish.png", "scope": "page", "fullPage": false },
{ "checkpoint": "entry-published", "file": "08-entry-published.png", "scope": "page", "fullPage": false }
]
}
Loading
Loading