Compare commits

...

20 Commits

Author SHA1 Message Date
jaybe a476756a6c feat(exporter): 다크모드 대비 보강(a 링크/호버, 버튼/CTA, FAQ 텍스트) + 테스트
Auto PR / open-pr (push) Successful in 23s
CI / test (pull_request) Failing after 11m12s
Auto Label PR / add-automerge-label (pull_request) Failing after 11m17s
2025-11-14 22:54:34 +09:00
jaybe 8d5501d927 Merge pull request 'auto: PR for feat/auto-label-payload-fix' (#15) from feat/auto-label-payload-fix into main 2025-11-14 13:24:01 +00:00
jaybe cd8ae32203 chore(ci): auto-label robust ID resolution (pagination)
Auto PR / open-pr (push) Successful in 32s
Auto Label PR / add-automerge-label (pull_request) Successful in 14s
CI / test (pull_request) Successful in 57s
2025-11-14 22:22:27 +09:00
jaybe 7110db8a4a Merge pull request 'auto: PR for feat/auto-label-payload-fix' (#14) from feat/auto-label-payload-fix into main 2025-11-14 13:17:16 +00:00
jaybe 7b36e14b65 chore(ci): auto-label payload fix 재검증 ping 2025-11-14 22:15:14 +09:00
jaybe 32a760dd9a Merge pull request 'auto: PR for feat/auto-label-payload-fix' (#13) from feat/auto-label-payload-fix into main 2025-11-14 13:14:31 +00:00
jaybe 3d2f1dc8d4 chore(ci): fix auto-label payload for Gitea API (use label id object) 2025-11-14 22:12:55 +09:00
jaybe a957401a59 test
Auto PR / open-pr (push) Successful in 16s
Auto Label PR / add-automerge-label (pull_request) Successful in 5s
CI / test (pull_request) Successful in 1m0s
2025-11-14 22:05:38 +09:00
jaybe 9cc5db5d28 chore(ci): PR 오픈/동기화 시 'automerge' 라벨 자동 부여 워크플로 추가 2025-11-14 21:55:39 +09:00
jaybe 16da17e533 chore(ci): auto-merge 단계에 'automerge' 라벨 가드 확정 반영 2025-11-14 21:52:06 +09:00
jaybe 8205215c17 chore(ci): auto-pr 라벨 자동부여 재검증 ping
Auto PR / open-pr (push) Successful in 18s
CI / test (pull_request) Successful in 52s
2025-11-14 21:45:15 +09:00
jaybe 36514286aa chore(ci): auto-pr 생성 시 'automerge' 라벨 자동 부여 2025-11-14 21:39:31 +09:00
jaybe cc271abedb Merge pull request 'auto: PR for feat/exporter-seo-perf' (#6) from feat/exporter-seo-perf into main 2025-11-14 12:23:09 +00:00
jaybe 8297002dc5 chore(ci): 자동 PR/그린시 자동 머지 구성(CI_TOKEN 사용)
Auto PR / open-pr (push) Successful in 11s
CI / test (pull_request) Successful in 56s
2025-11-14 21:22:00 +09:00
jaybe 25ed57da53 Merge pull request 'feat(exporter): SEO/Perf small improvements (canonical, lazy/async hero, dark token) + tests' (#5) from feat/exporter-seo-perf into main
Reviewed-on: #5
2025-11-14 11:53:17 +00:00
jaybe 90e410f49e feat(exporter): SEO/Perf small improvements (canonical, lazy/async hero, dark token) + tests 2025-11-14 20:50:13 +09:00
jaybe 14a3d32480 Merge pull request 'test(e2e): add exported-page a11y smoke (focus outline, skip-link tab order)' (#4) from feat/ci-verify-run into main
Reviewed-on: #4
2025-11-14 11:49:29 +00:00
jaybe f6c54d1074 fix(exporter): 스킵링크 활성화 시 main에 포커스 이동 처리 추가 2025-11-14 20:36:23 +09:00
jaybe 1ffebc774f test(e2e): add exported-page a11y smoke (focus outline, skip-link tab order) 2025-11-14 20:29:54 +09:00
jaybe 19b8becc30 Merge pull request 'feat/ci-verify-run' (#3) from feat/ci-verify-run into main
Reviewed-on: #3
2025-11-14 11:24:07 +00:00
10 changed files with 372 additions and 2 deletions
+44
View File
@@ -0,0 +1,44 @@
name: Auto Label PR
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
add-automerge-label:
runs-on: [macos, host]
steps:
- name: Add 'automerge' label to PR
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
GITEA_BASE_URL: https://gitea.jaybe.dev
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [ -z "${CI_TOKEN:-}" ]; then
echo "CI_TOKEN not set; skipping labeling"; exit 0; fi
OWNER=$(echo "$REPO" | cut -d'/' -f1)
NAME=$(echo "$REPO" | cut -d'/' -f2)
echo "Resolving label id for 'automerge' in $OWNER/$NAME"
curl -sS -o /tmp/labels.json -H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/labels?limit=1000" || true
# Robust parse without jq: find the id preceding name "automerge"
LID=$(tr '\n' ' ' < /tmp/labels.json | grep -o '"id":[0-9][0-9]*[^\}]*"name":"automerge"' | head -n1 | sed -E 's/.*"id":([0-9]+).*/\1/' || true)
if [ -z "${LID:-}" ]; then
echo "Label 'automerge' not found. Creating..."
curl -sS -o /tmp/create_label.json -X POST \
-H "Content-Type: application/json" \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/labels" \
-d '{"name":"automerge","color":"#0ea5e9"}'
LID=$(tr '\n' ' ' < /tmp/create_label.json | sed -E 's/.*"id":([0-9]+).*/\1/' | head -n1 || true)
fi
if [ -z "${LID:-}" ]; then
echo "Failed to resolve or create label id; skipping"; exit 0; fi
echo "Labeling PR #$PR_NUMBER with id $LID"
curl -sS -X POST \
-H "Content-Type: application/json" \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" \
-d '{"labels":['"${LID}"']}' || true
+83
View File
@@ -0,0 +1,83 @@
name: Auto PR
on:
push:
branches:
- 'feat/**'
jobs:
open-pr:
runs-on: [macos, host]
steps:
- name: Create Pull Request via Gitea API
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
GITEA_BASE_URL: https://gitea.jaybe.dev
REPO: ${{ github.repository }}
HEAD_BRANCH: ${{ github.ref_name }}
BASE_BRANCH: main
run: |
set -euo pipefail
if [ -z "${CI_TOKEN:-}" ]; then
echo "GITEA_TOKEN not set; skipping auto-pr"; exit 0; fi
OWNER=$(echo "$REPO" | cut -d'/' -f1)
NAME=$(echo "$REPO" | cut -d'/' -f2)
TITLE="auto: PR for $HEAD_BRANCH"
BODY="Automated PR created on push to $HEAD_BRANCH"
echo "Creating PR $HEAD_BRANCH -> $BASE_BRANCH for $OWNER/$NAME"
HTTP_CODE=$(curl -sS -o /tmp/pr_resp.json -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls" \
-d "{\"head\":\"${HEAD_BRANCH}\",\"base\":\"${BASE_BRANCH}\",\"title\":\"${TITLE}\",\"body\":\"${BODY}\"}") || true
echo "API status: $HTTP_CODE"
if [ "$HTTP_CODE" = "201" ]; then
echo "PR created"; cat /tmp/pr_resp.json
PR_NUMBER=$(cat /tmp/pr_resp.json | sed -n 's/.*"number":\([0-9]*\).*/\1/p' | head -n1)
else
echo "PR not created (possibly exists). Response:"; cat /tmp/pr_resp.json || true
fi
# Retry to resolve PR number by listing open PRs and matching head/base
i=0
while [ -z "${PR_NUMBER:-}" ] && [ $i -lt 10 ]; do
i=$((i+1))
echo "[retry $i] Resolving PR number for $HEAD_BRANCH -> $BASE_BRANCH"
curl -sS -o /tmp/pr_list.json -H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls?state=open" || true
# Extract PR number matching head.ref==HEAD_BRANCH and base.ref==BASE_BRANCH
PR_NUMBER=$(awk -v hb="$HEAD_BRANCH" -v bb="$BASE_BRANCH" 'BEGIN{RS="},"; pr=""} {
if($0 ~ /\"head\":\{[^}]*\"ref\":\"" hb "\"/ && $0 ~ /\"base\":\{[^}]*\"ref\":\"" bb "\"/){
if (match($0, /\"number\":([0-9]+)/, m)) { print m[1]; exit }
}
}' /tmp/pr_list.json)
if [ -z "${PR_NUMBER:-}" ]; then sleep 2; fi
done
if [ -n "${PR_NUMBER:-}" ]; then
echo "Resolving label id for 'automerge' in $OWNER/$NAME"
curl -sS -o /tmp/labels.json -H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/labels"
LID=$(awk 'BEGIN{RS=","} /"name":"automerge"/ {if (match($0,/"id":([0-9]+)/,m)) {print m[1]; exit}}' /tmp/labels.json || true)
if [ -z "${LID:-}" ]; then
echo "Label 'automerge' not found. Creating..."
curl -sS -o /tmp/create_label.json -X POST \
-H "Content-Type: application/json" \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/labels" \
-d '{"name":"automerge","color":"#0ea5e9"}'
LID=$(sed -n 's/.*"id":\([0-9]*\).*/\1/p' /tmp/create_label.json | head -n1 || true)
fi
if [ -n "${LID:-}" ]; then
echo "Adding 'automerge' (id=$LID) label to PR #$PR_NUMBER"
curl -sS -X POST \
-H "Content-Type: application/json" \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" \
-d '{"labels":['"${LID}"']}' || true
else
echo "Failed to resolve label id; skipping label add"
fi
else
echo "PR number could not be determined; skipping label add"
fi
+38
View File
@@ -42,3 +42,41 @@ jobs:
path: playwright-report path: playwright-report
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 3 retention-days: 3
- name: Auto-merge PR on green
if: ${{ github.event_name == 'pull_request' }}
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
GITEA_BASE_URL: https://gitea.jaybe.dev
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [ -z "${CI_TOKEN:-}" ]; then
echo "CI_TOKEN not set; skipping auto-merge"; exit 0; fi
OWNER=$(echo "$REPO" | cut -d'/' -f1)
NAME=$(echo "$REPO" | cut -d'/' -f2)
echo "Checking labels for PR #$PR_NUMBER on $OWNER/$NAME"
LABELS_JSON=$(curl -sS -X GET \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}")
echo "labels payload (first 200 chars):"; echo "$LABELS_JSON" | cut -c1-200 || true
if ! echo "$LABELS_JSON" | grep -qi '"name":"automerge"'; then
echo "Label 'automerge' not present; skipping merge"; exit 0; fi
echo "Label 'automerge' present; proceeding to merge"
# ensure PR is open
PR_JSON=$(curl -sS -X GET \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
STATE=$(echo "$PR_JSON" | sed -n 's/.*"state":"\([^"]*\)".*/\1/p')
echo "PR state: ${STATE:-unknown}"
if [ "${STATE:-}" != "open" ]; then
echo "PR state is not open; skipping merge"; exit 0; fi
echo "Merging PR #$PR_NUMBER for $OWNER/$NAME"
HTTP_CODE=$(curl -sS -o /tmp/merge_resp.json -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-H "Authorization: token ${CI_TOKEN}" \
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/merge" \
-d '{"Do":"merge","MergeMessage":"auto-merge: CI green"}' || true)
echo "merge http: $HTTP_CODE"
echo "merge resp:"; cat /tmp/merge_resp.json || true
+1 -1
View File
@@ -41,7 +41,7 @@ describe('Exporter a11y/responsive enhancements', () => {
test('hero image alt derives from heading', () => { test('hero image alt derives from heading', () => {
const out = exportPage(makePage()) const out = exportPage(makePage())
expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>') expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>')
expect(out.html).toContain('<img src="https://example.com/img.png" alt="Welcome" />') expect(out.html).toMatch(/<img[^>]*src="https:\/\/example\.com\/img\.png"[^>]*alt="Welcome"[^>]*\/>/)
}) })
test('responsive and a11y CSS tokens exist', () => { test('responsive and a11y CSS tokens exist', () => {
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { exportPage } from '@/lib/exporter/html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'Dark Contrast Hover',
locale: 'en',
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://img.example/h.png' } },
{ type: 'cta', props: { text: 'Call', href: '#' } },
],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 't', description: 'd' },
})
}
describe('Exporter - dark mode hover/cta tokens', () => {
it('adds link hover underline and ensures btn-cta readable in dark', () => {
const out = exportPage(makePage())
const css = out.css
// underline on hover for links in dark for clearer affordance
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*a:hover\{[^}]*text-decoration:underline/i)
// btn-cta has readable foreground/background in dark
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*\.btn-cta\{[^}]*background:[^;]+;[^}]*color:#fff/i)
})
})
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { exportPage } from '@/lib/exporter/html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'Dark Contrast',
locale: 'en',
theme: { primaryColor: '#2563eb', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://img.example/h.png' } },
{ type: 'faq', props: { items: [{ q: 'Q', a: 'A' }] } },
{ type: 'cta', props: { text: 'Go', href: '#' } },
],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 't', description: 'd' },
})
}
describe('Exporter - dark mode contrast tokens', () => {
it('includes dark mode tokens that improve contrast for links/buttons/faq text', () => {
const out = exportPage(makePage())
const css = out.css
// dark mode block exists
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)/)
// link color token in dark
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*a\{[^}]*color:#93c5fd/i)
// button keeps readable foreground/background in dark
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*button\{[^}]*background:[^;]+;[^}]*color:#fff/i)
// faq answer text brighter in dark
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)[\s\S]*\.faq-a\{[^}]*color:#cbd5e1/i)
})
})
+14 -1
View File
@@ -108,7 +108,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
${subheading ? `<p>${escapeHtml(subheading)}</p>` : ''} ${subheading ? `<p>${escapeHtml(subheading)}</p>` : ''}
</div> </div>
<div class="media"> <div class="media">
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" /> <img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="lazy" decoding="async" />
</div> </div>
</div> </div>
</section> </section>
@@ -237,6 +237,7 @@ function buildHead(page: Page) {
return ` return `
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="canonical" href="/" />
<title>${escapeHtml(page.seo.title)}</title> <title>${escapeHtml(page.seo.title)}</title>
<meta name="description" content="${escapeHtml(page.seo.description)}" /> <meta name="description" content="${escapeHtml(page.seo.description)}" />
<meta property="og:title" content="${escapeHtml(page.seo.title)}" /> <meta property="og:title" content="${escapeHtml(page.seo.title)}" />
@@ -272,6 +273,7 @@ function buildCss(page: Page) {
.testimonial blockquote{ margin:0; font-style:italic; color:#1f2937 } .testimonial blockquote{ margin:0; font-style:italic; color:#1f2937 }
.testimonial figcaption{ color:#475569; margin-top:.25rem } .testimonial figcaption{ color:#475569; margin-top:.25rem }
@media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } } @media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } }
@media (prefers-color-scheme: dark){ body{ color:#e5e7eb; background:#0b1020 } .feature-item{ background:#0f172a; border-color:#1f2937 } a{ color:#93c5fd } a:hover{ text-decoration:underline } button{ background: var(--primary); color:#fff } .btn-cta{ background: var(--primary); color:#fff } .faq-a{ color:#cbd5e1 } }
@media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } } @media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } }
` `
} }
@@ -282,6 +284,17 @@ function buildJs() {
(function(){ (function(){
var ts = document.querySelector('input[name="_ts"]'); var ts = document.querySelector('input[name="_ts"]');
if(ts){ ts.value = String(Date.now()); } if(ts){ ts.value = String(Date.now()); }
// Ensure skip-link moves focus into main for a11y
var skip = document.querySelector('a.skip-link');
var main = document.getElementById('main');
if (skip && main) {
skip.addEventListener('click', function(){
if (!main.hasAttribute('tabindex')) {
main.setAttribute('tabindex', '-1');
}
main.focus();
});
}
})(); })();
` `
} }
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { exportPage } from '@/lib/exporter/html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'SEO/Perf',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://img.example/hero.png' } },
],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter - small SEO/Perf improvements', () => {
it('includes canonical link and dark mode tokens; hero image lazy/async', () => {
const out = exportPage(makePage())
const html = out.html
const css = out.css
// canonical link present in head
expect(html).toMatch(/<link[^>]+rel="canonical"/)
// hero image has loading/decoding attributes
expect(html).toMatch(/<img[^>]*loading="lazy"/)
expect(html).toMatch(/<img[^>]*decoding="async"/)
// dark mode media query token exists
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)/)
})
})
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test'
type WindowWithExport = Window & {
__exportPreview?: () => { html: string; css: string; js: string }
}
const builderUrl = '/en/builder'
// Verify that focus outline tokens from CSS are applied in a real browser context
// by focusing the submit button in the exported page and inspecting computed styles.
test('Exported page shows 3px solid outline color on focused button', async ({ page, context }) => {
await page.goto(builderUrl)
// Minimal content to export
await page.getByTestId('card-hero').click()
await page.waitForFunction(() => Boolean((window as unknown as WindowWithExport).__exportPreview), { timeout: 5000 })
const exported = await page.evaluate(() => (window as unknown as WindowWithExport).__exportPreview?.() ?? null)
expect(exported).not.toBeNull()
const preview = await context.newPage()
await preview.setContent(exported!.html)
await preview.addStyleTag({ content: exported!.css })
const submit = preview.locator('button[type="submit"]')
await expect(submit).toHaveCount(1)
await submit.focus()
// Read computed style in the page context
const style = await preview.evaluate(() => {
const el = document.querySelector('button[type="submit"]') as HTMLElement | null
if (!el) return null
const cs = window.getComputedStyle(el)
return { outlineWidth: cs.outlineWidth, outlineStyle: cs.outlineStyle, outlineColor: cs.outlineColor }
})
expect(style).not.toBeNull()
// Expect 3px solid and brand color (#2563eb => rgb(37, 99, 235))
expect(style!.outlineWidth).toBe('3px')
expect(style!.outlineStyle).toBe('solid')
expect(style!.outlineColor.replace(/\s+/g, '')).toMatch(/rgb\(37,99,235\)|#2563eb/i)
await preview.close()
})
+49
View File
@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test'
type WindowWithExport = Window & {
__exportPreview?: () => { html: string; css: string; js: string }
}
const builderUrl = '/en/builder'
// Verify tab order after activating the skip-link: focus should move into <main>
// Then the first Tab should reach a focusable descendant (e.g., form submit button)
test('Skip-link Enter moves focus into main; next Tab reaches first focusable', async ({ page, context }) => {
await page.goto(builderUrl)
await page.getByTestId('card-hero').click()
await page.waitForFunction(() => Boolean((window as unknown as WindowWithExport).__exportPreview), { timeout: 5000 })
const exported = await page.evaluate(() => (window as unknown as WindowWithExport).__exportPreview?.() ?? null)
expect(exported).not.toBeNull()
const preview = await context.newPage()
await preview.setContent(exported!.html)
await preview.addStyleTag({ content: exported!.css })
const skip = preview.locator('a.skip-link')
await expect(skip).toHaveCount(1)
await skip.focus()
await expect(skip).toBeFocused()
// Activate skip-link
await preview.keyboard.press('Enter')
// Focus should be inside #main or on #main
const focusedInMain = await preview.evaluate(() => {
const main = document.getElementById('main')!
const active = document.activeElement as HTMLElement | null
if (!active) return false
return active === main || main.contains(active)
})
expect(focusedInMain).toBeTruthy()
// Press Tab and expect a focusable inside main (submit button) to receive focus
await preview.keyboard.press('Tab')
const isSubmitFocused = await preview.evaluate(() => {
const active = document.activeElement as HTMLElement | null
return !!active && active.tagName.toLowerCase() === 'button' && (active as HTMLButtonElement).type === 'submit'
})
expect(isSubmitFocused).toBeTruthy()
await preview.close()
})