326 lines
17 KiB
JavaScript
326 lines
17 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { resolve } from 'node:path';
|
|
import { mkdtemp, cp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { build, preview } from 'vite';
|
|
import { chromium } from 'playwright';
|
|
|
|
const loadModule = async (entry) => {
|
|
const result = await build({
|
|
configFile: false,
|
|
logLevel: 'silent',
|
|
build: {
|
|
write: false,
|
|
minify: false,
|
|
lib: { entry: resolve(entry), formats: ['es'] },
|
|
},
|
|
});
|
|
const output = Array.isArray(result) ? result[0] : result;
|
|
const code = output.output.find(chunk => chunk.type === 'chunk' && chunk.isEntry).code;
|
|
return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
|
|
};
|
|
|
|
const storage = new Map();
|
|
const chromeStorage = new Map();
|
|
let failWrite = null;
|
|
let maximumStorageBytes = Infinity;
|
|
let chromeReads = 0;
|
|
globalThis.localStorage = {
|
|
getItem: key => storage.get(key) ?? null,
|
|
setItem: (key, value) => {
|
|
if (key === failWrite) {
|
|
failWrite = null;
|
|
throw new Error('Quota exceeded');
|
|
}
|
|
const next = new Map(storage).set(key, value);
|
|
if ([...next.values()].reduce((bytes, entry) => bytes + entry.length, 0) > maximumStorageBytes) throw new Error('Quota exceeded');
|
|
storage.set(key, value);
|
|
},
|
|
removeItem: key => storage.delete(key),
|
|
};
|
|
globalThis.window = { chrome: { storage: { local: {
|
|
get: (keys, callback) => {
|
|
chromeReads++;
|
|
callback(Object.fromEntries(keys.map(key => [key, chromeStorage.get(key)])));
|
|
},
|
|
set: (values, callback) => {
|
|
Object.entries(values).forEach(([key, value]) => chromeStorage.set(key, value));
|
|
callback();
|
|
},
|
|
remove: (key, callback) => { chromeStorage.delete(key); callback(); },
|
|
} } } };
|
|
globalThis.FileReader = class {
|
|
readAsDataURL(blob) {
|
|
blob.arrayBuffer().then(buffer => {
|
|
this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}`;
|
|
this.onloadend?.();
|
|
this.onload?.();
|
|
});
|
|
}
|
|
};
|
|
const [{ ConfigurationService, DEFAULT_CONFIG, normalizeConfig, normalizeCategories }, icons, { default: ping }] = await Promise.all([
|
|
loadModule('components/services/ConfigurationService.ts'),
|
|
loadModule('components/utils/iconService.ts'),
|
|
loadModule('components/utils/jsping.ts'),
|
|
]);
|
|
const file = value => new File([JSON.stringify(value)], 'config.json', { type: 'application/json' });
|
|
const iconUrl = name => `https://www.google.com/s2/favicons?domain=${name}.com&sz=128`;
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
await test('malformed stored data recovers without shared defaults or unsafe polling rates', () => {
|
|
const config = normalizeConfig({ currentWallpapers: [null, 'Beach', 'Beach'], serverWidget: { pingFrequency: -1, servers: [null, {}, { id: '1', name: 'Server', address: 'localhost' }] }, clock: { size: 'unknown' }, wallpaperBlur: Infinity });
|
|
assert.deepEqual(config.currentWallpapers, ['Beach']);
|
|
assert.equal(config.serverWidget.pingFrequency, 5);
|
|
assert.equal(config.serverWidget.servers.length, 1);
|
|
assert.equal(config.clock.size, 'medium');
|
|
assert.equal(config.wallpaperBlur, 0);
|
|
const defaults = normalizeConfig(null);
|
|
defaults.clock.enabled = false;
|
|
defaults.currentWallpapers.length = 0;
|
|
assert.equal(DEFAULT_CONFIG.clock.enabled, true);
|
|
assert.deepEqual(DEFAULT_CONFIG.currentWallpapers, ['Beach']);
|
|
storage.set('categories', 'null');
|
|
assert.equal(ConfigurationService.loadCategories()[0].name, 'Search');
|
|
assert.deepEqual(normalizeCategories([]), []);
|
|
assert.deepEqual(normalizeCategories([null, { id: 'c', name: 'Group', websites: [null, { id: 'w', name: 'Site', url: 'https://example.com', categoryId: 'wrong' }] }])[0].websites[0].categoryId, 'c');
|
|
storage.set('userWallpapers', '{}');
|
|
assert.deepEqual(ConfigurationService.loadUserWallpapers(), []);
|
|
});
|
|
|
|
await test('imports validate before writing, roll back failures, and preserve omitted settings', async () => {
|
|
await assert.rejects(ConfigurationService.importConfig({ size: 10 * 1024 * 1024 + 1, text: () => assert.fail('Oversized files must not be read into memory') }), /10MB/);
|
|
storage.clear();
|
|
storage.set('config', JSON.stringify({ title: 'Original' }));
|
|
storage.set('categories', '[]');
|
|
await assert.rejects(ConfigurationService.importConfig(file({ config: { title: 'Bad' }, categories: {} })), /Invalid categories/);
|
|
assert.equal(JSON.parse(storage.get('config')).title, 'Original');
|
|
failWrite = 'categories';
|
|
await assert.rejects(ConfigurationService.importConfig(file({ config: { title: 'Bad' }, categories: [] })), /Quota/);
|
|
assert.equal(JSON.parse(storage.get('config')).title, 'Original');
|
|
const partial = await ConfigurationService.importConfig(file({ categories: '[]' }));
|
|
assert.equal(partial.config.title, 'Original');
|
|
const legacy = await ConfigurationService.importConfig(file({ config: JSON.stringify({ title: 'Legacy', clock: { enabled: false } }) }));
|
|
assert.equal(legacy.config.title, 'Legacy');
|
|
assert.equal(legacy.config.clock.enabled, false);
|
|
assert.equal(legacy.config.clock.size, 'medium');
|
|
storage.set('config', JSON.stringify(normalizeConfig({ title: 'Original'.repeat(50) })));
|
|
storage.set('userWallpapers', '[]');
|
|
const previous = new Map(storage);
|
|
maximumStorageBytes = [...storage.values()].reduce((bytes, value) => bytes + value.length, 0) + 32;
|
|
try {
|
|
await assert.rejects(ConfigurationService.importConfig(file({ config: { title: 'Small' }, categories: [{ id: 'c', name: 'Group'.repeat(40), websites: [] }], userWallpapers: [{ name: 'Large', url: `https://example.com/${'image'.repeat(200)}` }] })), /Quota/);
|
|
assert.deepEqual(storage, previous);
|
|
} finally { maximumStorageBytes = Infinity; }
|
|
});
|
|
|
|
await test('wallpaper collisions and non-image uploads cannot overwrite storage', async () => {
|
|
await assert.rejects(ConfigurationService.addWallpaper('Beach', 'https://example.com/image.png'), /already exists/);
|
|
await assert.rejects(ConfigurationService.addWallpaper('vision-start:icon:reserved', 'https://example.com/image.png'), /reserved/);
|
|
await assert.rejects(ConfigurationService.addWallpaperFile(new File(['text'], 'image.png', { type: 'text/plain' })), /image file/);
|
|
});
|
|
|
|
await test('server probes reject network failures, timeouts, and cancellation', async () => {
|
|
try {
|
|
globalThis.fetch = async (_url, options) => {
|
|
assert.equal(options.method, 'HEAD');
|
|
assert.equal(options.credentials, 'omit');
|
|
return new Response(null);
|
|
};
|
|
assert.equal(typeof await ping('localhost:8080'), 'number');
|
|
globalThis.fetch = async () => { throw new Error('Network failure'); };
|
|
await assert.rejects(ping('https://offline.example'), /Network failure/);
|
|
await assert.rejects(ping('ftp://example.com'), /HTTP or HTTPS/);
|
|
globalThis.fetch = async (_url, { signal }) => new Promise((_resolve, reject) => {
|
|
if (signal.aborted) reject(new Error('Aborted'));
|
|
else signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
|
|
});
|
|
const controller = new AbortController();
|
|
const request = ping('https://slow.example', 1, controller.signal);
|
|
controller.abort();
|
|
await assert.rejects(request, /Aborted/);
|
|
const originalTimeout = globalThis.setTimeout;
|
|
globalThis.setTimeout = callback => { queueMicrotask(callback); return 0; };
|
|
try { await assert.rejects(ping('https://slow.example'), /Aborted/); }
|
|
finally { globalThis.setTimeout = originalTimeout; }
|
|
} finally { globalThis.fetch = originalFetch; }
|
|
});
|
|
|
|
await test('icon requests deduplicate, cap concurrency, reject oversized streams, and evict by bytes', async () => {
|
|
let active = 0;
|
|
let maximum = 0;
|
|
let requests = 0;
|
|
try {
|
|
globalThis.fetch = async () => {
|
|
requests++;
|
|
maximum = Math.max(maximum, ++active);
|
|
await new Promise(resolve => setTimeout(resolve, 5));
|
|
active--;
|
|
return new Response(new Uint8Array(200 * 1024), { headers: { 'content-type': 'image/png' } });
|
|
};
|
|
const results = await Promise.all(Array.from({ length: 12 }, (_, index) => icons.cacheWebsiteIcon(iconUrl(`site${index}`))));
|
|
assert(results.every(result => result?.startsWith('data:image/png')));
|
|
assert(maximum <= 4);
|
|
const reads = chromeReads;
|
|
await icons.getCachedWebsiteIcon(iconUrl('site0'));
|
|
assert(chromeReads > reads, 'The oldest large icon must leave the extra memory cache');
|
|
const before = requests;
|
|
await Promise.all([icons.cacheWebsiteIcon(iconUrl('shared')), icons.cacheWebsiteIcon(iconUrl('shared'))]);
|
|
assert.equal(requests - before, 1);
|
|
let cancelled = false;
|
|
globalThis.fetch = async () => new Response(new ReadableStream({
|
|
pull(controller) { controller.enqueue(new Uint8Array(128 * 1024)); },
|
|
cancel() { cancelled = true; },
|
|
}), { headers: { 'content-type': 'image/png' } });
|
|
assert.equal(await icons.cacheWebsiteIcon(iconUrl('oversized')), null);
|
|
assert.equal(cancelled, true);
|
|
} finally { globalThis.fetch = originalFetch; }
|
|
});
|
|
|
|
await test('production UI survives malformed data, distinguishes server status, and releases modal memory', async () => {
|
|
const server = await preview({ logLevel: 'silent', preview: { host: '127.0.0.1', port: 0, strictPort: true } });
|
|
const browser = await chromium.launch();
|
|
try {
|
|
const address = server.httpServer.address();
|
|
const baseUrl = `http://127.0.0.1:${address.port}`;
|
|
const context = await browser.newContext();
|
|
const errors = [];
|
|
const page = await context.newPage();
|
|
await page.clock.install();
|
|
page.on('pageerror', error => errors.push(error.message));
|
|
await context.addInitScript(() => {
|
|
localStorage.setItem('config', JSON.stringify({ currentWallpapers: [], serverWidget: { enabled: true, pingFrequency: 5, servers: [null, { id: 'on', name: 'Online', address: 'https://online.example' }, { id: 'off', name: 'Offline', address: 'https://offline.example' }] } }));
|
|
localStorage.setItem('categories', JSON.stringify([null, { id: 'c', name: 'Group', websites: [{ id: 'a', name: 'Alpha', url: 'https://example.com', icon: '' }, { id: 'b', name: 'Beta', url: 'https://example.org', icon: '' }] }]));
|
|
localStorage.setItem('userWallpapers', 'null');
|
|
});
|
|
let onlineProbes = 0;
|
|
await context.route('https://online.example/**', route => {
|
|
onlineProbes++;
|
|
return route.fulfill({ status: 200, body: '' });
|
|
});
|
|
await context.route('https://offline.example/**', route => route.abort('connectionfailed'));
|
|
const metadata = Object.fromEntries(Array.from({ length: 4000 }, (_, index) => [`service-${index}`, { base: 'svg', colors: { blue: `service-${index}-blue` }, aliases: ['unused'.repeat(100)], update: { author: { name: 'unused'.repeat(100) } } }]));
|
|
let metadataLoads = 0;
|
|
let delayMetadata = false;
|
|
let releaseMetadata = () => {};
|
|
await context.route('**/icon-metadata.json', async route => {
|
|
metadataLoads++;
|
|
if (delayMetadata) await new Promise(resolve => { releaseMetadata = resolve; });
|
|
return route.fulfill({ json: metadata });
|
|
});
|
|
await context.route('https://cdn.jsdelivr.net/**', route => route.abort());
|
|
await page.goto(baseUrl);
|
|
await page.getByRole('link', { name: 'Open Alpha' }).waitFor();
|
|
await page.waitForFunction(() => [...document.querySelectorAll('.liquid-status-dot')].some(dot => dot.classList.contains('bg-green-400')) && [...document.querySelectorAll('.liquid-status-dot')].some(dot => dot.classList.contains('bg-red-400')));
|
|
const beforeHidden = onlineProbes;
|
|
const clockBeforeHidden = await page.locator('.liquid-clock-text').textContent();
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
|
|
document.dispatchEvent(new Event('visibilitychange'));
|
|
});
|
|
await page.clock.fastForward(120_000);
|
|
assert.equal(onlineProbes, beforeHidden);
|
|
assert.equal(await page.locator('.liquid-clock-text').textContent(), clockBeforeHidden);
|
|
const resumedProbe = page.waitForRequest('https://online.example/');
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
|
document.dispatchEvent(new Event('visibilitychange'));
|
|
});
|
|
await resumedProbe;
|
|
await page.waitForFunction(before => document.querySelector('.liquid-clock-text').textContent !== before, clockBeforeHidden);
|
|
await page.waitForFunction(() => [...document.querySelectorAll('.liquid-status-dot')].some(dot => dot.classList.contains('bg-green-400')));
|
|
const downloaded = [];
|
|
page.on('request', request => downloaded.push(request.url()));
|
|
await page.getByRole('button', { name: 'Open configuration' }).click();
|
|
await page.getByRole('dialog').waitFor();
|
|
assert(!downloaded.some(url => url.includes('ServerWidgetTab.js')));
|
|
await page.getByRole('button', { name: 'Server Widget', exact: true }).click();
|
|
await page.getByPlaceholder('Server Name').waitFor();
|
|
assert(downloaded.some(url => url.includes('ServerWidgetTab.js')));
|
|
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
|
await page.getByRole('dialog').waitFor({ state: 'hidden' });
|
|
await page.getByRole('button', { name: 'Edit page' }).click();
|
|
await page.getByRole('button', { name: 'Move Beta left' }).click();
|
|
assert.deepEqual(await page.locator('a[aria-label$="edit controls"]').evaluateAll(links => links.map(link => [...link.querySelectorAll('span')].at(-1).textContent)), ['Beta', 'Alpha']);
|
|
const openPicker = async () => {
|
|
await page.getByRole('button', { name: 'Edit Alpha', exact: true }).click();
|
|
const input = page.getByPlaceholder('Icon URL or name');
|
|
await input.fill('service');
|
|
await input.focus();
|
|
await page.locator('.liquid-dropdown-list').waitFor();
|
|
assert.equal(await page.locator('.liquid-dropdown-list img').count(), 50);
|
|
await page.getByRole('button', { name: 'Close', exact: true }).click();
|
|
await page.getByPlaceholder('Icon URL or name').waitFor({ state: 'hidden' });
|
|
};
|
|
await openPicker();
|
|
const session = await context.newCDPSession(page);
|
|
const heap = async () => {
|
|
await session.send('HeapProfiler.collectGarbage');
|
|
return (await session.send('Runtime.getHeapUsage')).usedSize;
|
|
};
|
|
const before = await heap();
|
|
for (let cycle = 0; cycle < 15; cycle++) await openPicker();
|
|
const growth = (await heap()) - before;
|
|
assert(growth < 2 * 1024 * 1024, `Retained JS heap grew by ${growth} bytes`);
|
|
assert.equal(metadataLoads, 16);
|
|
await page.evaluate(() => {
|
|
const original = window.fetch;
|
|
window.fetch = (url, options) => {
|
|
if (String(url).endsWith('/icon-metadata.json')) window.metadataSignal = options.signal;
|
|
return original(url, options);
|
|
};
|
|
});
|
|
delayMetadata = true;
|
|
await page.getByRole('button', { name: 'Edit Alpha', exact: true }).click();
|
|
const pendingMetadata = page.waitForRequest('**/icon-metadata.json');
|
|
await page.getByPlaceholder('Icon URL or name').focus();
|
|
await pendingMetadata;
|
|
await page.getByRole('button', { name: 'Close', exact: true }).click();
|
|
await page.waitForFunction(() => window.metadataSignal?.aborted === true);
|
|
releaseMetadata();
|
|
delayMetadata = false;
|
|
await openPicker();
|
|
assert.equal(metadataLoads, 18);
|
|
assert.deepEqual(errors, []);
|
|
console.log(`Retained JS heap change after 15 picker cycles: ${Math.round(growth / 1024)} KiB`);
|
|
await context.close();
|
|
} finally {
|
|
await browser.close();
|
|
await new Promise(resolve => server.httpServer.close(resolve));
|
|
}
|
|
});
|
|
|
|
await test('unpacked MV3 extension loads its new-tab page with working chrome storage', async () => {
|
|
const temporary = await mkdtemp(resolve(tmpdir(), 'vision-start-check-'));
|
|
const extension = resolve(temporary, 'extension');
|
|
let context;
|
|
try {
|
|
await cp(resolve('dist'), extension, { recursive: true });
|
|
await cp(resolve('manifest.json'), resolve(extension, 'manifest.json'));
|
|
await cp(resolve('extension'), resolve(extension, 'extension'), { recursive: true });
|
|
context = await chromium.launchPersistentContext(resolve(temporary, 'profile'), {
|
|
channel: 'chromium',
|
|
headless: true,
|
|
args: [`--disable-extensions-except=${extension}`, `--load-extension=${extension}`],
|
|
});
|
|
await context.route(/^https?:/, route => route.abort());
|
|
const page = await context.newPage();
|
|
const errors = [];
|
|
page.on('pageerror', error => errors.push(error.message));
|
|
await page.goto('chrome://newtab');
|
|
await page.getByRole('button', { name: 'Open configuration' }).waitFor();
|
|
const origin = await page.evaluate(() => document.location.origin);
|
|
assert(origin.startsWith('chrome-extension://'), `Unexpected new-tab origin: ${origin}`);
|
|
assert.equal(await page.evaluate(async () => {
|
|
await chrome.storage.local.set({ regression: 'working' });
|
|
return (await chrome.storage.local.get('regression')).regression;
|
|
}), 'working');
|
|
await page.getByRole('button', { name: 'Open configuration' }).click();
|
|
await page.getByRole('dialog').waitFor();
|
|
assert.deepEqual(errors, []);
|
|
} finally {
|
|
await context?.close();
|
|
await rm(temporary, { recursive: true, force: true });
|
|
}
|
|
});
|