experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

test(permissions): Allow+Remember coverage; settings rows grouped by origin

Two follow-up polishes on top of Phase 5:

1. **Allow+Remember test path** — the symmetric counterpart to the
existing Deny+Remember test. Drives the prompt → Allow with
"Remember this decision" checked → opens a fresh page-host on the
same origin → asserts no prompt appears AND the geolocation
promise does NOT reject with PERMISSION_DENIED (proving the stored
allow short-circuited the prompt the way the stored deny did).

2. **Settings rows grouped by origin** — the renderer now sorts by
origin first, then permission within origin, so every saved
decision for `https://example.com` sits adjacent in the list.
Backend `listDecisions` returns rows in `key`-order
(`{permission}:{origin}`), which scattered same-origin entries.
New sort happens in `refresh()` before appending to the list.
Removes the click-around-the-list friction for the common
"revoke everything I gave example.com" workflow.

Tests:
* `tests/desktop/permission-prompt.spec.ts` 6/6 (was 5; +Allow+
Remember).
* `tests/desktop/permissions-settings.spec.ts` 5/5 (was 4; +sort-
by-origin assertion that seeds in scrambled order and verifies
the rendered rows group by origin).

No backend changes; renderer + test additions only.

+84 -1
+10 -1
app/settings/settings.js
··· 1217 1217 1218 1218 // Reusable refresh — called both on initial load and on every visit 1219 1219 // to the section (so external mutations show up). 1220 + // 1221 + // Sorts the list by origin first, then by permission within origin — 1222 + // grouping every saved decision for a single site together makes the 1223 + // common case ("revoke everything I gave example.com") two clicks 1224 + // away instead of scattered through the list. 1220 1225 const refresh = async () => { 1221 1226 try { 1222 1227 const res = await api.permissions.list(); 1223 1228 list.replaceChildren(); 1224 1229 if (res?.success && Array.isArray(res.data) && res.data.length > 0) { 1225 - for (const decision of res.data) list.appendChild(renderRow(decision)); 1230 + const sorted = [...res.data].sort((a, b) => { 1231 + if (a.origin !== b.origin) return a.origin.localeCompare(b.origin); 1232 + return a.permission.localeCompare(b.permission); 1233 + }); 1234 + for (const decision of sorted) list.appendChild(renderRow(decision)); 1226 1235 emptyMsg.style.display = 'none'; 1227 1236 clearAllBtn.style.display = ''; 1228 1237 } else {
+41
tests/desktop/permission-prompt.spec.ts
··· 185 185 expect(promptCount).toBe(0); 186 186 }); 187 187 188 + test('Remember-this-decision (allow path): clicking Allow + checkbox persists, second request auto-allows', async () => { 189 + // Symmetric to the Deny+Remember test above. Start clean — earlier 190 + // tests may have stored a 127.0.0.1 deny. 191 + await bgWindow.evaluate(async () => { 192 + await (window as any).app.permissions.forgetAll(); 193 + }); 194 + 195 + const first = await openPageHost('perm-allow-remember-1'); 196 + const wv1 = await first.evaluateHandle(() => document.getElementById('content')); 197 + await first.evaluate((wv: any) => { 198 + (window as any).__geoPromise = wv.executeJavaScript('window.__requestGeo()'); 199 + }, wv1); 200 + 201 + const prompt = first.locator('.permission-prompt').first(); 202 + await expect(prompt).toBeVisible({ timeout: 5000 }); 203 + 204 + await prompt.locator('.permission-prompt-remember input[type="checkbox"]').check(); 205 + await prompt.locator('.permission-prompt-allow').click(); 206 + await expect(prompt).toHaveCount(0); 207 + 208 + // Allow proceeds with the actual geolocation lookup. In headless 209 + // mode it may fail with POSITION_UNAVAILABLE / TIMEOUT — but NOT 210 + // PERMISSION_DENIED (code 1). That confirms allow persisted. 211 + const r1 = await first.evaluate(() => (window as any).__geoPromise); 212 + expect(r1).not.toBe('denied:1'); 213 + 214 + // Second page-host on same origin: stored allow should short-circuit 215 + // the prompt. 216 + const second = await openPageHost('perm-allow-remember-2'); 217 + const wv2 = await second.evaluateHandle(() => document.getElementById('content')); 218 + await second.evaluate((wv: any) => { 219 + (window as any).__geoPromise = wv.executeJavaScript('window.__requestGeo()'); 220 + }, wv2); 221 + 222 + const r2 = await second.evaluate(() => (window as any).__geoPromise); 223 + expect(r2).not.toBe('denied:1'); 224 + 225 + const promptCount = await second.locator('.permission-prompt').count(); 226 + expect(promptCount).toBe(0); 227 + }); 228 + 188 229 test('Phase 5 — queueing: synthetic concurrent requests show one prompt + pending badge; resolving advances queue', async () => { 189 230 // Earlier "Remember-this-decision" test persisted a deny for 190 231 // 127.0.0.1 geolocation. Clear it so this test gets a fresh prompt.
+33
tests/desktop/permissions-settings.spec.ts
··· 136 136 expect(stillThere).toBe(0); 137 137 }); 138 138 139 + test('Phase 5 — rows are grouped by origin (sorted origin then permission)', async () => { 140 + // Seed in deliberately scrambled order — the renderer should still 141 + // group them by origin so the user sees example.com's two decisions 142 + // adjacent to each other, then meet.example.com's. 143 + await seedDecision('https://meet.example.com', 'media', true); 144 + await seedDecision('https://example.com', 'midi', false); 145 + await seedDecision('https://meet.example.com', 'geolocation', false); 146 + await seedDecision('https://example.com', 'geolocation', true); 147 + 148 + const settings = await openSettings(); 149 + await settings.locator('a.nav-item[data-section="permissions"]').click(); 150 + 151 + await settings.waitForFunction( 152 + () => document.querySelectorAll('#section-permissions .item-card').length === 4, 153 + undefined, 154 + { timeout: 5000 }, 155 + ); 156 + 157 + // Read the visual order — each row's origin text in DOM order. 158 + const origins = await settings.locator('#section-permissions .item-card').evaluateAll( 159 + (rows) => rows.map((r) => { 160 + const originEl = r.querySelector('div[style*="font-family"]'); 161 + return originEl?.textContent || ''; 162 + }), 163 + ); 164 + 165 + // Both example.com rows must come first (alphabetical), and consecutively. 166 + expect(origins[0]).toBe('https://example.com'); 167 + expect(origins[1]).toBe('https://example.com'); 168 + expect(origins[2]).toBe('https://meet.example.com'); 169 + expect(origins[3]).toBe('https://meet.example.com'); 170 + }); 171 + 139 172 test('Phase 5 — favicon img renders next to each decision row', async () => { 140 173 await seedDecision('https://example.com', 'geolocation', false); 141 174 await seedDecision('https://meet.example.com', 'media', true);