1import { expect, test, type Page } from "@playwright/test"
2
3/* M9 (DESIGN-SYSTEM §11, syarat "seluruh alur bisa diselesaikan dengan
4 keyboard dan tanpa gesture"): seluruh alur dasar tanpa satu pun klik
5 penunjuk. Fokus dipindah dengan Tab, radio dan pil digerakkan dengan panah,
6 dan tidak ada `page.mouse` di berkas ini.
7
8 Gesture dimatikan seperti berkas e2e lain supaya hitung mundur tidak
9 berlomba dengan inferensi MediaPipe di Chromium headless. */
10
11/** Nama aksesibel elemen yang sedang fokus: `aria-label`, teks dari
12 * `aria-labelledby`, lalu teksnya sendiri. */
13async function namaFokus(page: Page): Promise<string> {
14 return page.evaluate(() => {
15 const element = document.activeElement
16 if (element === null) {
17 return ""
18 }
19 const labelled = element.getAttribute("aria-labelledby")
20 const dariLabel =
21 labelled === null
22 ? null
23 : labelled
24 .split(/\s+/)
25 .map((id) => document.getElementById(id)?.textContent ?? "")
26 .join(" ")
27 return (
28 element.getAttribute("aria-label") ??
29 dariLabel ??
30 element.textContent ??
31 ""
32 ).trim()
33 })
34}
35
36/** Menekan Tab sampai elemen dengan nama aksesibel itu yang fokus. */
37async function tabKe(page: Page, nama: string, batas = 120): Promise<void> {
38 for (let langkah = 0; langkah < batas; langkah += 1) {
39 await page.keyboard.press("Tab")
40 if ((await namaFokus(page)).startsWith(nama)) {
41 return
42 }
43 }
44 throw new Error(`Tab tidak sampai ke "${nama}" dalam ${batas} langkah`)
45}
46
47/** Nama pil pilihan di rel kiri Hias, urut dari kiri ke kanan. */
48const PIL_HIAS: readonly string[] = ["Frame", "Efek", "Stiker"]
49
50/** Menggeser fokus pil hiasan dengan panah, satu pil tiap langkah.
51 *
52 * Grup pil pilihan hiasan memperlakukan Tab sebagai masuk ke grupnya, jadi
53 * perpindahan antar-pil memang lewat panah. Tiap langkah diulang sampai fokus
54 * benar-benar sampai di pil tujuan langkah itu, karena dua hal: Radix
55 * memindahkan fokus roving-nya setelah render berikutnya, dan satu tekanan
56 * pertama setelah `focus()` tersedot untuk menyegerakan tab stop-nya. Menekan
57 * panah beruntun lalu berharap fokusnya pindah pernah gagal di runner CI: 12
58 * tekanan habis tanpa satu pun berpindah, dan galatnya muncul di langkah
59 * hiasan ini. */
60async function panahKe(
61 page: Page,
62 ke: string,
63 tombol: "ArrowRight" | "ArrowLeft"
64): Promise<void> {
65 const arah = tombol === "ArrowRight" ? 1 : -1
66 let kini = await namaFokus(page)
67 if (!PIL_HIAS.includes(kini)) {
68 throw new Error(`Fokus awal bukan pil hiasan: "${kini}"`)
69 }
70 while (kini !== ke) {
71 const berikut = PIL_HIAS[PIL_HIAS.indexOf(kini) + arah]
72 if (berikut === undefined) {
73 throw new Error(`Panah ${tombol} tidak punya pil berikutnya dari "${kini}"`)
74 }
75 const pilKini = page.getByRole("radio", { name: kini })
76 const pilBerikut = page.getByRole("radio", { name: berikut })
77 await expect(async () => {
78 /* Fokus dipasang ulang tiap percobaan: kalau fokusnya jatuh ke badan
79 halaman, tekanan panah tidak melakukan apa pun. */
80 await pilKini.focus()
81 await page.keyboard.press(tombol)
82 await expect(pilBerikut).toBeFocused({ timeout: 1_000 })
83 }).toPass({ timeout: 15_000 })
84 kini = berikut
85 }
86}
87
88test.beforeEach(async ({ page }) => {
89 await page.addInitScript(() => {
90 window.localStorage.setItem("snapme:gesture-enabled", "0")
91 })
92})
93
94test("seluruh alur selesai hanya dengan keyboard", async ({ page }) => {
95 test.setTimeout(180_000)
96
97 /* Server pengembangan mengompilasi rute saat pertama diminta, dan muat ulang
98 yang dipicunya bisa mengosongkan state sesi di tengah alur (catatan yang
99 sama ada di docs/DECISIONS.md M8). Rutenya dihangatkan dulu supaya
100 penekanan Tab tidak berlomba dengan kompilasi. */
101 for (const rute of [
102 "/booth/layout",
103 "/booth/capture",
104 "/booth/customize",
105 "/booth/result",
106 ]) {
107 await page.goto(rute)
108 }
109 await page.goto("/")
110
111 /* Welcome: "Mulai Foto" dijangkau dengan Tab, lalu dibuka dengan Enter. */
112 await tabKe(page, "Mulai Foto")
113 await page.keyboard.press("Enter")
114 await page.waitForURL("**/booth/layout")
115
116 /* Pilih layout: panah menggerakkan pilihan di dalam grup radio, lalu
117 pilihannya dikunci dengan Space. */
118 await tabKe(page, "Strip 4 foto")
119 await page.keyboard.press("ArrowDown")
120 await page.keyboard.press("ArrowUp")
121 await page.keyboard.press("Space")
122 await expect(page.getByRole("radio", { name: "Strip 4 foto" })).toBeChecked()
123 await expect(page.getByRole("button", { name: "Mulai Foto" })).toBeEnabled()
124
125 await tabKe(page, "Mulai Foto")
126 await page.keyboard.press("Enter")
127 await page.waitForURL("**/booth/capture")
128
129 /* Izin kamera juga lewat tombol, bukan dialog peramban. */
130 await tabKe(page, "Izinkan Kamera")
131 await page.keyboard.press("Enter")
132 await expect(page.locator("video")).toBeVisible()
133 await expect
134 .poll(() =>
135 page.evaluate(() => document.querySelector("video")?.videoWidth ?? 0)
136 )
137 .toBeGreaterThan(0)
138
139 /* Spasi di area kamera memotret, empat kali. */
140 for (let nomor = 1; nomor <= 4; nomor += 1) {
141 /* Mesin keadaan hanya menerima `SHOOT` pada fase siap, dan jeda 300 ms
142 setelah tiap foto (GS-04) membuat Spasi yang datang lebih awal hilang
143 tanpa jejak: hitung mundur tidak pernah muncul. Tombol jepret yang aktif
144 adalah penanda fase siap, jadi ditunggu lebih dulu. Di runner CI tanpa
145 GPU, jeda itu berlomba dengan `expect` penghitung foto dan tekanannya
146 kadang jatuh di dalamnya. */
147 await expect(page.getByRole("button", { name: "Ambil foto" })).toBeEnabled()
148
149 await tabKe(page, "Area kamera")
150 /* `handleKeyDown` mengabaikan Spasi yang datang dari elemen anak
151 (`event.target !== event.currentTarget`), jadi fokusnya dipastikan ada
152 di kontainer area kamera sebelum Spasi ditekan. Memfokuskan elemen
153 terprogram sudah dipakai di bawah untuk pil hiasan. */
154 const areaKamera = page.getByRole("group", { name: "Area kamera" })
155 await areaKamera.focus()
156 await expect(areaKamera).toBeFocused()
157
158 await page.keyboard.press("Space")
159 await expect(page.getByTestId("countdown")).toBeVisible()
160 await expect(page.getByTestId("countdown")).toHaveCount(0)
161 await expect(page.getByText(`${nomor}/4`)).toBeVisible()
162 }
163
164 await tabKe(page, "Lanjut Hias")
165 await page.keyboard.press("Enter")
166 await page.waitForURL("**/booth/customize")
167 await expect(page.locator("canvas")).toBeVisible()
168
169 /* Hias: Tab masuk ke grup pil, lalu ke swatch frame di rel kiri. */
170 await tabKe(page, "Frame")
171 await tabKe(page, "Malam")
172 await page.keyboard.press("Enter")
173
174 /* Pil Stiker digerakkan panah lalu dikunci Enter. Fokus pilnya dimasukkan
175 terprogram (pola uji keyboard customize.spec.ts) karena hanya pil terpilih
176 yang jadi tab stop dan panah Radix tersendat satu tekanan. */
177 await page.getByRole("radio", { name: "Frame" }).focus()
178 await panahKe(page, "Stiker", "ArrowRight")
179 await expect(page.getByRole("radio", { name: "Stiker" })).toBeFocused()
180 await page.keyboard.press("Enter")
181 await expect(page.getByRole("radio", { name: "Stiker" })).toBeChecked()
182
183 await tabKe(page, "Tempel Bintang")
184 await page.keyboard.press("Enter")
185 await expect(page.getByText("Stiker Bintang")).toBeVisible()
186
187 await tabKe(page, "Pindah kanan: Bintang")
188 await page.keyboard.press("Enter")
189 await expect(page.getByText(/^x \d+ · y \d+ · 120 px · -6°$/)).toBeVisible()
190
191 /* Kartu QR menyala supaya dialog persetujuan unggah muncul di langkah Hasil
192 (ShareCard hanya dirender saat QR atau linknya ada). */
193 await tabKe(page, "QR di strip")
194 await page.keyboard.press("Space")
195 await expect(page.getByRole("switch", { name: "QR di strip" })).toBeChecked()
196
197 await tabKe(page, "Selesai & Unduh")
198 await page.keyboard.press("Enter")
199 await page.waitForURL("**/booth/result")
200 await expect(page.getByAltText("Pratinjau strip")).toBeVisible()
201
202 /* Dialog persetujuan unggah dibuka dengan Enter dan ditutup dengan Esc. */
203 await tabKe(page, "Buat Link dan Unggah")
204 await page.keyboard.press("Enter")
205 await expect(page.getByRole("dialog")).toBeVisible()
206 await page.keyboard.press("Escape")
207 await expect(page.getByRole("dialog")).toHaveCount(0)
208 await expect(
209 page.getByRole("button", { name: "Buat Link dan Unggah" })
210 ).toBeVisible()
211
212 /* Enter pada aksi utama mengunduh stripnya. */
213 await tabKe(page, "Selesai & Unduh")
214 const [download] = await Promise.all([
215 page.waitForEvent("download"),
216 page.keyboard.press("Enter"),
217 ])
218 expect(download.suggestedFilename()).toMatch(/^snapme-\d{8}-\d{4}\.png$/)
219})
220