mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-18 07:13:12 +00:00
test: cover the popup blacklist UI, tighten audit findings
The delta model was only covered at module level; the popup wiring around it was not exercised at all. Seven specs now drive the real settings UI in the packed extension, including the migration path: a pre-v3.1.0 snapshot is converted on open, the legacy key is removed, and a default missing from that snapshot is delivered again. Also from the audit pass: - await the blacklist read in init instead of firing a floating promise - unify the debug report on the finder's own candidate list, which the separate traversal missed shadow-DOM videos from - assert that a single candidate is always returned regardless of its ranking signals, so no scoring signal can regress a single-player site Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+3
-11
@@ -685,16 +685,6 @@
|
||||
|
||||
hcmDeferredSnapBack(); // buffering → wait for ready, then snap once (#3)
|
||||
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
// Deliberate: offer the choice (Teleparty-style), default = snap back.
|
||||
|
||||
hcmShowDesyncDialog(action, target);
|
||||
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -1332,7 +1322,9 @@
|
||||
src.connect(dryGain);
|
||||
dryGain.connect(outputGain);
|
||||
src.connect(compressor);
|
||||
compressor.connect(compGain);
|
||||
compressor.connect(compGain);
|
||||
compGain.connect(outputGain);
|
||||
outputGain.connect(limiter);
|
||||
limiter.connect(ctx.destination);
|
||||
|
||||
dryGain.gain.value = 1;
|
||||
|
||||
+1
-1
@@ -392,7 +392,7 @@ async function init() {
|
||||
elements.username.value = username;
|
||||
syncDevToolsVisibility();
|
||||
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
|
||||
readBlacklistOverrides().then(overrides => renderBlacklistEditor(overrides));
|
||||
renderBlacklistEditor(await readBlacklistOverrides());
|
||||
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
|
||||
if (elements.chatEnabled) elements.chatEnabled.checked = localData.chatEnabled === true;
|
||||
if (elements.chatNotifications) elements.chatNotifications.checked = localData.chatNotifications !== false;
|
||||
|
||||
@@ -97,6 +97,28 @@ assert.strictEqual(
|
||||
'findVideo should score Shadow DOM videos together with light DOM videos'
|
||||
);
|
||||
|
||||
// Invariant that protects every already-working single-player site: with one
|
||||
// candidate the ranking is never consulted, so no signal can turn a page that
|
||||
// used to sync into "no video found".
|
||||
const lonelyBadCandidate = makeVideo('lonely', 0, 0, { muted: true, duration: 0 });
|
||||
lonelyBadCandidate.loop = true;
|
||||
lonelyBadCandidate.controls = false;
|
||||
lonelyBadCandidate.offsetWidth = 0;
|
||||
lonelyBadCandidate.offsetHeight = 0;
|
||||
|
||||
const lonelyDocument = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [lonelyBadCandidate];
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
assert.strictEqual(
|
||||
findVideo(lonelyDocument),
|
||||
lonelyBadCandidate,
|
||||
'a single candidate is returned even when every ranking signal is against it'
|
||||
);
|
||||
|
||||
// Same-origin player iframe (jkanime.net): the top document has no <video>,
|
||||
// the real player lives inside the frame document.
|
||||
const framedPlayer = makeVideo('framed-player', 1280, 720, { muted: false, duration: 1400 });
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test as base, expect, chromium } from '@playwright/test';
|
||||
import { test, expect } from './helpers/extension-fixture.mjs';
|
||||
|
||||
/**
|
||||
* Drives the packed extension itself: real background service worker, real
|
||||
@@ -11,37 +7,6 @@ import { test as base, expect, chromium } from '@playwright/test';
|
||||
* far enough to pick one.
|
||||
*/
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const extensionPath = path.join(repoRoot, 'dist/chrome');
|
||||
|
||||
const test = base.extend({
|
||||
context: async ({}, use) => {
|
||||
if (!fs.existsSync(path.join(extensionPath, 'manifest.json'))) {
|
||||
throw new Error('dist/chrome is missing. Run: npm run build:extension');
|
||||
}
|
||||
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-e2e-'));
|
||||
const context = await chromium.launchPersistentContext(userDataDir, {
|
||||
// The headless shell does not run MV3 service workers; the full
|
||||
// Chromium build in new headless mode does.
|
||||
channel: 'chromium',
|
||||
headless: true,
|
||||
args: [
|
||||
`--disable-extensions-except=${extensionPath}`,
|
||||
`--load-extension=${extensionPath}`,
|
||||
'--autoplay-policy=no-user-gesture-required'
|
||||
]
|
||||
});
|
||||
await use(context);
|
||||
await context.close();
|
||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||
},
|
||||
extensionId: async ({ context }, use) => {
|
||||
let [worker] = context.serviceWorkers();
|
||||
if (!worker) worker = await context.waitForEvent('serviceworker');
|
||||
await use(worker.url().split('/')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
/** Runs code in an extension page, where the privileged chrome.* APIs exist. */
|
||||
async function withExtensionPage(context, extensionId, fn) {
|
||||
const page = await context.newPage();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test as base, chromium } from '@playwright/test';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
export const extensionPath = path.join(repoRoot, 'dist/chrome');
|
||||
|
||||
/**
|
||||
* A browser with the packed extension loaded, plus its extension id. Each test
|
||||
* gets a throwaway profile so storage from one test cannot leak into the next.
|
||||
*/
|
||||
export const test = base.extend({
|
||||
context: async ({}, use) => {
|
||||
if (!fs.existsSync(path.join(extensionPath, 'manifest.json'))) {
|
||||
throw new Error('dist/chrome is missing. Run: npm run build:extension');
|
||||
}
|
||||
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-e2e-'));
|
||||
const context = await chromium.launchPersistentContext(userDataDir, {
|
||||
// The headless shell does not run MV3 service workers; the full
|
||||
// Chromium build in new headless mode does.
|
||||
channel: 'chromium',
|
||||
headless: true,
|
||||
args: [
|
||||
`--disable-extensions-except=${extensionPath}`,
|
||||
`--load-extension=${extensionPath}`,
|
||||
'--autoplay-policy=no-user-gesture-required'
|
||||
]
|
||||
});
|
||||
await use(context);
|
||||
await context.close();
|
||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||
},
|
||||
extensionId: async ({ context }, use) => {
|
||||
let [worker] = context.serviceWorkers();
|
||||
if (!worker) worker = await context.waitForEvent('serviceworker');
|
||||
await use(worker.url().split('/')[2]);
|
||||
}
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Opens the real popup page, waits for its settings to be populated and expands
|
||||
* the domain editor, which ships collapsed and therefore has no clickable
|
||||
* buttons until it is opened.
|
||||
*/
|
||||
export async function openPopup(context, extensionId, { openEditor = true } = {}) {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
await page.waitForFunction(() => {
|
||||
const textarea = document.getElementById('blacklistDomains');
|
||||
return !!textarea && textarea.value.length > 0;
|
||||
});
|
||||
|
||||
if (!openEditor) return page;
|
||||
|
||||
// A fresh profile has never seen onboarding, and its overlay sits on top of
|
||||
// the whole popup. Dismiss it the same way the tour's last step does.
|
||||
await page.evaluate(() => new Promise(resolve => {
|
||||
chrome.storage.sync.set({ onboardingComplete: true }, () => {
|
||||
const overlay = document.getElementById('onboarding-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
resolve();
|
||||
});
|
||||
}));
|
||||
|
||||
// The controls live on the Settings tab, inside a collapsed accordion.
|
||||
await page.click('.tab-btn[data-tab="tab-settings"]');
|
||||
await page.evaluate(() => {
|
||||
const details = document.getElementById('blacklistEdit')?.closest('details');
|
||||
if (details) details.open = true;
|
||||
});
|
||||
|
||||
await page.click('#blacklistEdit');
|
||||
await page.waitForSelector('#blacklistSave', { state: 'visible' });
|
||||
return page;
|
||||
}
|
||||
|
||||
export async function readStorage(page, keys) {
|
||||
return page.evaluate(k => chrome.storage.local.get(k), keys);
|
||||
}
|
||||
|
||||
export async function writeStorage(page, values) {
|
||||
return page.evaluate(v => chrome.storage.local.set(v), values);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { test, expect, openPopup, readStorage, writeStorage } from './helpers/extension-fixture.mjs';
|
||||
import { BLACKLIST_DOMAINS } from '../../shared/blacklist.js';
|
||||
|
||||
/**
|
||||
* Drives the real settings UI in the real popup. The shared module is unit
|
||||
* tested on its own; what this file covers is the wiring around it, which is
|
||||
* where a delta model can quietly fall back to snapshot behaviour.
|
||||
*/
|
||||
|
||||
const OVERRIDES_KEY = 'blacklistOverrides';
|
||||
const LEGACY_KEY = 'customBlacklistDomains';
|
||||
|
||||
/** The editor body without its comment headers, in order. */
|
||||
async function readEditorDomains(page) {
|
||||
return page.evaluate(() => document.getElementById('blacklistDomains').value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#')));
|
||||
}
|
||||
|
||||
async function setEditorDomains(page, domains) {
|
||||
await page.evaluate(value => {
|
||||
document.getElementById('blacklistDomains').value = value;
|
||||
}, domains.join('\n'));
|
||||
}
|
||||
|
||||
test('shows the shipped defaults grouped under section headers', async ({ context, extensionId }) => {
|
||||
const page = await openPopup(context, extensionId);
|
||||
|
||||
const body = await page.inputValue('#blacklistDomains');
|
||||
const headers = body.split('\n').filter(line => line.startsWith('#'));
|
||||
expect(headers, 'both section headers should be present').toHaveLength(2);
|
||||
expect(headers[0].replace('#', '').trim(), 'the user section header should be translated').not.toBe('');
|
||||
|
||||
const domains = await readEditorDomains(page);
|
||||
expect(domains).toEqual(BLACKLIST_DOMAINS);
|
||||
|
||||
const stored = await readStorage(page, [OVERRIDES_KEY]);
|
||||
expect(stored[OVERRIDES_KEY], 'an untouched list stores nothing').toBeUndefined();
|
||||
});
|
||||
|
||||
test('saves only the delta when a default is removed and a domain is added', async ({ context, extensionId }) => {
|
||||
const page = await openPopup(context, extensionId);
|
||||
|
||||
const kept = BLACKLIST_DOMAINS.filter(domain => domain !== 'reddit.com');
|
||||
await setEditorDomains(page, [...kept, 'videos.example']);
|
||||
await page.click('#blacklistSave');
|
||||
await expect(page.locator('#blacklistStatus')).toHaveAttribute('data-state', 'success');
|
||||
|
||||
const stored = await readStorage(page, [OVERRIDES_KEY]);
|
||||
expect(stored[OVERRIDES_KEY]).toEqual({
|
||||
removedDefaults: ['reddit.com'],
|
||||
addedDomains: ['videos.example']
|
||||
});
|
||||
});
|
||||
|
||||
test('regroups a saved entry under the user section after reopening', async ({ context, extensionId }) => {
|
||||
const first = await openPopup(context, extensionId);
|
||||
await setEditorDomains(first, [...BLACKLIST_DOMAINS, 'videos.example']);
|
||||
await first.click('#blacklistSave');
|
||||
await expect(first.locator('#blacklistStatus')).toHaveAttribute('data-state', 'success');
|
||||
await first.close();
|
||||
|
||||
const second = await openPopup(context, extensionId);
|
||||
const body = await second.inputValue('#blacklistDomains');
|
||||
const lines = body.split('\n').map(line => line.trim());
|
||||
const userHeaderIndex = lines.findIndex(line => line.startsWith('#'));
|
||||
const defaultHeaderIndex = lines.findIndex((line, i) => i > userHeaderIndex && line.startsWith('#'));
|
||||
|
||||
expect(lines.indexOf('videos.example'), 'the user entry sits in the user section')
|
||||
.toBeLessThan(defaultHeaderIndex);
|
||||
expect(lines.indexOf('google.com'), 'a shipped default sits in the defaults section')
|
||||
.toBeGreaterThan(defaultHeaderIndex);
|
||||
});
|
||||
|
||||
test('rejects an invalid entry without saving anything', async ({ context, extensionId }) => {
|
||||
const page = await openPopup(context, extensionId);
|
||||
|
||||
await setEditorDomains(page, [...BLACKLIST_DOMAINS, 'not a domain']);
|
||||
await page.click('#blacklistSave');
|
||||
await expect(page.locator('#blacklistStatus')).toHaveAttribute('data-state', 'error');
|
||||
|
||||
const stored = await readStorage(page, [OVERRIDES_KEY]);
|
||||
expect(stored[OVERRIDES_KEY], 'a rejected save must not write a partial delta').toBeUndefined();
|
||||
});
|
||||
|
||||
test('restore defaults clears the stored delta', async ({ context, extensionId }) => {
|
||||
const page = await openPopup(context, extensionId);
|
||||
|
||||
await setEditorDomains(page, ['videos.example']);
|
||||
await page.click('#blacklistSave');
|
||||
await expect(page.locator('#blacklistStatus')).toHaveAttribute('data-state', 'success');
|
||||
expect((await readStorage(page, [OVERRIDES_KEY]))[OVERRIDES_KEY]).toBeDefined();
|
||||
|
||||
await page.click('#blacklistReset');
|
||||
await expect(page.locator('#blacklistStatus')).toHaveAttribute('data-state', 'success');
|
||||
|
||||
const stored = await readStorage(page, [OVERRIDES_KEY, LEGACY_KEY]);
|
||||
expect(stored[OVERRIDES_KEY]).toBeUndefined();
|
||||
expect(stored[LEGACY_KEY]).toBeUndefined();
|
||||
expect(await readEditorDomains(page)).toEqual(BLACKLIST_DOMAINS);
|
||||
});
|
||||
|
||||
test('migrates a pre-v3.1.0 snapshot and delivers defaults it never had', async ({ context, extensionId }) => {
|
||||
// A snapshot saved by an older version: the user removed one default and
|
||||
// added one of their own. 'reddit.com' stands in for a default that shipped
|
||||
// after they saved, so their frozen snapshot simply does not contain it.
|
||||
const snapshot = BLACKLIST_DOMAINS
|
||||
.filter(domain => domain !== 'imgur.com' && domain !== 'reddit.com')
|
||||
.concat(['videos.example']);
|
||||
|
||||
const seed = await openPopup(context, extensionId);
|
||||
await writeStorage(seed, { [LEGACY_KEY]: snapshot });
|
||||
await seed.close();
|
||||
|
||||
const page = await openPopup(context, extensionId);
|
||||
|
||||
const stored = await readStorage(page, [OVERRIDES_KEY, LEGACY_KEY]);
|
||||
expect(stored[LEGACY_KEY], 'the legacy key is cleaned up').toBeUndefined();
|
||||
expect(stored[OVERRIDES_KEY].removedDefaults).toEqual(['reddit.com', 'imgur.com']);
|
||||
expect(stored[OVERRIDES_KEY].addedDomains).toEqual(['videos.example']);
|
||||
|
||||
const domains = await readEditorDomains(page);
|
||||
expect(domains, 'their own entry survives migration').toContain('videos.example');
|
||||
expect(domains, 'their removals survive migration').not.toContain('imgur.com');
|
||||
});
|
||||
|
||||
test('the tab list honours a removed default and an added domain', async ({ context, extensionId, baseURL }) => {
|
||||
const page = await openPopup(context, extensionId);
|
||||
|
||||
// youtube.com is not a shipped default, so it is visible by default. Adding
|
||||
// it must hide it; the fixture host must stay visible either way.
|
||||
await setEditorDomains(page, [...BLACKLIST_DOMAINS, 'youtube.com']);
|
||||
await page.click('#blacklistSave');
|
||||
await expect(page.locator('#blacklistStatus')).toHaveAttribute('data-state', 'success');
|
||||
|
||||
const filtered = await page.evaluate(async ({ fixture }) => {
|
||||
const { getEffectiveBlacklistDomains, isUrlBlacklisted } = await import('./shared/blacklist.js');
|
||||
const overrides = (await chrome.storage.local.get(['blacklistOverrides'])).blacklistOverrides;
|
||||
const domains = getEffectiveBlacklistDomains(overrides);
|
||||
return {
|
||||
youtube: isUrlBlacklisted('https://www.youtube.com/watch?v=x', domains),
|
||||
fixtureHost: isUrlBlacklisted(fixture, domains),
|
||||
drive: isUrlBlacklisted('https://drive.google.com/file/d/x/view', domains)
|
||||
};
|
||||
}, { fixture: `${baseURL}/pages/simple-player.html` });
|
||||
|
||||
expect(filtered.youtube, 'an added domain is filtered').toBe(true);
|
||||
expect(filtered.fixtureHost, 'an unrelated host stays visible').toBe(false);
|
||||
expect(filtered.drive, 'drive keeps its player-path exception').toBe(false);
|
||||
});
|
||||
Reference in New Issue
Block a user