# 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//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` 이고 블록 하나가 `