Files
document-haness/.agents/skills/publishing-tech-log-to-studio/references/playwright-recipes.md
T

9.4 KiB

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

종류를 라디오로 고르고 작업본 만들기 를 누른다. 라디오의 이름은 화면에 보이는 그대로다.

종류 라디오 이름 화면이 적어 놓은 칸
Case Case 문제 · 결론 · 환경 · 재현 · 본문
Reference Reference 목적 · 규칙 · 적용 조건 · 예외 · 예시
Concept 개념 기준 버전 · 본문
Question Question 상태 · 사실 · 가정 · 미지수 · 선택지
Decision Decision 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거

Concept 만 라디오 이름이 한글(개념)이다. 나머지 넷은 영어다.

await page.goto('https://hyeonworks.com/studio/documents/new');
await page.getByRole('radio', { name: /^Reference/ }).check();
await page.getByRole('button', { name: '작업본 만들기' }).click();
// 주소가 /studio/documents/<uuid>/edit 로 바뀐다. 그 uuid 를 기록 frontmatter 에 적는다

화면에 이렇게 적혀 있다 — 「이 화면의 작업본은 현재 Studio 세션에서만 유지됩니다.」 만들었으면 그 자리에서 칸을 채우고 저장한다. 만들어 두고 나중에 돌아오지 않는다.

화면의 기준점

const RAIL = 'aside[class*="studio-document-status"]';

aside 가 상태 레일이다. 여기에 버전(dd 첫 번째)과 저장 상태(저장됨)와 버튼 (저장·게시)이 있다.

이것이 25초 안에 안 뜨면 인증이 안 된 것이다. 로그인 화면을 자동으로 넘기려 들지 않는다.

await page.goto('https://hyeonworks.com/studio/documents/' + id + '/edit');
try { await page.waitForSelector(RAIL, { timeout: 25000 }); }
catch { return { error: 'AUTH? ' + page.url() }; }

버전 읽기

const version = await page.locator(RAIL + ' dd').first().innerText();

저장 전후로 읽는다. 안 올라갔으면 저장이 안 된 것이다.

칸 채우기

칸은 label 안의 span 이 이름을 들고 있다.

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이 아니면 채우지 않고 못 찾았다고 적는다. 비슷한 이름의 다른 칸에 넣는 것보다 안 넣는 쪽이 낫다.

이미 같은 값이면 건드리지 않는다. 안 그러면 바뀐 것이 없는데 버전만 올라간다.

되풀이되는 칸은 줄 수를 먼저 맞춘다

영향·선택지·사실처럼 여러 줄인 칸은 fieldsetlegend 가 이름이고 줄이 .studio-ordered-item 이다.

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개 생겼다. 카운트만 믿으면 못 잡는다.

// 채우기 전에 그 fieldset 안의 라벨을 전부 읽어 몇 줄인지 다시 센다
const labels = await fs.locator('label span').allInnerTexts();

칸 DOM 이 두 가지다

위 xpath 는 절반만 통한다. 실제 편집 화면에는 모양이 둘이다.

<label class="studio-field"><span>문제</span><textarea></textarea></label>       <!-- A -->
<div class="studio-field"><label for="studio-field-summary">요약</label>
     <textarea id="studio-field-summary"></textarea></div>                        <!-- B -->

요약 · 본문 Markdown · 관계 N 이유 가 B형이다. A형만 찾으면 이 칸들이 조용히 안 채워진다. 둘 다 본다.

async function field(page, name) {
  const a = page.locator(`xpath=//label[./span[normalize-space(.)="${name}"]]`)
                .locator('textarea, input').first();
  if (await a.count()) return a;
  const id = await page.locator(`xpath=//label[normalize-space(.)="${name}"]`)
                       .first().getAttribute('for');
  return id ? page.locator('#' + id) : null;
}

본문 칸의 이름은 본문 이 아니라 본문 Markdown 이다.

Asset 을 올릴 때

Asset 업로드 는 모달을 띄우고 filechooser 이벤트를 내지 않는다. 기다리면 타임아웃이다. 모달 안의 input[type=file] 에 직접 넣는다.

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 로 한다.

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;
}, '판단 기준');

넣은 값을 다시 읽어 원문과 대조한다. 넣었다는 것과 들어갔다는 것은 다르다.

저장

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 의 첫 번째로 고르면 화면이 바뀌었을 때 게시 를 누르게 된다.

저장됨 을 못 보면 실패다. 다시 누르지 말고 레일의 글자를 읽어서 보고한다.

const why = (await page.locator(RAIL).innerText()).replace(/\s+/g, ' ').slice(0, 140);

바뀐 것이 없으면 저장하지 않는다

if (filled.length === 0 && rowsChanged.length === 0) { /* CLEAN — 저장 버튼을 누르지 않는다 */ }

누를 때마다 version 이 올라가고, 버전이 올라가면 앞서 만든 검증·미리보기 산출물이 무효가 된다(validatedVersion == document.version 이 깨진다).

미리보기

즉시 미리보기 는 탭이 아니다. 편집 화면 오른쪽에 나란히 놓인 region 이라 getByRole('tab', ...) 은 0개를 찾는다. 화면에 [role="tab"] 자체가 없다.

await page.getByRole('region', { name: /즉시 미리보기/ }).scrollIntoViewIfNeeded();

저장한 값이 아니라 화면에 입력한 값을 렌더링한다. Asset 을 방금 올렸으면 편집 화면을 한 번 새로 고친다 — 안 그러면 편집 화면이 들고 있는 옛 asset 목록에서 키를 못 찾아 본문 전체가 막힌다.

초안을 미리 볼 수 없습니다
1:1 supported local evidence key not found: <asset-key>

배치로 돌릴 때 돌려받을 것

한 건마다 이만큼은 남긴다. 무엇이 채워졌고 무엇을 못 찾았는지 없이 「성공」만 돌려받으면 못 찾은 칸이 묻힌다.

{ name, version, saved: 'OK v13' | 'CLEAN' | 'FAIL <레일 글자>',
  filled: ['문제','결론'], skipped: 2, miss: ['적용 조건'], rows: ['영향 2→3'] }

절대로 하지 않는 것

  • 게시 버튼을 누르지 않는다
  • 로그인 화면에 자격증명을 입력하지 않는다
  • 저장됨 이 안 뜬다고 연타하지 않는다
  • 시험 삼아 문서를 만들지 않는다 — 한 번 게시한 문서는 취소해도 삭제가 409 로 거절된다