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
9 changes: 8 additions & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,20 @@ $EDITOR .sdlc/config.md # build, test, lint, run, e2e,
## 설정

프로젝트마다 다른 값은 `.sdlc/config.md` 한 곳에만 둡니다. `build`, `test`,
`lint`, `run`, `e2e` 명령, 화면이나 API를 다룰 `qa` 도구, `human_gates`(기본값
`lint`, `run`, `e2e` 명령, 화면이나 API를 다룰 `qa` 도구, 선택 실행기 어댑터 `qa_runner`와
그 로그인 명령 `qa_session`, `human_gates`(기본값
`intent`, 모든 plan을 직접 승인하려면 `plan` 추가, 위험 플래그가 있는 plan은
설정과 상관없이 사람이 승인), `knowledge` 폴더, `repos`(리뷰가 묶을 저장소.
프로젝트 루트 아래 저장소가 많으면 나열하거나 리뷰마다 `sdlc.sh review <slug> pass --repos "<경로> ..." "<메모>"`로 지정하고, 비우면 루트 아래 전부)입니다. `.sdlc/`는
`.git/info/exclude`로 git에서 빠집니다. 팀이 배운 것을 공유하려면 `knowledge:`를
git이 추적하는 폴더로 지정하세요.

`adapters/e2e/`가 그런 실행기 하나입니다. `qa_runner: e2e`이면 QA는 브라우저·모바일
시나리오마다 [e2e](https://www.npmjs.com/package/e2e) 테스트를 씁니다. 결정적 층(증거,
모델 없음)과, 다음 라운드부터 모델 호출 없이 재생되는 선택 agent 층으로 나뉩니다.
키트는 e2e의 에이전트 가이드를 버전 고정으로 들고 있어(Apache-2.0) 오프라인에서도
쓸 수 있고, `adapters/e2e/update.sh [버전]`으로 갱신합니다.

## 늘 지키는 규칙

- **권한은 게이트와 별개입니다.** 데이터 유실, 마이그레이션, 공개 API 변경,
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Closed features move to `.sdlc/archive/`. Verified facts move to the

`.sdlc/config.md` is the only project-specific file: the `build`, `test`,
`lint`, `run` and `e2e` commands, the `qa` tool that drives screens or APIs,
an optional `qa_runner` adapter with its `qa_session` sign-in command,
`human_gates` (default `intent`; add `plan` to approve every plan yourself — a
plan with a risk flag always asks), the `knowledge` folder, and `repos` (the
repositories a review binds — list them when the project root holds many,
Expand All @@ -101,6 +102,13 @@ empty means every one under it). `.sdlc/` stays
out of git through `.git/info/exclude`; point `knowledge:` at a tracked folder
to share what the team learns.

`adapters/e2e/` is one such runner: with `qa_runner: e2e`, QA writes each
browser or mobile scenario as an [e2e](https://www.npmjs.com/package/e2e)
test with a deterministic layer (the evidence, no model) and an optional
agent layer that replays without model calls on later rounds. The kit
vendors e2e's agent guide (Apache-2.0, pinned) so it works offline;
`adapters/e2e/update.sh [version]` refreshes it.

## Rules that never relax

- **Authority is not a gate.** Data loss, migrations, public API changes,
Expand Down
4 changes: 3 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ finds `.git`.
## Config and records

`.sdlc/config.md` holds every project value: `build`, `test`, `lint`, `run`,
`e2e`, `qa` (the tool that drives screens or APIs), `human_gates` (default
`e2e`, `qa` (the tool that drives screens or APIs), `qa_runner` (an optional
adapter in `adapters/`, such as `e2e`) and `qa_session` (its sign-in command),
`human_gates` (default
`intent`), `knowledge` (the durable knowledge folder) and `repos` (the
repositories a review binds; empty means every one under the project root;
`review --repos` names them for one review).
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.4.0
2.5.0
74 changes: 74 additions & 0 deletions adapters/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Adapter: e2e (optional QA runner)

Use when config.md says `qa_runner: e2e`. QA then writes each browser or
mobile scenario as an [e2e](https://www.npmjs.com/package/e2e) test, so a
later round re-runs it with one command instead of re-driving it by hand.
Without that key, nothing here applies.

The kit carries what an agent needs to write and run these tests offline:
`vendor/e2e-skill/` is e2e's own agent guide (Apache-2.0, `LICENSE` and
`NOTICE` inside), pinned in `VERSION`. `update.sh [version]` refreshes it from
npm. The project still installs the runner itself.

## Set up once per project

1. Node `^22.22.3 || >=24.8.0` (see `VERSION`). Install the pinned packages
as dev dependencies: `e2e`, `@e2e-dev/web` (or `@e2e-dev/mobile`).
2. Turn telemetry off for every run: `E2E_TELEMETRY_DISABLED=1`.
3. Copy `templates/e2e.config.ts` and fill the app URL. Agent steps need a
model: prefer a subscription login the human makes once
(`npx e2e login openai`, or another plan listed in
`vendor/e2e-skill/references/setup.md`). Deterministic tests need none.
4. Sign-in the runner cannot drive (a popup, SSO, a device prompt) goes in
`qa_session:` — a command that writes the signed-in storage to a file the
setup test injects (`templates/session.setup.e2e.ts`). e2e does not follow
windows the app opens.

Read `vendor/e2e-skill/SKILL.md` and the one reference it points to before
writing tests.

## Popups

e2e does not follow windows the app opens. `templates/popup-bridge.ts` is an
init script that turns `window.open` into a full-viewport iframe in the same
tab and makes `window.opener`, `close()` and `focus()` behave inside it, so a
same-origin popup that talks through opener and postMessage works unchanged;
assert inside it with `browser.frameLocator`. Its header lists what it does
not cover (cross-origin popups, `target="_blank"` links, `noopener`) and how
it differs from a real popup. Tried on two document-viewer popups: the
deterministic run passed twice, and an agent step inside the popup replayed
with no model call on its second run.

## Each scenario, two layers

`templates/scenario.e2e.ts` shows both, tagged with the scenario ID:

- **det** — exact steps and locator, URL and request assertions; no model.
This is the evidence: its result is the scenario's PASS or FAIL.
- **agent** (optional) — `agent.act` for steps whose wording or layout
churns, each followed by a locator assertion so the replay cache records
it. The first run calls the model; later runs replay with no model call
until the screen changes. `agent.assert` always calls the model.

Run det in every round; run agent with `--strict-cache` when it must not
spend model calls (a stale recording then fails instead of re-planning).
Save `--reporter json` output in `logs/` with the scenario ID.

## Limits seen in use

- Opening is not always read-only. In the trial, merely opening a one-page
assignment in its viewer marked it submitted on the server. Before driving
a flow on shared or seeded data, find out what opening, closing and
navigating write, and use fixture accounts whose state may change.
- An agent step absorbs a renamed control and still passes; only the det
locator catches that regression. Keep det as the gate.
- A wrong expectation after a correct replay evicts the correct recording;
the next run pays the model again. Fix expectations before re-running.
- A replay that cannot find its target waits for it before handing over and
is counted as missed, not handed off.
- Sessions keep cookies and localStorage, not sessionStorage; inject that in
the test when the app needs it. Signed-in tokens expire: re-run
`qa_session` when the setup assertion fails.
- The replay cache stores typed inputs in plain text. Commit it only as
reviewed test data, or keep it local.
- e2e is pre-1.0: pin versions; update with `update.sh` and re-run the suite.
5 changes: 5 additions & 0 deletions adapters/e2e/VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
e2e: 0.20.0
web: 0.14.0
node: ^22.22.3 || >=24.8.0
source: https://www.npmjs.com/package/e2e (skills/e2e, LICENSE, NOTICE)
updated: 2026-10-14
24 changes: 24 additions & 0 deletions adapters/e2e/templates/e2e.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// sdlc-kit adapter template — fill APP_URL and, for agent steps, a model.
import type { E2EConfig } from 'e2e';
import { web } from '@e2e-dev/web';
// Agent steps only. A subscription login made once by the human, e.g.:
// npx e2e login openai then import { chatgpt } from 'e2e/oauth/chatgpt';
// import { chatgpt } from 'e2e/oauth/chatgpt';

const APP_URL = process.env.APP_URL ?? 'http://localhost:3000';

export default {
targets: [
{
name: 'web',
engine: web(),
// A fixed identity keeps replay recordings valid when the host changes (dev, staging).
app: { url: APP_URL, identity: 'my-app' },
},
],
// agents: { default: { model: chatgpt('<model from `npx e2e models openai`>'), context: 'Read-only QA: never create, submit or delete data.' } },
workers: 1, // one signed-in account shared by every test
retries: 0,
cache: { mode: 'read-write', dir: '.e2e/cache' },
reporters: ['list'],
} satisfies E2EConfig;
147 changes: 147 additions & 0 deletions adapters/e2e/templates/popup-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// sdlc-kit adapter template — popup bridge.
// e2e does not follow windows the app opens. This init script turns window.open into a
// full-viewport iframe in the same tab, with no app change, so popup flows stay testable.
//
// Top window:
// - window.open(url, name, features) makes (or reuses, by name) a full-viewport iframe
// and returns its contentWindow; '_self' | '_parent' | '_top' go to the native open;
// '' | '_blank' always make a new iframe.
// Inside a bridge iframe:
// - window.opener is the window that opened it; window.close() removes the iframe
// (the opener's reference then reports closed === true); window.focus() raises it;
// window.open() opens a nested bridge popup.
// Other iframes the app uses are left alone.
//
// Works for same-origin popups that talk through opener, postMessage, window.name and
// .closed. Not covered: cross-origin popups (frameElement is unreadable, and the server
// may forbid framing), <a target="_blank"> and form targets (no window.open), noopener.
// Differences from a real popup: sessionStorage is shared, not copied; size features are
// ignored; window.top is the app window; the app window gets no blur/visibilitychange;
// the popup covers the app until it closes.
//
// Use:
// await installPopupBridge(browser); // before app.open
// browser.frameLocator(popupFrameSelector('name')) // assert inside the popup
// await browser.evaluate(listPopups) // open popups
// toBeVisible passes on an element with opacity 0: assert something that proves the
// popup painted (an image loaded, computed opacity) before trusting a screenshot.

/** Self-contained: browser.addInitScript serializes it into every document, frames included. No outside references. */
export function popupBridgeInit(): void {
const W = window as unknown as Record<string, any>;
if (W.__popupBridgeInstalled) return;
W.__popupBridgeInstalled = true;
const ATTR = 'data-popup-bridge';

if (window === window.top) {
const nativeOpen = window.open.bind(window);
const frames = new Map<string, HTMLIFrameElement>();
const openers = new Map<HTMLIFrameElement, Window>();
let seq = 0;
let z = 2147480000;
const frameOf = (win: unknown) => {
for (const f of frames.values()) if (f.contentWindow === win) return f;
return null;
};
const raise = (f: HTMLIFrameElement) => {
f.style.zIndex = String(++z);
};
const api = {
open(url: string, name: string | undefined, features: string | undefined, opener: Window): Window | null {
const target = name ?? '';
if (target === '_self' || target === '_parent' || target === '_top') return nativeOpen(url, target, features);
const anonymous = target === '' || target === '_blank';
const key = anonymous ? `__anonymous_${++seq}` : target;
const href = url ? new URL(url, location.href).href : '';
let f = frames.get(key);
if (f && f.isConnected) {
if (href) f.src = href;
} else {
f = document.createElement('iframe');
if (!anonymous) f.name = target;
f.setAttribute(ATTR, key);
f.setAttribute('title', `popup ${key}`);
if (features) f.setAttribute('data-popup-features', features);
f.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;border:0;margin:0;padding:0;background:#fff;';
f.src = href || 'about:blank';
(document.body ?? document.documentElement).appendChild(f);
frames.set(key, f);
}
openers.set(f, opener);
raise(f);
return f.contentWindow;
},
close(win: Window): void {
for (const [k, f] of frames) {
if (f.contentWindow === win) {
frames.delete(k);
openers.delete(f);
f.remove();
}
}
},
focus(win: Window): void {
const f = frameOf(win);
if (f) raise(f);
},
openerOf(win: Window): Window | null {
const f = frameOf(win);
return f ? (openers.get(f) ?? window) : null;
},
list(): { key: string; name: string; src: string; url: string; z: string }[] {
return [...frames].map(([key, f]) => {
let url = '';
try {
url = f.contentWindow?.location.href ?? '';
} catch {
url = '(cross-origin)';
}
return { key, name: f.name, src: f.src, url, z: f.style.zIndex };
});
},
};
W.__popupBridge = api;
window.open = function (url?: string | URL, name?: string, features?: string) {
return api.open(url == null ? '' : String(url), name, features, window);
} as typeof window.open;
return;
}

// Child document: act only inside an iframe the bridge made (frameElement is readable only same-origin).
let host: Element | null = null;
try {
host = window.frameElement;
} catch {
host = null;
}
if (!host || !host.hasAttribute(ATTR)) return;
const api = (window.top as unknown as Record<string, any>).__popupBridge;
if (!api) return;
Object.defineProperty(window, 'opener', { configurable: true, get: () => api.openerOf(window), set: () => undefined });
window.close = () => api.close(window);
window.focus = () => api.focus(window);
window.open = function (url?: string | URL, name?: string, features?: string) {
return api.open(url == null ? '' : String(url), name, features, window);
} as typeof window.open;
}

type InitScriptHost = { addInitScript(script: () => unknown): Promise<void> };

/** Call before app.open; every later document gets the bridge. */
export async function installPopupBridge(browser: InitScriptHost): Promise<void> {
await browser.addInitScript(popupBridgeInit);
}

/** Selector for a bridge iframe by window name; without a name, the newest one. */
export const popupFrameSelector = (name?: string): string =>
name ? `iframe[data-popup-bridge="${name}"]` : 'iframe[data-popup-bridge]:last-of-type';

/** browser.evaluate(listPopups) lists the open bridge popups. */
export const listPopups = (): { key: string; name: string; src: string; url: string; z: string }[] =>
((window as unknown as Record<string, any>).__popupBridge?.list() ?? []) as {
key: string;
name: string;
src: string;
url: string;
z: string;
}[];
29 changes: 29 additions & 0 deletions adapters/e2e/templates/scenario.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// sdlc-kit adapter template — one PLAN.md scenario, two layers, tagged with its ID.
// det: npx e2e run --tag det --reporter json (evidence; no model)
// agent: npx e2e run --tag agent --strict-cache (optional; replays without model calls)
import { describe, test } from '@e2e-dev/web';
import { expect } from 'e2e';

describe('S1 <persona> <behavior>', { session: 'user', tags: ['S1'] }, () => {
test('det', { tags: ['det'] }, async ({ app, browser }) => {
await app.open('/start');
await browser.locator('nav >> text=Reports').tap();
await expect(browser).toHaveURL('/reports');
// A request the change must (or must not) send: record it, then assert.
const sent: string[] = [];
await browser.route('**/api/audit-log', async (route) => {
if (route.request.method === 'POST') sent.push(route.request.postData ?? '');
await route.continue();
});
await browser.locator('text=Monthly').tap();
await expect.poll(() => sent.length).toBeGreaterThan(0);
});

test('agent', { tags: ['agent'] }, async ({ app, agent, browser }) => {
await app.open('/start');
// Keep the act wording stable: it keys the replay recording.
await agent.act('open the Reports page from the main menu');
// A locator or URL check after the act is what records it for replay.
await expect(browser).toHaveURL('/reports');
});
});
25 changes: 25 additions & 0 deletions adapters/e2e/templates/session.setup.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// sdlc-kit adapter template — restore a sign-in that config `qa_session:` captured
// (popup, SSO or device flows the runner cannot drive), then save it as a named session.
import { readFileSync } from 'node:fs';
import { test } from '@e2e-dev/web';
import { expect } from 'e2e';

// Written by the qa_session command: { "localStorage": {...}, "sessionStorage": {...} }
const STATE_FILE = process.env.QA_SESSION_FILE ?? '.auth/session.json';

test.setup('signed-in session', { sessions: ['user'] }, async ({ app, browser, session }) => {
const state = JSON.parse(readFileSync(STATE_FILE, 'utf8')) as {
localStorage?: Record<string, string>;
sessionStorage?: Record<string, string>;
};
await app.open('/');
await browser.evaluate((s: typeof state) => {
for (const [k, v] of Object.entries(s.localStorage ?? {})) localStorage.setItem(k, v);
for (const [k, v] of Object.entries(s.sessionStorage ?? {})) sessionStorage.setItem(k, v);
return true;
}, state);
await app.open('/'); // reload signed in
// Assert a signed-in element before saving; session.save does not check it.
await expect(browser.locator('[data-signed-in], nav')).toBeVisible();
await session.save('user');
});
31 changes: 31 additions & 0 deletions adapters/e2e/update.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Refresh the vendored e2e agent guide from the npm package, pinned by version.
# Usage: adapters/e2e/update.sh [e2e-version] (default: the version in adapters/e2e/VERSION)
# Needs npm and tar only while updating; using the vendored guide needs neither.
set -eu
DIR=$(cd "$(dirname "$0")" && pwd)
case "${1:-}" in -h|--help) sed -n '2,4p' "$0" | sed 's/^# //'; exit 0;; esac
current=$(awk -F': *' '$1=="e2e"{print $2}' "$DIR/VERSION")
version=${1:-$current}
[ -n "$version" ] || { echo 'update.sh: no e2e version given or pinned' >&2; exit 2; }
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
( cd "$tmp" && npm pack --silent "e2e@$version" >/dev/null )
tar -xzf "$tmp"/e2e-*.tgz -C "$tmp"
src=$tmp/package
[ -f "$src/skills/e2e/SKILL.md" ] || { echo "update.sh: e2e@$version has no skills/e2e/SKILL.md" >&2; exit 1; }
dest=$DIR/vendor/e2e-skill
rm -rf "$dest.new"; mkdir -p "$dest.new"
cp -R "$src/skills/e2e/." "$dest.new/"
cp "$src/LICENSE" "$src/NOTICE" "$dest.new/"
rm -rf "$dest"; mv "$dest.new" "$dest"
# @e2e-dev/web declares a peer range on e2e; record the latest web release to install beside it.
web=$(npm view @e2e-dev/web version 2>/dev/null || true)
node=$(awk -F'"' '/"node"/{print $4; exit}' "$src/package.json")
cat > "$DIR/VERSION" <<V
e2e: $version
web: ${web:-unknown}
node: ${node:-unknown}
source: https://www.npmjs.com/package/e2e (skills/e2e, LICENSE, NOTICE)
updated: $(date +%Y-%m-%d)
V
echo "vendored e2e@$version guide into $dest"
Loading
Loading