tinyrelayfile
tinyrelay/scripts/qa/room-attachments-browser.js/

scripts/qa/room-attachments-browser.js raw
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.
4async 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![image](" + url + ")"};
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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs