136 lines
7.7 KiB
TypeScript
136 lines
7.7 KiB
TypeScript
// "Share my setup" (panel/setupModel.ts): what a shared setup can carry (the look, the mode and the
|
|
// non-default flags, nothing personal), that a link and a file read back the same, that anything
|
|
// unknown or malformed is dropped or refused rather than guessed, and that importing changes exactly
|
|
// what the plan lists.
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import type { FeatureTable } from '../../runtime/src/featureTable.ts';
|
|
import { tableDefaults } from '../../runtime/src/featureTable.ts';
|
|
import {
|
|
SETUP_FORMAT, SETUP_LINK, SETUP_MAX_BYTES, applySetup, makeSetup, readSetup, setupFile, setupLink, setupPlan, type SetupState,
|
|
} from '../src/panel/setupModel.ts';
|
|
|
|
const TABLE: FeatureTable = {
|
|
areas: [
|
|
{ id: 'navigation', name: 'Navigation+', description: 'd', owner: 'wb-navigation-plus.plugin.js', namespace: 'navigation-plus' },
|
|
{ id: 'reader', name: 'Reader+', description: 'd', owner: 'wb-reader-plus.plugin.js', namespace: 'reader-plus' },
|
|
],
|
|
features: [
|
|
{ id: 'fixScroll', area: null, group: 'fixes', default: true, backing: 'app' },
|
|
{ id: 'hideTrends', area: 'navigation', default: false, backing: 'module' },
|
|
{ id: 'navigation.focusMode', area: 'navigation', default: false, backing: 'module' },
|
|
{ id: 'hideAds', area: 'reader', default: true, backing: 'app' },
|
|
],
|
|
modes: [
|
|
{ id: 'basic', name: 'b', description: 'b', flags: { 'area.navigation': false, 'area.reader': false } },
|
|
{ id: 'advanced', name: 'a', description: 'a', recommended: true, flags: { 'navigation.focusMode': true } },
|
|
],
|
|
};
|
|
const DEFAULTS = tableDefaults(TABLE);
|
|
const MINE: SetupState = {
|
|
theme: { preset: 'nord', accent: '#88c0d0', icons: 'pixel' },
|
|
mode: 'advanced',
|
|
flags: { ...DEFAULTS, 'navigation.focusMode': true, hideTrends: true, hideAds: false },
|
|
};
|
|
|
|
test('a setup carries the look, the mode and the non-default flags, and nothing else', () => {
|
|
const setup = makeSetup('x', TABLE, MINE);
|
|
assert.deepEqual(setup, {
|
|
format: SETUP_FORMAT, version: 1, app: 'x',
|
|
flags: { hideTrends: true, 'navigation.focusMode': true, hideAds: false },
|
|
theme: { preset: 'nord', accent: '#88c0d0', icons: 'pixel' },
|
|
mode: 'advanced',
|
|
});
|
|
// Whatever else the state object happens to hold never reaches the file.
|
|
const leaky = { ...MINE, profiles: ['@someone'], notes: { a: 'b' }, drafts: ['text'] } as SetupState;
|
|
assert.deepEqual(Object.keys(makeSetup('x', TABLE, leaky)).sort(), ['app', 'flags', 'format', 'mode', 'theme', 'version']);
|
|
// A mode the table does not know is saved as none; an app without modes saves no mode at all.
|
|
assert.equal(makeSetup('x', TABLE, { ...MINE, mode: 'gone' }).mode, null);
|
|
assert.equal('mode' in makeSetup('x', { ...TABLE, modes: [] }, MINE), false);
|
|
});
|
|
|
|
test('a link and a file read back to the same setup', () => {
|
|
const setup = makeSetup('x', TABLE, MINE);
|
|
const link = setupLink(setup);
|
|
assert.ok(link.startsWith(`${SETUP_LINK}?app=x&d=`));
|
|
assert.match(link.slice(link.indexOf('d=') + 2), /^[A-Za-z0-9_-]+$/, 'URL-safe, no padding');
|
|
for (const input of [link, setupFile(setup), ` ${link}\n`]) {
|
|
const read = readSetup(input, 'x', TABLE);
|
|
assert.ok(read.ok, input);
|
|
assert.deepEqual(read.setup, setup);
|
|
assert.deepEqual(read.dropped, []);
|
|
}
|
|
// An empty setup (everything at its default) is still a valid link.
|
|
const empty = readSetup(setupLink({ format: SETUP_FORMAT, version: 1, app: 'x', flags: {} }), 'x', TABLE);
|
|
assert.ok(empty.ok);
|
|
assert.deepEqual(empty.setup.flags, {});
|
|
});
|
|
|
|
test('another app, another format, a newer version or an oversized paste is refused whole', () => {
|
|
const setup = makeSetup('kick', TABLE, MINE);
|
|
assert.deepEqual(readSetup(setupLink(setup), 'x', TABLE), { ok: false, error: 'app', app: 'kick' });
|
|
assert.deepEqual(readSetup('{"format":"waybetter-backup"}', 'x', TABLE), { ok: false, error: 'format' });
|
|
assert.deepEqual(readSetup(JSON.stringify({ ...makeSetup('x', TABLE, MINE), version: 2 }), 'x', TABLE), { ok: false, error: 'version' });
|
|
assert.deepEqual(readSetup('not json', 'x', TABLE), { ok: false, error: 'format' });
|
|
assert.deepEqual(readSetup(`${SETUP_LINK}?app=x&d=%%%`, 'x', TABLE), { ok: false, error: 'format' });
|
|
assert.deepEqual(readSetup(`${SETUP_LINK}?app=x&d=bm90IGpzb24`, 'x', TABLE), { ok: false, error: 'format' });
|
|
assert.deepEqual(readSetup('x'.repeat(SETUP_MAX_BYTES + 1), 'x', TABLE), { ok: false, error: 'size' });
|
|
assert.deepEqual(readSetup('[]', 'x', TABLE), { ok: false, error: 'format' });
|
|
});
|
|
|
|
test('unknown flags, a foreign mode and a malformed theme are dropped and named, never guessed', () => {
|
|
const raw = {
|
|
format: SETUP_FORMAT, version: 1, app: 'x',
|
|
flags: { hideTrends: true, 'p.someone.plugin': true, hideAds: 'yes', 'area.chat': false },
|
|
theme: { preset: '<script>', accent: 'red' },
|
|
mode: 'power',
|
|
notes: { '@someone': 'secret' },
|
|
};
|
|
const read = readSetup(JSON.stringify(raw), 'x', TABLE);
|
|
assert.ok(read.ok);
|
|
assert.deepEqual(read.setup, { format: SETUP_FORMAT, version: 1, app: 'x', flags: { hideTrends: true } });
|
|
assert.deepEqual(read.dropped.sort(), ['area.chat', 'hideAds', 'mode', 'p.someone.plugin', 'theme']);
|
|
assert.equal('notes' in read.setup, false, 'fields a setup does not have are not carried over');
|
|
});
|
|
|
|
test('the plan lists exactly what changes; applying it changes nothing else', () => {
|
|
const setup = makeSetup('x', TABLE, MINE);
|
|
const now: SetupState = { theme: { preset: 'x', accent: null }, mode: null, flags: DEFAULTS };
|
|
const themes = new Set(['x', 'nord']);
|
|
const packs = new Set(['pixel']);
|
|
const plan = setupPlan(TABLE, now, setup, themes, packs);
|
|
assert.deepEqual(plan, {
|
|
on: ['hideTrends', 'navigation.focusMode'], off: ['hideAds'], same: false,
|
|
theme: { from: 'x', to: 'nord' }, accent: { from: null, to: '#88c0d0' }, icons: { from: undefined, to: 'pixel' },
|
|
mode: { from: null, to: 'advanced' },
|
|
});
|
|
const after = applySetup(TABLE, now, plan);
|
|
assert.deepEqual(after, MINE);
|
|
// Applied twice: nothing more to do.
|
|
assert.equal(setupPlan(TABLE, after, setup, themes, packs).same, true);
|
|
});
|
|
|
|
test('a theme or icon pack that is not installed here is named and left alone', () => {
|
|
const setup = makeSetup('x', TABLE, { ...MINE, theme: { preset: 'my-own-theme', accent: '#112233', icons: 'someone-pack' } });
|
|
const now: SetupState = { theme: { preset: 'x', accent: null }, mode: 'advanced', flags: MINE.flags };
|
|
const plan = setupPlan(TABLE, now, setup, new Set(['x']), new Set());
|
|
assert.equal(plan.missingTheme, 'my-own-theme');
|
|
assert.equal(plan.missingIcons, 'someone-pack');
|
|
assert.equal(plan.theme, undefined);
|
|
assert.equal(plan.accent, undefined, 'an accent belongs to its theme');
|
|
assert.equal(plan.icons, undefined);
|
|
assert.equal(plan.same, true, 'nothing else differs, so nothing would change');
|
|
assert.deepEqual(applySetup(TABLE, now, plan), now);
|
|
// "site" (the site's own icons) is a choice every app has.
|
|
const site = setupPlan(TABLE, now, makeSetup('x', TABLE, { ...MINE, theme: { preset: 'x', accent: null, icons: 'site' } }), new Set(['x']), new Set());
|
|
assert.deepEqual(site.icons, { from: undefined, to: 'site' });
|
|
});
|
|
|
|
test('a setup without a theme or mode leaves the look and the mode alone', () => {
|
|
const setup = readSetup(JSON.stringify({ format: SETUP_FORMAT, version: 1, app: 'x', flags: { hideTrends: true } }), 'x', TABLE);
|
|
assert.ok(setup.ok);
|
|
const now: SetupState = { theme: { preset: 'nord', accent: '#88c0d0' }, mode: 'basic', flags: DEFAULTS };
|
|
const plan = setupPlan(TABLE, now, setup.setup, new Set(['nord']), new Set());
|
|
assert.deepEqual(plan, { on: ['hideTrends'], off: [], same: false });
|
|
assert.deepEqual(applySetup(TABLE, now, plan), { ...now, flags: { ...DEFAULTS, hideTrends: true } });
|
|
});
|