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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,32 @@ jobs:
run: npm run build:cloud
working-directory: web-ui

e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "20"
cache: npm
cache-dependency-path: web-ui/package-lock.json
- name: Install
run: npm ci
working-directory: web-ui
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
working-directory: web-ui
- name: E2E test
run: npm run test:e2e
working-directory: web-ui
- name: Upload trace on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: web-ui/test-results/
retention-days: 7

infra:
runs-on: ubuntu-latest
steps:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ admin-ui/dist/
web-ui/node_modules/
web-ui/.next/
web-ui/public/aws-exports.json
web-ui/e2e/.tmp/
web-ui/test-results/
web-ui/playwright-report/

# Kiro / Q-SPEC (local steering, specs, settings)
.kiro/*
Expand Down
14 changes: 14 additions & 0 deletions web-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,20 @@ export default function AuthenticatedLayout({ children }: { children: React.Reac

---

## Testing

```bash
npm test # Unit tests (Vitest + Testing Library)
npm run test:e2e # E2E test (Playwright — first run: npx playwright install chromium)
```

The E2E test starts the Web UI in Local mode against a stub ACP agent
(`e2e/stub-agent.mjs`), so it needs no AWS and no `kiro-cli`. Decks and agent
config are sandboxed under `e2e/.tmp/` — your real `~/Documents/SDPM-Presentations`
and `mcp-local/.sdpm/` are never touched.

---

## Project Structure

```
Expand Down
14 changes: 14 additions & 0 deletions web-ui/README_ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ export default function AuthenticatedLayout({ children }: { children: React.Reac

---

## テスト

```bash
npm test # ユニットテスト(Vitest + Testing Library)
npm run test:e2e # E2E テスト(Playwright — 初回は npx playwright install chromium)
```

E2E テストはスタブ ACP エージェント(`e2e/stub-agent.mjs`)を使って Web UI を
ローカルモードで起動するため、AWS も `kiro-cli` も不要。デッキとエージェント設定は
`e2e/.tmp/` 配下にサンドボックス化され、実際の `~/Documents/SDPM-Presentations` や
`mcp-local/.sdpm/` には触れない。

---

## プロジェクト構成

```
Expand Down
45 changes: 45 additions & 0 deletions web-ui/e2e/deck-creation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT-0
/**
* E2E: deck creation happy path (roadmap 2-5b-4).
*
* Deck list → New Deck → send a chat message → stub agent creates a deck
* and streams tool events → UI navigates to the workspace and shows the
* slide preview. Exercises the full Local-mode stack: React UI, Next.js
* API routes, ACP process manager, SSE bridge, and strandsParser.
*/
import { test, expect } from "@playwright/test"

const DECK_ID = "e2e-test-deck"

test("create deck via chat and see slide preview", async ({ page }) => {
await page.goto("/decks")

// Deck list renders (empty sandbox)
await expect(page.getByRole("heading", { name: "Decks" })).toBeVisible()

// Open the new-deck chat panel
await page.getByRole("button", { name: "New Deck" }).click()
const input = page.getByRole("textbox", { name: "Chat message input" })
await expect(input).toBeVisible()

// Send a message
await input.fill("Create a deck about end-to-end testing")
await page.getByRole("button", { name: "Send message" }).click()

// User message and streamed assistant reply appear
const chatLog = page.getByRole("log", { name: "Chat messages" })
await expect(chatLog).toContainText("Create a deck about end-to-end testing")
await expect(chatLog).toContainText("Done! Your deck is ready.", { timeout: 30_000 })

// Deck-created tool result navigates to the workspace (hash routing)
await expect(page).toHaveURL(new RegExp(`#${DECK_ID}$`), { timeout: 15_000 })

// Workspace polling picks up the slide — the Slides tab appears with a count
const slidesTab = page.getByRole("tab", { name: /Slides/ })
await expect(slidesTab).toBeVisible({ timeout: 30_000 })
await slidesTab.click()

// Slide preview renders from the deck the stub wrote to disk
await expect(page.locator(`img[alt^="Slide 1"]`).first()).toBeVisible({ timeout: 30_000 })
})
36 changes: 36 additions & 0 deletions web-ui/e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT-0
/**
* Playwright global setup — prepares an isolated sandbox under e2e/.tmp:
* - decks/ : SDPM_DECK_ROOT for the dev server (keeps ~/Documents untouched)
* - config/ : acp-config.json pointing the ACP process manager at the
* stub agent instead of kiro-cli (via SDPM_ACP_CONFIG_DIR)
*/
import fs from "fs"
import path from "path"

export default async function globalSetup() {
const tmp = path.resolve(__dirname, ".tmp")
fs.rmSync(tmp, { recursive: true, force: true })
fs.mkdirSync(path.join(tmp, "decks"), { recursive: true })
fs.mkdirSync(path.join(tmp, "config"), { recursive: true })

const stubPath = path.resolve(__dirname, "stub-agent.mjs")
const config = {
activeAgent: "e2e-stub",
agents: [
{
id: "e2e-stub",
displayName: "E2E Stub Agent",
path: process.execPath,
args: [stubPath],
env: {},
subagentTool: "use_subagent",
subagentInstruction: "",
restartOnNewChat: true,
subagentQueryField: "query",
},
],
}
fs.writeFileSync(path.join(tmp, "config", "acp-config.json"), JSON.stringify(config, null, 2) + "\n")
}
96 changes: 96 additions & 0 deletions web-ui/e2e/stub-agent.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT-0
/**
* E2E stub ACP agent — stands in for kiro-cli in Playwright tests.
*
* Speaks just enough of the Agent Client Protocol (JSON-RPC over stdio)
* for acp-process.ts: initialize, session/new, session/load, session/prompt.
* On prompt it writes a deck to SDPM_DECK_ROOT (deck.json + slide JSON +
* preview PNG), then emits the same session/update sequence a real agent
* produces: text chunk → tool_call → tool_call_update(completed with deckId)
* → text chunk → end_turn.
*/
import fs from "node:fs"
import path from "node:path"
import readline from "node:readline"

const DECK_ROOT = process.env.SDPM_DECK_ROOT
const DECK_ID = process.env.SDPM_STUB_DECK_ID || "e2e-test-deck"
const SESSION_ID = "stub-session-1"

// 1x1 transparent PNG
const PNG_1X1 = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
"base64",
)

function send(obj) {
process.stdout.write(JSON.stringify(obj) + "\n")
}

function update(u) {
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: SESSION_ID, update: u } })
}

function createDeckOnDisk() {
const dir = path.join(DECK_ROOT, DECK_ID)
fs.mkdirSync(path.join(dir, "slides"), { recursive: true })
fs.mkdirSync(path.join(dir, "preview"), { recursive: true })
fs.writeFileSync(path.join(dir, "deck.json"), JSON.stringify({ name: "E2E Test Deck" }))
fs.writeFileSync(
path.join(dir, "slides", "intro.json"),
JSON.stringify({ slug: "intro", elements: [] }),
)
fs.writeFileSync(path.join(dir, "preview", "intro.png"), PNG_1X1)
return dir
}

function handlePrompt(msg) {
update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Creating your deck… " } })

const outputDir = createDeckOnDisk()
update({
sessionUpdate: "tool_call",
toolCallId: "stub-tool-1",
title: "init_presentation",
rawInput: { template: "e2e" },
})
update({
sessionUpdate: "tool_call_update",
toolCallId: "stub-tool-1",
status: "completed",
rawOutput: {
items: [{ Json: { content: [{ text: JSON.stringify({ deckId: DECK_ID, output_dir: outputDir }) }] } }],
},
})

update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Done! Your deck is ready." } })
send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } })
}

const rl = readline.createInterface({ input: process.stdin })
rl.on("line", (line) => {
let msg
try {
msg = JSON.parse(line)
} catch {
return
}
switch (msg.method) {
case "initialize":
send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1, agentCapabilities: {} } })
break
case "session/new":
send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: SESSION_ID } })
break
case "session/load":
send({ jsonrpc: "2.0", id: msg.id, result: {} })
break
case "session/prompt":
handlePrompt(msg)
break
default:
// notifications (session/cancel etc.) — ignore
break
}
})
Loading
Loading