Skip to content
Merged
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
373 changes: 373 additions & 0 deletions .agent/plans/2026-05-31-playwright-sample-e2e/implementation-plan.md

Large diffs are not rendered by default.

421 changes: 421 additions & 0 deletions .agent/plans/2026-05-31-playwright-sample-e2e/research.md

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions .devcontainer/post-create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,36 @@ else
echo "==> cloudflared already installed: $(cloudflared --version | head -n1)"
fi

# --- Node.js (for Playwright E2E tests) ---------------------------------------
# Installs Node.js 20 LTS from NodeSource. The browser end-to-end suite under
# tests/e2e/ uses @playwright/test (TypeScript), which needs a Node toolchain.
# Docs: https://github.com/nodesource/distributions
if ! command -v node >/dev/null 2>&1; then
echo "==> Installing Node.js 20 LTS"

sudo_cmd=""
if [[ $EUID -ne 0 ]]; then
sudo_cmd="sudo"
fi

curl -fsSL https://deb.nodesource.com/setup_20.x | $sudo_cmd bash -
$sudo_cmd apt-get install -y nodejs
else
echo "==> Node.js already installed: $(node --version)"
fi

# --- Playwright E2E dependencies + browsers ------------------------------------
# Installs the npm toolchain and the Chromium browser used by the E2E suite.
# Idempotent: npm ci is a no-op when node_modules is current; browser install
# skips already-present binaries.
E2E_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/tests/e2e"
if [[ -f "${E2E_DIR}/package.json" ]]; then
echo "==> Installing Playwright E2E toolchain in ${E2E_DIR}"
(cd "${E2E_DIR}" && npm ci && npx --yes playwright install --with-deps chromium)
else
echo "==> Skipping Playwright install (no ${E2E_DIR}/package.json yet)"
fi

# --- Bash: git completion + git status in prompt ------------------------------

# Idempotent: only appended once, guarded by a marker line.
Expand Down
61 changes: 59 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- uses: actions/setup-dotnet@v4
- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'

Expand All @@ -25,3 +25,60 @@ jobs:

- name: Test
run: dotnet test AAuth.slnx --no-build -c Release --logger "trx;LogFileName=results.trx"

e2e:
needs: build
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v5

- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'

- uses: actions/setup-node@v5
with:
node-version: '20'
cache: npm
cache-dependency-path: tests/e2e/package-lock.json

- name: Resolve Playwright version
id: pw
run: echo "version=$(node -p "require('./package.json').devDependencies['@playwright/test']")" >> "$GITHUB_OUTPUT"
working-directory: tests/e2e

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.pw.outputs.version }}

- name: Install E2E dependencies
run: npm ci
working-directory: tests/e2e

- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
working-directory: tests/e2e

- name: Run E2E specs
run: npm test
working-directory: tests/e2e

- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v5
with:
name: playwright-report
path: tests/e2e/playwright-report
retention-days: 7

- name: Upload test-results
if: ${{ failure() }}
uses: actions/upload-artifact@v5
with:
name: playwright-test-results
path: tests/e2e/test-results
retention-days: 7

4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ jobs:
id-token: write

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- uses: actions/setup-dotnet@v4
- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'

Expand Down
19 changes: 18 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ AP_URL := http://localhost:5301
ORCH_URL := http://localhost:5200
TOUR_URL := http://localhost:5400
SAMPLE_URL := http://localhost:5240

E2E_DIR := tests/e2e
.DEFAULT_GOAL := help

.PHONY: help build restore test test-unit test-conformance \
whoami ps ap tour agent demo \
e2e-install e2e e2e-tour e2e-sample e2e-report \
live clean format

help: ## List available targets
Expand Down Expand Up @@ -107,6 +108,22 @@ demo-sample: ## Start WhoAmI + Orchestrator + MockPersonServer + MockAgentProvid
$(DOTNET) run --project $(SAMPLE_PROJECT) & \
wait

e2e-install: ## Install the Playwright toolchain + Chromium (run once)
cd $(E2E_DIR) && npm ci && npm run install-browsers

e2e: ## Run all Playwright E2E specs (boots backends + apps via webServer)
cd $(E2E_DIR) && npm test

e2e-tour: ## Run the GuidedTour Playwright specs only
cd $(E2E_DIR) && npm run test:tour

e2e-sample: ## Run the SampleApp Playwright specs only
cd $(E2E_DIR) && npm run test:sample

e2e-report: ## Serve the last Playwright HTML report (Ctrl-C to stop)
@echo "Serving report at http://localhost:9323 — open it in your browser, Ctrl-C to stop."
cd $(E2E_DIR) && npm run report

clean: ## dotnet clean + remove bin/ obj/ trees
$(DOTNET) clean $(SOLUTION)
find . -type d \( -name bin -o -name obj \) -prune -exec rm -rf {} +
Expand Down
43 changes: 43 additions & 0 deletions samples/GuidedTour/playwright-tests/autonomous.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { test, expect } from '../../../tests/e2e/helpers/fixtures';
import {
openTour,
selectFlow,
runAll,
selectStep,
expectResponse,
readResponseJson,
TourMode,
} from '../../../tests/e2e/helpers/tour';
import { Agents, Urls } from '../../../tests/e2e/helpers/agents';

/**
* PS-Asserted (Direct Grant) — autonomous three-party flow, 6 steps, no human.
* The agent has standing consent at the Person Server (the page pre-seeds it via
* PrepareConsentStateAsync), so POST /token returns an auth_token immediately and
* the replayed GET / returns 200 with a three-party identity. Assert the actual
* 200 result and the full claim set on the final step.
*/
test.describe.configure({ timeout: 60_000 });

test('autonomous flow exchanges and replays to a three-party 200', async ({ page }) => {
await openTour(page);
await selectFlow(page, TourMode.Autonomous);

await runAll(page);

// Step 6 ("Replay GET / with auth_token") is the resource result.
await selectStep(page, 5);
await expectResponse(page, 200, ['three-party']);

const json = (await readResponseJson(page)) as Record<string, unknown>;
expect(json.mode).toBe('three-party');
expect(json.scheme).toBe('jwt');
expect(json.agent).toBe(Agents.tour);
expect(json.sub).toBe('pairwise-sub');
expect(json.scope).toEqual(['whoami']);
expect(json.iss).toBe(Urls.personServer);
// Standing-consent single-hop grant — act names the tour agent, no nesting.
const act = json.act as Record<string, unknown>;
expect(act.sub).toBe(Agents.tour);
expect(act.act).toBeUndefined();
});
37 changes: 37 additions & 0 deletions samples/GuidedTour/playwright-tests/bootstrap.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect } from '../../../tests/e2e/helpers/fixtures';
import { openTour, selectFlow, runAll, doneSteps, TourMode } from '../../../tests/e2e/helpers/tour';
import { Agents, Urls } from '../../../tests/e2e/helpers/agents';

/**
* Bootstrap — keygen + Agent Provider enrolment, 3 steps (AP configured). There
* is no resource call in this flow; the result is the minted `aa-agent+jwt`. The
* DoD here is that all three steps complete and the final step renders the agent
* token whose decoded payload binds the tour agent's identity to the AP issuer.
*/
test.describe.configure({ timeout: 60_000 });

test('bootstrap enrols and mints an agent token', async ({ page }) => {
await openTour(page);
await selectFlow(page, TourMode.Bootstrap);

await runAll(page);

await expect(doneSteps(page)).toHaveCount(3);
await expect(page.locator('button.primary')).toHaveText('Done');

// Final step auto-selected; the enrolment yields an aa-agent+jwt. The token
// header declares the AAuth agent-token type; the decoded payload binds the
// tour agent's identity (sub) to the AP issuer with a cnf key.
const inspector = page.locator('section.payload article.inspector');
const header = inspector.locator('details.token', { hasText: 'Decoded header' });
await expect(header.locator('pre code')).toContainText('aa-agent');

const payloadPanel = inspector.locator('details.token', { hasText: 'Decoded payload' });
const payloadText = await payloadPanel.locator('pre code').innerText();
const payload = JSON.parse(
payloadText.slice(payloadText.indexOf('{'), payloadText.lastIndexOf('}') + 1),
) as Record<string, unknown>;
expect(payload.sub).toBe(Agents.tour);
expect(payload.iss).toBe(Urls.agentProvider);
expect(payload.cnf).toBeTruthy(); // proof-of-possession confirmation key
});
73 changes: 73 additions & 0 deletions samples/GuidedTour/playwright-tests/call-chain.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { test, expect } from '../../../tests/e2e/helpers/fixtures';
import {
openTour,
selectFlow,
runAll,
selectStep,
expectResponse,
readResponseJson,
TourMode,
} from '../../../tests/e2e/helpers/tour';
import { grantConsent } from '../../../tests/e2e/helpers/consent';
import { Agents, Urls } from '../../../tests/e2e/helpers/agents';

/**
* Call Chain — multi-agent delegation Agent → Orchestrator → WhoAmI, 7 steps,
* no human. The tour self-seeds hop-1 consent (tour-agent → Orchestrator) via
* PrepareConsentStateAsync; this spec also pre-grants hop-2 (Orchestrator →
* WhoAmI) so the Orchestrator's downstream chaining mints immediately. The
* retry step renders the combined 200 with a three-party downstream identity,
* and the inspect step surfaces the nested `act` delegation chain.
*/
test.describe.configure({ timeout: 90_000 });

test.beforeEach(async ({ request }) => {
// Hop 2: the Orchestrator chains downstream to WhoAmI on the user's behalf.
await grantConsent(request, 'aauth:orchestrator@localhost:5200', Urls.whoami);
});

test('call chain replays through the orchestrator to a three-party 200', async ({ page }) => {
await openTour(page);
await selectFlow(page, TourMode.CallChain);

await runAll(page);

// Step 6 ("Retry Orchestrator with auth_token → 200") holds the combined
// resource result: a three-party downstream identity with a nested act chain.
await selectStep(page, 5);
await expectResponse(page, 200, ['three-party', 'act']);

const json = (await readResponseJson(page)) as Record<string, unknown>;

// Upstream: how the tour agent authenticated to the Orchestrator.
const upstream = json.upstream as Record<string, unknown>;
expect(upstream.agent).toBe(Agents.tour);

// Orchestrator: the intermediary's own identity.
const orchestrator = json.orchestrator as Record<string, unknown>;
expect(orchestrator.identity).toBe('aauth:orchestrator@localhost:5200');

// Downstream: WhoAmI's three-party identity with the nested act chain.
const downstream = json.downstream as Record<string, unknown>;
expect(downstream.mode).toBe('three-party');
expect(downstream.scheme).toBe('jwt');
expect(downstream.agent).toBe('aauth:orchestrator@localhost:5200');
expect(downstream.sub).toBe('pairwise-sub');
expect(downstream.scope).toEqual(['whoami']);
expect(downstream.iss).toBe(Urls.personServer);

// The act chain proves the full delegation path:
// act.sub = the Orchestrator (immediate actor)
// act.act.sub = the original calling agent (the tour agent)
const act = downstream.act as Record<string, unknown>;
expect(act.sub).toBe('aauth:orchestrator@localhost:5200');
const innerAct = act.act as Record<string, unknown>;
expect(innerAct.sub).toBe(Agents.tour);

// Step 7 ("Inspect multi-agent chain result") renders the decoded chain
// summary showing the full Agent → Orchestrator → WhoAmI delegation.
await selectStep(page, 6);
await expect(
page.locator('section.payload article.inspector details.token pre code'),
).toContainText('Call Chain Summary');
});
Loading
Loading