기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과 keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지 밟았고 밟은 범위를 편마다 적었다. 명령이 못 도는 것을 고쳤다. - kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다. 라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다 - `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표 - busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다 - `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을 못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다 - 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v` - `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어 그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다 - B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다 - D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는 `kc-lab-edge` 의 `certbot.service` 다 - `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로 - `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다 결과가 조건부인 것을 갈랐다. - readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다 - 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다 - A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다 - A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다 증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은 길이만 적었고 화면에 찍힌 토큰은 가렸다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
253 lines
11 KiB
Markdown
253 lines
11 KiB
Markdown
# Playwright MCP 로 편집 화면을 다루는 법
|
|
|
|
여기 적은 셀렉터는 지어낸 것이 아니라 이 저장소가 실제로 돌린 것이다. 원문은
|
|
`.playwright-mcp/_p1.mjs`·`_p2.mjs`·`_p3.mjs` 에 남아 있다.
|
|
|
|
## 두 가지 방식
|
|
|
|
| 방식 | 언제 |
|
|
|---|---|
|
|
| MCP 도구를 하나씩 (`browser_navigate` · `browser_snapshot` · `browser_fill_form` · `browser_click`) | 기록 한 건. 화면을 보면서 한다 |
|
|
| `browser_run_code_unsafe` 로 한 번에 | 여러 건을 같은 모양으로 채운다 |
|
|
|
|
**처음 넣는 기록은 하나씩 한다.** 배치는 이미 한 번 성공한 모양을 반복할 때만 쓴다. 화면을
|
|
안 보고 배치를 돌리면 못 찾은 칸이 조용히 버려진다.
|
|
|
|
## 새로 만들 때 — `/studio/documents/new`
|
|
|
|
종류를 라디오로 고르고 `작업본 만들기` 를 누른다. 라디오의 이름은 화면에 보이는 그대로다.
|
|
|
|
| 계약 `kind` | 라디오 이름 | 화면이 적어 놓은 칸 |
|
|
|---|---|---|
|
|
| `CASE` | `검증 기록` | 문제 · 결론 · 환경 · 재현 · 본문 |
|
|
| `REFERENCE` | `적용 기준` | 목적 · 규칙 · 적용 조건 · 예외 · 예시 |
|
|
| `CONCEPT` | `동작 원리` | 기준 버전 · 본문 |
|
|
| `SETUP` | `환경 구성` | 버전 · 본문 |
|
|
| `QUESTION` | `열린 질문` | 상태 · 사실 · 가정 · 미지수 · 선택지 |
|
|
| `PROJECT_DECISION` | `설계 결정` | 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거 |
|
|
|
|
**여섯 다 한글이다.** 이 표는 전에 「Concept 만 라디오 이름이 한글(`개념`)이다. 나머지 넷은
|
|
영어다」라고 적고 있었고 그것은 낡았다 — 2026-09-12 에 `/studio/documents/new` 를 열어 여섯
|
|
라디오의 이름을 그대로 읽었다. **화면 문구는 이렇게 조용히 바뀐다.** 표를 외워서 넣지 말고
|
|
스냅샷으로 읽은 이름을 쓴다.
|
|
|
|
```js
|
|
await page.goto('https://hyeonworks.com/studio/documents/new');
|
|
await page.getByRole('radio', { name: /^환경 구성/ }).check();
|
|
await page.getByRole('button', { name: '작업본 만들기' }).click();
|
|
// 주소가 /studio/documents/<uuid>/edit 로 바뀐다. 그 uuid 를 기록 frontmatter 에 적는다
|
|
```
|
|
|
|
화면에 이렇게 적혀 있다 — 「이 화면의 작업본은 현재 Studio 세션에서만 유지됩니다.」
|
|
**만들었으면 그 자리에서 칸을 채우고 저장한다.** 만들어 두고 나중에 돌아오지 않는다.
|
|
|
|
## 화면의 기준점
|
|
|
|
```js
|
|
const RAIL = 'aside[class*="studio-document-status"]';
|
|
```
|
|
|
|
이 `aside` 가 상태 레일이다. 여기에 버전(`dd` 첫 번째)과 저장 상태(`저장됨`)와 버튼
|
|
(`저장`·`게시`)이 있다.
|
|
|
|
**이것이 25초 안에 안 뜨면 인증이 안 된 것이다.** 로그인 화면을 자동으로 넘기려 들지 않는다.
|
|
|
|
```js
|
|
await page.goto('https://hyeonworks.com/studio/documents/' + id + '/edit');
|
|
try { await page.waitForSelector(RAIL, { timeout: 25000 }); }
|
|
catch { return { error: 'AUTH? ' + page.url() }; }
|
|
```
|
|
|
|
## 버전 읽기
|
|
|
|
```js
|
|
const version = await page.locator(RAIL + ' dd').first().innerText();
|
|
```
|
|
|
|
저장 전후로 읽는다. 안 올라갔으면 저장이 안 된 것이다.
|
|
|
|
## 칸 채우기
|
|
|
|
칸은 `label` 안의 `span` 이 이름을 들고 있다.
|
|
|
|
```js
|
|
const loc = page.locator('xpath=//label[./span[normalize-space(.)="' + label + '"]]')
|
|
.locator('textarea, input').first();
|
|
if (await loc.count() !== 1) { /* 그 종류에 없는 칸이다 — 채우지 말고 기록한다 */ }
|
|
if (await loc.inputValue() === value) { /* 이미 같다 — 건드리지 않는다 */ }
|
|
await loc.fill(value);
|
|
```
|
|
|
|
**매치가 1이 아니면 채우지 않고 못 찾았다고 적는다.** 비슷한 이름의 다른 칸에 넣는 것보다
|
|
안 넣는 쪽이 낫다.
|
|
|
|
**이미 같은 값이면 건드리지 않는다.** 안 그러면 바뀐 것이 없는데 버전만 올라간다.
|
|
|
|
## 되풀이되는 칸은 줄 수를 먼저 맞춘다
|
|
|
|
`영향`·`선택지`·`사실`처럼 여러 줄인 칸은 `fieldset` 의 `legend` 가 이름이고 줄이
|
|
`.studio-ordered-item` 이다.
|
|
|
|
```js
|
|
const fs = page.locator('xpath=//fieldset[./legend[normalize-space(.)="' + legend + '"]]').first();
|
|
let cur = await fs.locator('.studio-ordered-item').count();
|
|
while (cur > want) { // 줄이 남으면 뒤에서 지운다
|
|
await fs.locator('.studio-ordered-item').last().getByRole('button', { name: '삭제' }).click();
|
|
await page.waitForTimeout(60); cur--;
|
|
}
|
|
while (cur < want) { // 모자라면 더한다
|
|
await fs.getByRole('button', { name: legend + ' 추가' }).click();
|
|
await page.waitForTimeout(60); cur++;
|
|
}
|
|
```
|
|
|
|
**값을 넣기 전에 한다.** 줄이 모자란 채로 채우면 뒤엣것이 조용히 버려진다.
|
|
|
|
**행 클래스가 칸마다 다르다.** `관계` 는 `.studio-relation-item` 이고 나머지가
|
|
`.studio-ordered-item` 이다. 하나로 세면 관계는 늘 0으로 읽힌다.
|
|
|
|
**더한 뒤에는 카운트가 아니라 라벨 목록으로 검산한다.** 실제로 「판단 기준 추가」를 7번
|
|
눌렀는데 행이 9개 생겼다. 카운트만 믿으면 못 잡는다.
|
|
|
|
```js
|
|
// 채우기 전에 그 fieldset 안의 라벨을 전부 읽어 몇 줄인지 다시 센다
|
|
const labels = await fs.locator('label span').allInnerTexts();
|
|
```
|
|
|
|
## 칸은 `aria-label` 로 찾는다 (2026-09-17 에 다시 잼)
|
|
|
|
**위의 `label` 두 모양 이야기는 낡았다.** 지금 `label.studio-field` 가 감싸는 것은 제목과 요약
|
|
둘뿐이고, 나머지 칸은 전부 `aria-label` 을 갖는다. xpath 로 `span` 을 뒤질 일이 없다.
|
|
|
|
```js
|
|
document.querySelector('input[aria-label="제목"]')
|
|
document.querySelector('textarea[aria-label="요약"]')
|
|
document.querySelector('input[aria-label="slug"]')
|
|
document.querySelector('input[aria-label="판단 기준 1 제목"]')
|
|
document.querySelector('textarea[aria-label="적용할 때 3"]')
|
|
```
|
|
|
|
되풀이 칸을 늘리는 버튼은 `+ 판단 기준` · `+ 적용할 때` · `+ 예외와 주의` · `+ 예시` ·
|
|
`+ 버전 추가` 다. 줄 수를 먼저 맞추고 값을 넣는 규칙은 그대로다.
|
|
|
|
## 본문 칸이 사라졌다 — 블록 편집기다
|
|
|
|
**`본문 Markdown` 이라는 textarea 는 더 없다.** 본문은 `.studio-block-editor` 이고 블록 하나가
|
|
`<textarea>` 하나다. 기록 한 편이 블록 171개인 것을 실제로 봤다.
|
|
|
|
```
|
|
.studio-block-editor
|
|
├ textarea.studio-markdown-source 본문 전체의 거울. display:none · aria-hidden · readonly
|
|
├ .studio-block-editor__blocks
|
|
│ └ .studio-authoring-block[data-kind] heading · paragraph · code · bullet · raw
|
|
└ button + 블록 추가
|
|
```
|
|
|
|
**코드 블록의 `input.studio-code-language` 에 울타리 뒤 정보 문자열 전체가 들어간다** —
|
|
`bash label="[kc-lab-1] ① 기동 로그에서 쿠키 설정을 찾는다"` 가 그 입력의 값이었다. 라벨을
|
|
넣을 별도 칸은 없다.
|
|
|
|
합성한 `ClipboardEvent` 로 붙여넣는 것은 **안 먹는다.** 그래서 쓰인 기록을 화면으로 옮기지
|
|
않는다 — [studio-api.md](studio-api.md) 의 `PUT` 으로 `bodyMarkdown` 을 통째로 보낸다.
|
|
`.studio-markdown-source` 는 `readonly` 라 입력 자리가 아니지만, 화면이 지금 무엇을 직렬화할지를
|
|
그대로 보여 주므로 **저장 전에 눈으로 대조하기에 좋다.**
|
|
|
|
## 저장 다음은 게시가 아니라 검증이다
|
|
|
|
편집 화면의 `aside` 에는 `저장` 과 `게시` 둘뿐인데, 저장만 된 기록은 서버가 `nextAction` 을
|
|
**`VALIDATE`** 로 준다. 검증 화면은 `/studio/documents/{id}/validation` 이다. 이 스킬은 저장까지라
|
|
거기까지 가지 않는다 — 다만 「저장했는데 왜 게시가 안 되나」의 답이 이것이다.
|
|
|
|
## Asset 을 올릴 때
|
|
|
|
`Asset 업로드` 는 모달을 띄우고 **`filechooser` 이벤트를 내지 않는다.** 기다리면 타임아웃이다.
|
|
모달 안의 `input[type=file]` 에 직접 넣는다.
|
|
|
|
```js
|
|
await page.getByRole('button', { name: 'Asset 업로드' }).click();
|
|
await page.locator('input[type=file]').setInputFiles(svgPath);
|
|
// 대체 텍스트 칸에 기록 본문의 alt 를 그대로 넣는다
|
|
```
|
|
|
|
**올린 직후 목록이 갱신되지 않는다.** 화면의 `Asset 검색` 을 다시 눌러야 새 키가 나온다.
|
|
새로 올리면 서버가 새 키를 준다(`<이름>-<해시8>`) — 옛 키는 목록에 남는다. 다른 문서가
|
|
참조할 수 있으니 지우지 않는다.
|
|
|
|
## 값을 읽을 때는 `getByLabel` 을 쓰지 않는다
|
|
|
|
칸이 `<label><span>이름</span><textarea></label>` 모양이라 `getByLabel(...).inputValue()` 가
|
|
30초 타임아웃으로 실패한다. `fill` 은 되는데 읽기만 안 된다. 읽기는 `evaluate` 로 한다.
|
|
|
|
```js
|
|
const got = await page.evaluate((name) => {
|
|
const el = [...document.querySelectorAll('label')]
|
|
.find(l => l.querySelector('span')?.textContent.trim() === name);
|
|
return el?.querySelector('textarea, input')?.value ?? null;
|
|
}, '판단 기준');
|
|
```
|
|
|
|
**넣은 값을 다시 읽어 원문과 대조한다.** 넣었다는 것과 들어갔다는 것은 다르다.
|
|
|
|
## 저장
|
|
|
|
```js
|
|
await page.locator(RAIL).getByRole('button', { name: '저장' }).first().click();
|
|
await page.waitForFunction(() => {
|
|
const el = document.querySelector('aside[class*="studio-document-status"]');
|
|
return el && /저장됨/.test(el.innerText);
|
|
}, null, { timeout: 30000 });
|
|
```
|
|
|
|
**버튼을 이름으로 고른다.** `aside button` 의 첫 번째로 고르면 화면이 바뀌었을 때 `게시` 를
|
|
누르게 된다.
|
|
|
|
`저장됨` 을 못 보면 실패다. 다시 누르지 말고 레일의 글자를 읽어서 보고한다.
|
|
|
|
```js
|
|
const why = (await page.locator(RAIL).innerText()).replace(/\s+/g, ' ').slice(0, 140);
|
|
```
|
|
|
|
## 바뀐 것이 없으면 저장하지 않는다
|
|
|
|
```js
|
|
if (filled.length === 0 && rowsChanged.length === 0) { /* CLEAN — 저장 버튼을 누르지 않는다 */ }
|
|
```
|
|
|
|
누를 때마다 `version` 이 올라가고, 버전이 올라가면 앞서 만든 검증·미리보기 산출물이 무효가
|
|
된다(`validatedVersion == document.version` 이 깨진다).
|
|
|
|
## 미리보기
|
|
|
|
**`즉시 미리보기` 는 탭이 아니다.** 편집 화면 오른쪽에 나란히 놓인 `region` 이라
|
|
`getByRole('tab', ...)` 은 0개를 찾는다. 화면에 `[role="tab"]` 자체가 없다.
|
|
|
|
```js
|
|
await page.getByRole('region', { name: /즉시 미리보기/ }).scrollIntoViewIfNeeded();
|
|
```
|
|
|
|
저장한 값이 아니라 화면에 입력한 값을 렌더링한다. **Asset 을 방금 올렸으면 편집 화면을 한 번
|
|
새로 고친다** — 안 그러면 편집 화면이 들고 있는 옛 asset 목록에서 키를 못 찾아 본문 전체가
|
|
막힌다.
|
|
|
|
```text
|
|
초안을 미리 볼 수 없습니다
|
|
1:1 supported local evidence key not found: <asset-key>
|
|
```
|
|
|
|
## 배치로 돌릴 때 돌려받을 것
|
|
|
|
한 건마다 이만큼은 남긴다. 무엇이 채워졌고 무엇을 못 찾았는지 없이 「성공」만 돌려받으면
|
|
못 찾은 칸이 묻힌다.
|
|
|
|
```js
|
|
{ name, version, saved: 'OK v13' | 'CLEAN' | 'FAIL <레일 글자>',
|
|
filled: ['문제','결론'], skipped: 2, miss: ['적용 조건'], rows: ['영향 2→3'] }
|
|
```
|
|
|
|
## 절대로 하지 않는 것
|
|
|
|
- `게시` 버튼을 누르지 않는다
|
|
- 로그인 화면에 자격증명을 입력하지 않는다
|
|
- `저장됨` 이 안 뜬다고 연타하지 않는다
|
|
- 시험 삼아 문서를 만들지 않는다 — 한 번 게시한 문서는 취소해도 삭제가 409 로 거절된다
|