| 1 | // Run with playwright-cli run-code after signing in as the fixture owner: |
| 2 | // ... run-code --filename scripts/qa/room-attachments-browser.js |
| 3 | // The fixture is created by room-attachments-fixture.mjs. |
| 4 | async page => { |
| 5 | const results = [], errors = []; |
| 6 | const check = (condition, message) => { if (!condition) throw Error(message); }; |
| 7 | const run = async (name, action) => { try { results.push({name, ok: true, detail: await action()}); } catch (error) { results.push({name, ok: false, error: error.message}); } }; |
| 8 | page.on("pageerror" , error => errors.push(error.message)); |
| 9 | const base = await page.evaluate(() => location.origin); |
| 10 | const room = "/rooms/attachments-qa" ; |
| 11 | await page.setViewportSize({width: 1440, height: 900}); |
| 12 | |
| 13 | await run("SSR room shows image, generic file and agent message" , async () => { |
| 14 | await page.goto(base + room); |
| 15 | await page.waitForSelector("#messages room-message" ); |
| 16 | await page.waitForFunction(() => typeof window.tiny?.signer === "function" && typeof window.tiny.signer()?.signEvent === "function" ); |
| 17 | const info = await page.evaluate(() => ({ |
| 18 | messages: document.querySelectorAll("#messages room-message" ).length, |
| 19 | images: document.querySelectorAll("#messages img" ).length, |
| 20 | links: [...document.querySelectorAll("#messages a" )].map(a => a.textContent.trim()), |
| 21 | videos: document.querySelectorAll("#messages video" ).length, |
| 22 | agent: [...document.querySelectorAll("#messages room-message" )].filter(node => node.textContent.includes("Agent published file" )).length, |
| 23 | live: !!document.querySelector("room-live" ) |
| 24 | })); |
| 25 | check(info.images >= 1, "initial image was not rendered inline" ); |
| 26 | check(info.links.some(label => label.includes("fixture notes.txt" )), "generic file link missing" ); |
| 27 | check(info.agent >= 1, "agent message missing from room timeline" ); |
| 28 | check(info.live, "room live stream missing" ); |
| 29 | const liveText = "Live agent attachment" ; |
| 30 | await page.evaluate(async text => { |
| 31 | const url = document.querySelector("#messages img" )?.src; |
| 32 | const unsigned = {kind: 9, created_at: Math.floor(Date.now() / 1000), tags: [["h" , "attachments-qa" ], ["imeta" , "url " + url, "m image/png" ]], content: text + "\n\n" }; |
| 33 | const signer = window.tiny.signer(); |
| 34 | if (!signer?.signEvent) throw Error("connected signer unavailable for live check" ); |
| 35 | const signed = await signer.signEvent(unsigned); |
| 36 | const response = await window.tiny.signedFetch("/events" , "POST" , JSON.stringify(signed), {contentType: "application/json" }); |
| 37 | if (!response.ok) throw Error("live publish failed" ); |
| 38 | }, liveText); |
| 39 | await page.waitForFunction(text => document.querySelector("#messages" )?.textContent.includes(text), liveText); |
| 40 | await page.screenshot({path: "output/playwright/room-attachments/room-desktop.png" }); |
| 41 | return {...info, liveAppend: true}; |
| 42 | }); |
| 43 | |
| 44 | await run("file picker previews and publishes an image" , async () => { |
| 45 | const picker = page.locator('room-compose input[name="attachments"]' ); |
| 46 | check(await picker.count() === 1, "attachment picker missing" ); |
| 47 | const attach = page.locator('room-compose [data-attach]' ); |
| 48 | check(await attach.count() === 1, "compact attach button missing" ); |
| 49 | check(!await picker.isVisible(), "native file picker should be hidden behind the attach button" ); |
| 50 | // Check the native chooser separately with CLI click/upload commands; |
| 51 | // its modal state is managed outside run-code by playwright-cli. |
| 52 | await picker.setInputFiles("output/playwright/room-attachments/browser-upload.png" ); |
| 53 | await page.waitForSelector("room-files li" ); |
| 54 | check((await page.locator("room-files" ).textContent()).includes("browser-upload.png" ), "pending file preview missing" ); |
| 55 | check(await page.locator("room-files img" ).count() === 1, "image attachment thumbnail missing" ); |
| 56 | await page.waitForFunction(() => { |
| 57 | const img = document.querySelector("room-files img" ); |
| 58 | return img?.complete && img.naturalWidth > 0; |
| 59 | }); |
| 60 | check(await page.locator("room-files img" ).first().evaluate(img => img.complete && img.naturalWidth > 0), "image attachment thumbnail did not load" ); |
| 61 | const remove = page.getByRole("button" , {name: "Remove browser-upload.png" , exact: true}); |
| 62 | check(await remove.count() === 1, "attachment remove button missing" ); |
| 63 | await remove.click(); |
| 64 | check(await page.locator("room-files li" ).count() === 0, "attachment remove did not clear preview" ); |
| 65 | await picker.setInputFiles("output/playwright/room-attachments/browser-upload.png" ); |
| 66 | await page.waitForSelector("room-files li" ); |
| 67 | await page.locator('room-compose textarea[name="content"]' ).fill("Browser upload" ); |
| 68 | const request = page.waitForRequest(req => req.method() === "PUT" && req.url().includes("/rooms/attachments-qa/attachments" )); |
| 69 | await page.locator("room-compose" ).getByRole("button" , {name: "Send" , exact: true}).click(); |
| 70 | await request; |
| 71 | await page.waitForFunction(() => !document.querySelector("room-files li" )); |
| 72 | check(await page.locator("#messages" ).textContent().then(text => text.includes("Browser upload" )), "published upload missing from timeline" ); |
| 73 | return {uploaded: true}; |
| 74 | }); |
| 75 | |
| 76 | await run("attachment-only message is accepted" , async () => { |
| 77 | const picker = page.locator('room-compose input[name="attachments"]' ); |
| 78 | await picker.setInputFiles("output/playwright/room-attachments/only.txt" ); |
| 79 | await page.waitForSelector("room-files li" ); |
| 80 | check(await page.locator('room-compose textarea[name="content"]' ).getAttribute("required" ) === null, "content remained required with an attachment" ); |
| 81 | await page.locator("room-compose" ).getByRole("button" , {name: "Send" , exact: true}).click(); |
| 82 | await page.waitForFunction(() => !document.querySelector("room-files li" )); |
| 83 | return {uploaded: true}; |
| 84 | }); |
| 85 | |
| 86 | await run("reload and narrow viewport preserve media layout" , async () => { |
| 87 | await page.reload(); |
| 88 | await page.waitForSelector("#messages room-message" ); |
| 89 | await page.setViewportSize({width: 360, height: 800}); |
| 90 | const metrics = await page.evaluate(() => ({width: innerWidth, scrollWidth: document.documentElement.scrollWidth, content: document.querySelector("#content" )?.scrollWidth, image: document.querySelector("#messages img" )?.complete})); |
| 91 | check(metrics.scrollWidth <= metrics.width && metrics.content <= metrics.width, "room overflows at narrow viewport" ); |
| 92 | check(metrics.image, "image did not load after reload" ); |
| 93 | await page.screenshot({path: "output/playwright/room-attachments/room-mobile.png" }); |
| 94 | return metrics; |
| 95 | }); |
| 96 | |
| 97 | await run("composer stays compact and touch friendly" , async () => { |
| 98 | await page.setViewportSize({width: 360, height: 420}); |
| 99 | const compose = page.locator("room-compose" ); |
| 100 | const input = compose.locator('input[name="attachments"]' ); |
| 101 | check(await input.count() === 1, "attachment input missing" ); |
| 102 | const metrics = await page.evaluate(() => { |
| 103 | const node = document.querySelector("room-compose" ); |
| 104 | const attach = node?.querySelector('button[aria-label*="Attach" i], label[for], label' ); |
| 105 | const send = node?.querySelector('button[type="submit"], button:not([type])' ); |
| 106 | const box = element => element?.getBoundingClientRect(); |
| 107 | const attachBox = box(attach), sendBox = box(send); |
| 108 | return { |
| 109 | composeHeight: box(node)?.height || 0, |
| 110 | viewportHeight: innerHeight, |
| 111 | scrollWidth: document.documentElement.scrollWidth, |
| 112 | viewportWidth: innerWidth, |
| 113 | attachWidth: attachBox?.width || 0, |
| 114 | attachHeight: attachBox?.height || 0, |
| 115 | sendWidth: sendBox?.width || 0, |
| 116 | sendHeight: sendBox?.height || 0, |
| 117 | placeholder: node?.querySelector("textarea" )?.getAttribute("placeholder" ) || "" |
| 118 | }; |
| 119 | }); |
| 120 | check(metrics.scrollWidth <= metrics.viewportWidth, "composer overflows horizontally" ); |
| 121 | check(metrics.composeHeight <= 90, "empty composer occupies too much of the viewport" ); |
| 122 | check(metrics.attachWidth >= 40 && metrics.attachHeight >= 40, "attach control is too small to tap" ); |
| 123 | check(metrics.sendWidth >= 40 && metrics.sendHeight >= 40, "send control is too small to tap" ); |
| 124 | check(!/[0-9a-f]{16,}/i.test(metrics.placeholder), "composer exposes an internal identifier in its placeholder" ); |
| 125 | await page.screenshot({path: "output/playwright/room-attachments/room-mobile-composer.png" }); |
| 126 | await page.setViewportSize({width: 1440, height: 900}); |
| 127 | const desktop = await page.evaluate(() => ({ |
| 128 | width: innerWidth, |
| 129 | scrollWidth: document.documentElement.scrollWidth, |
| 130 | composeHeight: document.querySelector("room-compose" )?.getBoundingClientRect().height || 0 |
| 131 | })); |
| 132 | check(desktop.scrollWidth <= desktop.width, "desktop composer overflows horizontally" ); |
| 133 | await page.screenshot({path: "output/playwright/room-attachments/room-desktop-composer.png" }); |
| 134 | return {mobile: metrics, desktop}; |
| 135 | }); |
| 136 | |
| 137 | await run("JavaScript-disabled SSR still renders media" , async () => { |
| 138 | const context = await page.context().browser().newContext({javaScriptEnabled: false, viewport: {width: 360, height: 800}, storageState: await page.context().storageState()}); |
| 139 | try { |
| 140 | const noJS = await context.newPage(); |
| 141 | await noJS.goto(base + room); |
| 142 | const info = await noJS.evaluate(() => ({images: document.querySelectorAll("#messages img" ).length, links: document.querySelectorAll("#messages a" ).length, compose: !!document.querySelector("room-compose" )})); |
| 143 | check(info.images >= 1 && info.links >= 1, "no-JS room omitted attachment markup" ); |
| 144 | check(info.compose, "no-JS room omitted the attachment composer markup" ); |
| 145 | await noJS.screenshot({path: "output/playwright/room-attachments/room-nojs.png" }); |
| 146 | return info; |
| 147 | } finally { await context.close(); } |
| 148 | }); |
| 149 | |
| 150 | await run("guest cannot fetch private media" , async () => { |
| 151 | const context = await page.context().browser().newContext(); |
| 152 | try { |
| 153 | const guest = await context.newPage(); |
| 154 | const mediaURL = await page.locator("#messages img" ).first().getAttribute("src" ); |
| 155 | check(mediaURL, "could not locate fixture media URL" ); |
| 156 | const media = await context.request.get(mediaURL); |
| 157 | check([401, 403].includes(media.status()), "guest private media returned " + media.status()); |
| 158 | return {status: media.status()}; |
| 159 | } finally { await context.close(); } |
| 160 | }); |
| 161 | results.push({name: "uncaught browser errors" , ok: errors.length === 0, errors}); |
| 162 | return {passed: results.filter(item => item.ok).length, failed: results.filter(item => !item.ok).length, results}; |
| 163 | } |