Merge Luma launcher fork into monorepo
Move trampoline files (boot-hook, watcher, overlay) into trampoline/ subdirectory. Add upstream Luma v2.3.1 source at repo root with our additions: - Page::Ha (4th nav tab) with placeholder empty state - HA tab config schema and default entity mapping (ha-home/) - Nav keys renamed: "home" → "start", new "ha" tab between Apps and Settings - All state.rs/view.rs/main.rs/tests.rs updated for 4-tab navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc3ca0f3b9
commit
cbea5a322f
204 files changed
+25056
-65
No files matched your search
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import sharp from 'sharp';
|
||||
import { sources, prepareSvg } from '../tools/brand-assets.mjs';
|
||||
|
||||
test('every expanded brand has standalone, renderable vector artwork and pinned provenance', async () => {
|
||||
assert.equal(sources.length, 27);
|
||||
for (const [name, url, hash] of sources) {
|
||||
assert.match(hash, /^[0-9a-f]{64}$/, name);
|
||||
const svg = await fs.readFile(new URL(`../assets/logos/${name}.svg`, import.meta.url), 'utf8');
|
||||
assert.ok(svg.includes(url), `Missing source attribution for ${name}`);
|
||||
assert.doesNotMatch(svg, /<image\b|<text\b|=\{/, name);
|
||||
const { data, info } = await sharp(Buffer.from(svg))
|
||||
.resize(256, 128, { fit: 'inside' })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
assert.ok(
|
||||
data.some((value, index) => index % info.channels === 3 && value > 0),
|
||||
`Empty logo: ${name}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('source preparation preserves paths and removes component syntax and redundant clipping', () => {
|
||||
const svg = prepareSvg(
|
||||
'coupang-play',
|
||||
'export default () => (<svg width={115} height={22} style={{ width: 115 }}><g clip-path="url(#bounds)"><path d="M0 0L1 1L1 0Z"/></g><defs><clipPath id="bounds"/></defs></svg>);',
|
||||
'https://example.com/source',
|
||||
);
|
||||
assert.match(svg, /width="115"/);
|
||||
assert.match(svg, /d="M0 0L1 1L1 0Z"/);
|
||||
assert.doesNotMatch(svg, /export|style=|clip-path|<defs>|=\{/);
|
||||
assert.doesNotMatch(
|
||||
prepareSvg('hulu', '<?xml version="1.0"?><svg/>', 'https://example.com/source'),
|
||||
/<\?xml/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// Exercise every curated logo with the native desktop renderer. No TV, network
|
||||
// service or weather API is used. The contact sheet is a manual design preview.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
const out = path.join(root, '.build');
|
||||
await fs.mkdir(out, { recursive: true });
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, { cwd: root, stdio: 'inherit', timeout: 120000 });
|
||||
if (result.error) throw result.error;
|
||||
assert.equal(result.status, 0, `${command} failed`);
|
||||
}
|
||||
run('cargo', ['build', '--locked', '--package', 'luma', '--no-default-features']);
|
||||
const binary = path.resolve(root, process.env.CARGO_TARGET_DIR ?? 'target', 'debug/luma');
|
||||
const scratch = await fs.mkdtemp(path.join(out, 'brand-gallery-'));
|
||||
try {
|
||||
const preferences = path.join(scratch, 'preferences.json');
|
||||
const dump = path.join(scratch, 'state.json');
|
||||
const diagnostics = path.join(scratch, 'render.json');
|
||||
const screenshot = path.join(scratch, 'card.png');
|
||||
await fs.writeFile(preferences, JSON.stringify({ reduced_motion: true, show_weather: false }));
|
||||
run(binary, [
|
||||
'--preferences',
|
||||
preferences,
|
||||
'--script',
|
||||
'apps',
|
||||
'--frames',
|
||||
'18',
|
||||
'--dump-state',
|
||||
dump,
|
||||
]);
|
||||
const { inventory } = JSON.parse(await fs.readFile(dump, 'utf8'));
|
||||
const logos = (await fs.readdir(path.join(root, 'assets/logos')))
|
||||
.filter((file) => file.endsWith('.svg'))
|
||||
.map((file) => path.parse(file).name)
|
||||
.sort();
|
||||
const cards = [];
|
||||
for (const [index, logo] of logos.entries()) {
|
||||
const app = inventory.find((app) => app.brand === logo);
|
||||
assert.ok(app, `Desktop demo is missing ${logo}`);
|
||||
run(binary, [
|
||||
'--preferences',
|
||||
preferences,
|
||||
'--script',
|
||||
`apps,type:${app.title},back`,
|
||||
'--frames',
|
||||
'18',
|
||||
'--screenshot',
|
||||
screenshot,
|
||||
'--dump-state',
|
||||
dump,
|
||||
'--diagnostics',
|
||||
diagnostics,
|
||||
]);
|
||||
const state = JSON.parse(await fs.readFile(dump, 'utf8'));
|
||||
const report = JSON.parse(await fs.readFile(diagnostics, 'utf8'));
|
||||
assert.ok(state.apps.includes(app.id), `${logo} must be searchable in the demo`);
|
||||
assert.equal(report.capture.missing_images, 0, `Missing artwork for ${logo}`);
|
||||
assert.equal(report.capture.glyph_failures, 0, `Invalid caption for ${logo}`);
|
||||
assert.ok(report.capture.vector_meshes > 0);
|
||||
const input = await sharp(screenshot)
|
||||
.extract({ left: 78, top: 264, width: 282, height: 238 })
|
||||
.png()
|
||||
.toBuffer();
|
||||
cards.push({ input, left: (index % 6) * 282, top: Math.floor(index / 6) * 238 });
|
||||
}
|
||||
await sharp({
|
||||
create: {
|
||||
width: 6 * 282,
|
||||
height: Math.ceil(cards.length / 6) * 238,
|
||||
channels: 4,
|
||||
background: '#11151b',
|
||||
},
|
||||
})
|
||||
.composite(cards)
|
||||
.png()
|
||||
.toFile(path.join(out, 'brands-gallery.png'));
|
||||
console.log(
|
||||
`All ${logos.length} SVG-backed demo apps rendered. Contact sheet: .build/brands-gallery.png`,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(scratch, { recursive: true, force: true });
|
||||
}
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{ "app": { "id": "coupangplay", "visible": true }, "include": true },
|
||||
{ "app": { "id": "org.litefin.app" }, "include": true },
|
||||
{ "app": { "id": "com.example.hidden", "visible": false }, "include": false },
|
||||
{ "app": { "id": "com.example.hidden-string", "visible": "false" }, "include": false },
|
||||
{ "app": { "id": "com.example.string-true", "visible": "true" }, "include": true },
|
||||
{ "app": { "id": "com.example.null-visible", "visible": null }, "include": true },
|
||||
{ "app": { "id": "com.webos.app.igallery", "visible": true }, "include": true },
|
||||
{ "app": { "id": "com.webos.app.roomconnect", "visible": true }, "include": true },
|
||||
{ "app": { "id": "com.webos.app.hidden", "visible": false }, "include": false },
|
||||
{ "app": { "id": "com.webos.app.unlisted" }, "include": false },
|
||||
{ "app": { "id": "com.webos.app.string-true", "visible": "true" }, "include": false },
|
||||
{ "app": { "id": "com.palm.app.visible", "visible": true }, "include": true },
|
||||
{ "app": { "id": "com.palm.app.hidden", "visible": false }, "include": false },
|
||||
{ "app": { "id": "com.lge.app.visible", "visible": true }, "include": true },
|
||||
{ "app": { "id": "com.lge.app.unlisted" }, "include": false },
|
||||
{ "app": { "id": "com.webos.app.livetv", "visible": false }, "include": true },
|
||||
{ "app": { "id": "com.ecklf.luma", "visible": true }, "include": false },
|
||||
{ "app": { "id": "com.luma.native", "visible": true }, "include": false },
|
||||
{ "app": { "id": "com.luma.launcher", "visible": true }, "include": false },
|
||||
{ "app": { "id": "com.luma.launcher.native", "visible": true }, "include": false },
|
||||
{ "app": { "id": "com.webos.app.home", "visible": true }, "include": false },
|
||||
{ "app": { "id": "com.webos.app.hdmi1", "visible": true }, "include": false },
|
||||
{ "app": { "id": "com.webos.app.externalinput.av1", "visible": true }, "include": false },
|
||||
{ "app": { "id": "com.example.duplicate", "visible": false }, "include": false },
|
||||
{ "app": { "id": "com.example.duplicate", "visible": true }, "include": true },
|
||||
{ "app": { "id": "com.example.duplicate", "visible": false }, "include": false },
|
||||
{ "app": { "id": "com.example.first-visible" }, "include": true },
|
||||
{ "app": { "id": "com.example.first-visible", "visible": false }, "include": false },
|
||||
{ "app": { "id": "constructor" }, "include": true },
|
||||
{ "app": { "id": "" }, "include": false },
|
||||
{ "app": { "id": "com..invalid", "visible": true }, "include": false },
|
||||
{ "app": { "id": "bad;command", "visible": true }, "include": false },
|
||||
{ "app": { "id": "com.example.한국어", "visible": true }, "include": false },
|
||||
{ "app": { "id": 123, "visible": true }, "include": false },
|
||||
{ "app": { "title": "Missing ID", "visible": true }, "include": false }
|
||||
]
|
||||
@@ -0,0 +1,206 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const script = await fs.readFile(new URL('../tv/home.cjs', import.meta.url), 'utf8');
|
||||
const app = 'com.ecklf.luma';
|
||||
const stage = '/var/lib/luma-home/app';
|
||||
const target = '/usr/palm/applications/com.webos.app.home';
|
||||
const developer = `/media/developer/${app}/preferences.json`;
|
||||
const shared = `/media/developer/temp/${app}/preferences.json`;
|
||||
const internal = `/media/internal/.${app}/preferences.json`;
|
||||
|
||||
function fixture({ production = false, mounted = false, preferences = {} } = {}) {
|
||||
const appDir = `/media/${production ? 'cryptofs' : 'developer'}/apps/usr/palm/applications/${app}`;
|
||||
const files = new Map([
|
||||
[stage, Buffer.alloc(0)],
|
||||
[`${stage}/appinfo.json`, Buffer.from('{}')],
|
||||
[`${appDir}/luma`, Buffer.from('mock executable')],
|
||||
[`${appDir}/splash.png`, Buffer.from('default')],
|
||||
...['dunes', 'forest', 'prism'].map((name) => [
|
||||
`${appDir}/splash/${name}.png`,
|
||||
Buffer.from(name),
|
||||
]),
|
||||
...Object.entries(preferences).map(([file, value]) => [
|
||||
file,
|
||||
Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)),
|
||||
]),
|
||||
]);
|
||||
const writes = [];
|
||||
const logs = [];
|
||||
const missing = () => Object.assign(new Error('Missing'), { code: 'ENOENT' });
|
||||
const mockFs = {
|
||||
existsSync: (file) => files.has(file),
|
||||
readFileSync(file, encoding) {
|
||||
if (!files.has(file)) throw missing();
|
||||
const data = files.get(file);
|
||||
return encoding ? data.toString(encoding) : data;
|
||||
},
|
||||
writeFileSync(file, data) {
|
||||
writes.push(file);
|
||||
files.set(file, data);
|
||||
},
|
||||
renameSync(from, to) {
|
||||
assert.ok(files.has(from));
|
||||
files.set(to, files.get(from));
|
||||
files.delete(from);
|
||||
},
|
||||
statSync(file) {
|
||||
assert.ok([stage, target].includes(file));
|
||||
return { dev: 1, ino: mounted || file === stage ? 1 : 2 };
|
||||
},
|
||||
readlinkSync() {
|
||||
throw missing();
|
||||
},
|
||||
};
|
||||
const context = vm.createContext({
|
||||
require(name) {
|
||||
if (name === 'fs') return mockFs;
|
||||
if (name === 'path') return path.posix;
|
||||
assert.equal(name, 'child_process');
|
||||
const unexpected = () => assert.fail('Tests must not invoke any system or TV command');
|
||||
return { execFileSync: unexpected, spawnSync: unexpected };
|
||||
},
|
||||
process: { argv: ['node', 'home.cjs', 'status'] },
|
||||
console: { log: (line) => logs.push(line), error: (line) => logs.push(line) },
|
||||
});
|
||||
vm.runInContext(script, context, { timeout: 2000 });
|
||||
assert.equal(context.process.exitCode, undefined);
|
||||
return {
|
||||
context,
|
||||
files,
|
||||
writes,
|
||||
appDir,
|
||||
splash() {
|
||||
context.stageSplash(appDir);
|
||||
assert.ok(!files.has(`${stage}/splash.png.new`));
|
||||
return files.get(`${stage}/splash.png`).toString();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('production and developer installs follow native preference precedence', () => {
|
||||
const preferences = {
|
||||
[developer]: { background: 'forest' },
|
||||
[shared]: { background: 'dunes' },
|
||||
[internal]: { background: 'prism' },
|
||||
};
|
||||
assert.equal(fixture({ production: true, preferences }).splash(), 'prism');
|
||||
assert.equal(fixture({ preferences }).splash(), 'forest');
|
||||
});
|
||||
|
||||
test('an existing persistent destination wins before installation-local migration', () => {
|
||||
const persistent = fixture({ preferences: { [internal]: { background: 'prism' } } });
|
||||
persistent.files.set(
|
||||
`${persistent.appDir}/preferences.json`,
|
||||
Buffer.from(JSON.stringify({ background: 'forest' })),
|
||||
);
|
||||
assert.equal(persistent.splash(), 'prism');
|
||||
const legacy = fixture();
|
||||
legacy.files.set(
|
||||
`${legacy.appDir}/preferences.json`,
|
||||
Buffer.from(JSON.stringify({ background: 'forest' })),
|
||||
);
|
||||
assert.equal(legacy.splash(), 'forest');
|
||||
});
|
||||
|
||||
test('missing, malformed, invalid and unavailable backgrounds have a default splash', () => {
|
||||
for (const saved of [undefined, '{', { background: '../forest' }, { background: 'unknown' }]) {
|
||||
const mock = fixture({ preferences: saved === undefined ? {} : { [developer]: saved } });
|
||||
assert.equal(mock.splash(), saved?.background === 'unknown' ? 'default' : 'dunes');
|
||||
}
|
||||
});
|
||||
|
||||
test('apply refreshes the staged image even while the Home mount is active', () => {
|
||||
const mock = fixture({ mounted: true, preferences: { [developer]: { background: 'prism' } } });
|
||||
mock.files.set(`${stage}/splash.png`, Buffer.from('old background'));
|
||||
const calls = [];
|
||||
Object.assign(mock.context, {
|
||||
foreignMount: () => false,
|
||||
foreground: () => 'some.other.app',
|
||||
reread: () => calls.push('reread'),
|
||||
stopHome: () => calls.push('stop'),
|
||||
launchHome: () => assert.fail('Applying must not steal focus from another app'),
|
||||
});
|
||||
mock.context.apply({ appDir: mock.appDir }, false);
|
||||
assert.equal(mock.files.get(`${stage}/splash.png`).toString(), 'prism');
|
||||
assert.deepEqual(mock.writes, [`${stage}/splash.png.new`]);
|
||||
assert.deepEqual(calls, ['reread', 'stop']);
|
||||
});
|
||||
|
||||
function closeFixture({ running, closeReply = { returnValue: true } }) {
|
||||
const mock = fixture();
|
||||
const calls = [];
|
||||
let queries = 0;
|
||||
let pauses = 0;
|
||||
Object.assign(mock.context, {
|
||||
luna(method, payload) {
|
||||
calls.push([method, { ...payload }]);
|
||||
if (method === 'running') {
|
||||
return { returnValue: true, running: running[Math.min(queries++, running.length - 1)] };
|
||||
}
|
||||
// The old close({id: APP}) call causes "no app matched by pid" on TV.
|
||||
assert.equal(method, 'closeByAppId');
|
||||
assert.equal(payload.id, app);
|
||||
if (closeReply instanceof Error) throw closeReply;
|
||||
return closeReply;
|
||||
},
|
||||
pause() {
|
||||
pauses++;
|
||||
},
|
||||
});
|
||||
return { ...mock, calls, pauses: () => pauses };
|
||||
}
|
||||
|
||||
test('takeover closes the normal native instance by app ID and waits for its exit', () => {
|
||||
const normal = { id: app, processId: '1234' };
|
||||
const other = { id: 'some.other.app', processId: '5678' };
|
||||
const mock = closeFixture({ running: [[normal, other], [normal, other], [other]] });
|
||||
mock.context.closeNormal();
|
||||
assert.deepEqual(mock.calls, [
|
||||
['running', {}],
|
||||
['closeByAppId', { id: app }],
|
||||
['running', {}],
|
||||
['running', {}],
|
||||
]);
|
||||
assert.equal(mock.pauses(), 1);
|
||||
});
|
||||
|
||||
test('takeover skips closing when the normal instance is not running', () => {
|
||||
const mock = closeFixture({ running: [[{ id: 'com.webos.app.home' }]] });
|
||||
mock.context.closeNormal();
|
||||
assert.deepEqual(mock.calls, [['running', {}]]);
|
||||
assert.equal(mock.pauses(), 0);
|
||||
});
|
||||
|
||||
test('an instance exiting before the close reply does not block takeover', () => {
|
||||
for (const closeReply of [
|
||||
{ returnValue: false, errorCode: 1, errorText: 'no app matched by pid' },
|
||||
new Error('Luna close request timed out'),
|
||||
]) {
|
||||
const mock = closeFixture({ running: [[{ id: app }], []], closeReply });
|
||||
mock.context.closeNormal();
|
||||
assert.equal(mock.calls.length, 3);
|
||||
assert.equal(mock.pauses(), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('close failures remain visible when the normal instance is still running', () => {
|
||||
const mock = closeFixture({
|
||||
running: [[{ id: app }]],
|
||||
closeReply: { returnValue: false, errorText: 'Permission denied' },
|
||||
});
|
||||
assert.throws(
|
||||
() => mock.context.closeNormal(),
|
||||
/Close the normal Luma instance.*Permission denied/,
|
||||
);
|
||||
assert.equal(mock.pauses(), 0);
|
||||
});
|
||||
|
||||
test('takeover stops waiting after five seconds if the normal instance stays running', () => {
|
||||
const mock = closeFixture({ running: [[{ id: app }]] });
|
||||
assert.throws(() => mock.context.closeNormal(), /Close Luma before enabling Home integration/);
|
||||
assert.equal(mock.pauses(), 5);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const script = await fs.readFile(new URL('../tv/read-app-icon.cjs', import.meta.url), 'utf8');
|
||||
const roots = [
|
||||
'/media/developer/apps/usr/palm/applications',
|
||||
'/media/cryptofs/apps/usr/palm/applications',
|
||||
'/usr/palm/applications',
|
||||
];
|
||||
const directory = `${roots[0]}/sample`;
|
||||
|
||||
function readIcon({ metadata = {}, installations = [], chunk = 16384 } = {}) {
|
||||
const files = new Map();
|
||||
for (const install of installations) {
|
||||
const dir = install.directory ?? directory;
|
||||
if (install.manifest !== undefined) {
|
||||
files.set(`${dir}/appinfo.json`, Buffer.from(JSON.stringify(install.manifest)));
|
||||
}
|
||||
for (const [name, bytes] of Object.entries(install.icons ?? {})) {
|
||||
files.set(path.posix.resolve(dir, name), Buffer.from(bytes));
|
||||
}
|
||||
}
|
||||
const opened = [];
|
||||
const descriptors = new Set();
|
||||
const logs = [];
|
||||
const errors = [];
|
||||
const mockFs = {
|
||||
openSync(file) {
|
||||
opened.push(file);
|
||||
if (!files.has(file)) throw Object.assign(new Error('Missing'), { code: 'ENOENT' });
|
||||
descriptors.add(file);
|
||||
return file;
|
||||
},
|
||||
fstatSync(file) {
|
||||
return { size: files.get(file).length, isFile: () => true };
|
||||
},
|
||||
readSync(file, buffer, offset, length, position) {
|
||||
return files.get(file).copy(buffer, offset, position, position + Math.min(length, chunk));
|
||||
},
|
||||
closeSync(file) {
|
||||
assert.ok(descriptors.delete(file));
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(
|
||||
script,
|
||||
{
|
||||
require(name) {
|
||||
if (name === 'fs') return mockFs;
|
||||
assert.equal(name, 'path');
|
||||
return path.posix;
|
||||
},
|
||||
process: { argv: ['node', JSON.stringify({ id: 'sample', ...metadata })] },
|
||||
Buffer,
|
||||
console: { log: (line) => logs.push(line), error: (line) => errors.push(line) },
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
assert.equal(descriptors.size, 0, 'all opened files must be closed');
|
||||
assert.equal(logs.length, 1);
|
||||
const reply = JSON.parse(logs[0]);
|
||||
assert.equal(reply.returnValue, true);
|
||||
return { icons: reply.icons.map((hex) => Buffer.from(hex, 'hex')), opened, errors };
|
||||
}
|
||||
|
||||
test('runtime helper discovers manifest artwork without any bundled icon map', () => {
|
||||
const { icons } = readIcon({
|
||||
installations: [
|
||||
{
|
||||
manifest: { id: 'sample', largeIcon: 'resources/hu/large.png', icon: 'regular.png' },
|
||||
icons: { 'resources/hu/large.png': 'large', 'regular.png': 'regular' },
|
||||
},
|
||||
],
|
||||
chunk: 3,
|
||||
});
|
||||
assert.deepEqual(
|
||||
icons.map((bytes) => bytes.toString()),
|
||||
['large', 'regular'],
|
||||
);
|
||||
});
|
||||
|
||||
test('live inventory installation and localized icon take precedence over other copies', () => {
|
||||
const installed = `${roots[1]}/actual-directory`;
|
||||
const { icons } = readIcon({
|
||||
metadata: { directory: installed, large_icon: `file://${installed}/localized.webp` },
|
||||
installations: [
|
||||
{ icons: { 'icon.png': 'old developer install' } },
|
||||
{ directory: installed, icons: { 'localized.webp': 'current installed artwork' } },
|
||||
],
|
||||
});
|
||||
assert.equal(icons[0].toString(), 'current installed artwork');
|
||||
});
|
||||
|
||||
test('missing, unsupported and oversized large icons fall back to regular or default artwork', () => {
|
||||
for (const [name, bytes] of [
|
||||
['missing.png', null],
|
||||
['large.svg', '<svg/>'],
|
||||
['large.png', Buffer.alloc(512 * 1024 + 1)],
|
||||
]) {
|
||||
const { icons } = readIcon({
|
||||
installations: [
|
||||
{
|
||||
manifest: { id: 'sample', largeIcon: name, icon: 'regular.jpeg' },
|
||||
icons: { ...(bytes ? { [name]: bytes } : {}), 'regular.jpeg': 'regular' },
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
icons.map((data) => data.toString()),
|
||||
['regular'],
|
||||
);
|
||||
}
|
||||
const { icons } = readIcon({ installations: [{ icons: { 'icon.png': 'default' } }] });
|
||||
assert.equal(icons[0].toString(), 'default');
|
||||
});
|
||||
|
||||
test('artwork responses have an aggregate byte limit and do not duplicate the same path', () => {
|
||||
const { icons } = readIcon({
|
||||
metadata: { large_icon: 'large.png', icon: 'regular.png' },
|
||||
installations: [
|
||||
{
|
||||
manifest: { id: 'sample', largeIcon: 'large.png', icon: 'regular.png' },
|
||||
icons: {
|
||||
'large.png': Buffer.alloc(400 * 1024),
|
||||
'regular.png': Buffer.alloc(200 * 1024),
|
||||
'icon.png': 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
icons.map((data) => data.length),
|
||||
[400 * 1024, 7],
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid paths and unrelated manifests cannot select artwork outside app directories', () => {
|
||||
for (const icon of [
|
||||
'/etc/passwd.png',
|
||||
'../../secret.png',
|
||||
'https://example.com/icon.png',
|
||||
'bad\0.png',
|
||||
]) {
|
||||
const { icons, opened } = readIcon({
|
||||
metadata: { directory: '/etc', large_icon: icon },
|
||||
installations: [
|
||||
{
|
||||
manifest: { id: 'another-app', largeIcon: 'wrong.png' },
|
||||
icons: { 'wrong.png': 'wrong' },
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(icons, []);
|
||||
assert.ok(opened.every((file) => roots.some((root) => file.startsWith(`${root}/`))));
|
||||
assert.ok(!opened.some((file) => file.endsWith('/wrong.png')));
|
||||
}
|
||||
assert.throws(() => readIcon({ metadata: { id: 'sample; command' } }), /Invalid application ID/);
|
||||
const { icons } = readIcon({
|
||||
installations: [{ manifest: null, icons: { 'icon.png': 'default' } }],
|
||||
});
|
||||
assert.equal(icons[0].toString(), 'default');
|
||||
});
|
||||
|
||||
test('file names containing shell punctuation are treated as literal local paths', () => {
|
||||
const name = "it's; $(not-a-command).png";
|
||||
const { icons } = readIcon({
|
||||
metadata: { icon: name },
|
||||
installations: [{ icons: { [name]: 'literal file' } }],
|
||||
});
|
||||
assert.equal(icons[0].toString(), 'literal file');
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
import { syncVersions } from '../tools/sync-version.mjs';
|
||||
import { isReleaseMerge, isVersionIncrease } from '../tools/release-gate.mjs';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
const metadata = ['appinfo.json', 'Cargo.toml', 'Cargo.lock', 'readme.md'];
|
||||
|
||||
async function fixture(t, version = '2.0.4') {
|
||||
const scratch = path.join(root, '.build');
|
||||
await fs.mkdir(scratch, { recursive: true });
|
||||
const directory = await fs.mkdtemp(path.join(scratch, 'release-test-'));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const project = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
project.version = '1.2.3';
|
||||
await fs.writeFile(path.join(directory, 'package.json'), JSON.stringify(project));
|
||||
await Promise.all(
|
||||
metadata.map((filename) =>
|
||||
fs.copyFile(path.join(root, filename), path.join(directory, filename)),
|
||||
),
|
||||
);
|
||||
await syncVersions(directory);
|
||||
project.version = version;
|
||||
await fs.writeFile(path.join(directory, 'package.json'), JSON.stringify(project));
|
||||
return directory;
|
||||
}
|
||||
|
||||
const readMetadata = (directory) =>
|
||||
Promise.all(metadata.map((filename) => fs.readFile(path.join(directory, filename), 'utf8')));
|
||||
|
||||
test('version sync changes only release versions and is idempotent', async (t) => {
|
||||
const directory = await fixture(t, '3.1.0');
|
||||
const before = await readMetadata(directory);
|
||||
await assert.rejects(syncVersions(directory, { check: true }), /out of sync/);
|
||||
assert.deepEqual(await readMetadata(directory), before, 'check mode must not write files');
|
||||
|
||||
assert.deepEqual((await syncVersions(directory)).changed, metadata);
|
||||
const after = await readMetadata(directory);
|
||||
const previous = JSON.parse(before[0]).version;
|
||||
assert.equal(JSON.parse(after[0]).version, '3.1.0');
|
||||
assert.equal(after[0], before[0].replace(`"version": "${previous}"`, '"version": "3.1.0"'));
|
||||
assert.equal(after[1], before[1].replace(`version = "${previous}"`, 'version = "3.1.0"'));
|
||||
assert.equal(
|
||||
after[2],
|
||||
before[2].replace(`name = "luma"\nversion = "${previous}"`, 'name = "luma"\nversion = "3.1.0"'),
|
||||
'dependency versions and helper crates must be preserved',
|
||||
);
|
||||
assert.equal(
|
||||
after[3],
|
||||
before[3].replaceAll(`com.ecklf.luma_${previous}_arm.ipk`, 'com.ecklf.luma_3.1.0_arm.ipk'),
|
||||
);
|
||||
assert.deepEqual((await syncVersions(directory, { check: true })).changed, []);
|
||||
assert.deepEqual((await syncVersions(directory)).changed, []);
|
||||
});
|
||||
|
||||
test('invalid release versions and missing packages fail before writing metadata', async (t) => {
|
||||
for (const version of ['3.0.0-beta.1', '3.0', '03.0.0']) {
|
||||
const directory = await fixture(t, version);
|
||||
const before = await readMetadata(directory);
|
||||
await assert.rejects(syncVersions(directory), /numeric major.minor.patch/);
|
||||
assert.deepEqual(await readMetadata(directory), before);
|
||||
}
|
||||
const directory = await fixture(t);
|
||||
const lockfile = path.join(directory, 'Cargo.lock');
|
||||
await fs.writeFile(
|
||||
lockfile,
|
||||
(await fs.readFile(lockfile, 'utf8')).replace('name = "luma"', 'name = "other"'),
|
||||
);
|
||||
const before = await readMetadata(directory);
|
||||
await assert.rejects(syncVersions(directory), /one luma package in Cargo.lock/);
|
||||
assert.deepEqual(await readMetadata(directory), before);
|
||||
});
|
||||
|
||||
test('the real release command combines changesets and synchronizes every file', async (t) => {
|
||||
const directory = await fixture(t, '1.2.3');
|
||||
await syncVersions(directory);
|
||||
await fs.mkdir(path.join(directory, 'tools'));
|
||||
await fs.mkdir(path.join(directory, '.changeset'));
|
||||
for (const filename of [
|
||||
'tools/sync-version.mjs',
|
||||
'.changeset/config.json',
|
||||
'pnpm-workspace.yaml',
|
||||
'.oxfmtrc.json',
|
||||
]) {
|
||||
await fs.copyFile(path.join(root, filename), path.join(directory, filename));
|
||||
}
|
||||
await fs.symlink(path.join(root, 'node_modules'), path.join(directory, 'node_modules'), 'dir');
|
||||
await fs.writeFile(
|
||||
path.join(directory, '.changeset/fix.md'),
|
||||
'---\n"lg-luma": patch\n---\n\nFix release behavior.\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(directory, '.changeset/feature.md'),
|
||||
'---\n"lg-luma": minor\n---\n\nAdd a release feature.\n',
|
||||
);
|
||||
const result = spawnSync('pnpm', ['version:release'], {
|
||||
cwd: directory,
|
||||
encoding: 'utf8',
|
||||
timeout: 30000,
|
||||
env: { ...process.env, CI: 'true' },
|
||||
});
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.equal((await syncVersions(directory, { check: true })).version, '1.3.0');
|
||||
const changelog = await fs.readFile(path.join(directory, 'CHANGELOG.md'), 'utf8');
|
||||
assert.match(changelog, /1\.3\.0/);
|
||||
assert.match(changelog, /Fix release behavior/);
|
||||
assert.match(changelog, /Add a release feature/);
|
||||
assert.deepEqual(await fs.readdir(path.join(directory, '.changeset')), ['config.json']);
|
||||
});
|
||||
|
||||
function gateFixture({ pull = {}, previous = '2.0.3', current = '2.0.4', event = {} } = {}) {
|
||||
const context = {
|
||||
eventName: 'push',
|
||||
ref: 'refs/heads/main',
|
||||
repo: { owner: 'ecklf', repo: 'lg-luma' },
|
||||
sha: 'merged-commit',
|
||||
payload: { before: 'previous-commit', forced: false, deleted: false },
|
||||
...event,
|
||||
};
|
||||
const releasePull = {
|
||||
merged_at: '2026-10-23T12:00:00Z',
|
||||
merge_commit_sha: context.sha,
|
||||
base: { ref: 'main', repo: { full_name: 'ecklf/lg-luma' } },
|
||||
head: { ref: 'changeset-release/main', repo: { full_name: 'ecklf/lg-luma' } },
|
||||
...pull,
|
||||
};
|
||||
const reads = [];
|
||||
const github = {
|
||||
paginate: async (_method, parameters) => {
|
||||
assert.equal(parameters.commit_sha, context.sha);
|
||||
return [releasePull];
|
||||
},
|
||||
rest: {
|
||||
repos: {
|
||||
listPullRequestsAssociatedWithCommit() {},
|
||||
async getContent({ ref, path: filename }) {
|
||||
assert.equal(filename, 'package.json');
|
||||
reads.push(ref);
|
||||
const version = ref === context.sha ? current : previous;
|
||||
return {
|
||||
data: {
|
||||
encoding: 'base64',
|
||||
content: Buffer.from(JSON.stringify({ version })).toString('base64'),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return { github, context, reads };
|
||||
}
|
||||
|
||||
test('a merged same-repository version PR with an increased version can publish', async () => {
|
||||
const fixture = gateFixture();
|
||||
assert.equal(await isReleaseMerge(fixture), true);
|
||||
assert.deepEqual(fixture.reads, ['previous-commit', 'merged-commit']);
|
||||
});
|
||||
|
||||
test('ordinary merges, unmerged PRs, forks and manual runs cannot publish', async (t) => {
|
||||
const cases = [
|
||||
['feature PR', { pull: { head: { ref: 'feature', repo: { full_name: 'ecklf/lg-luma' } } } }],
|
||||
['unmerged version PR', { pull: { merged_at: null } }],
|
||||
['different merge commit', { pull: { merge_commit_sha: 'other-commit' } }],
|
||||
[
|
||||
'fork version branch',
|
||||
{ pull: { head: { ref: 'changeset-release/main', repo: { full_name: 'other/lg-luma' } } } },
|
||||
],
|
||||
['deleted head repository', { pull: { head: { ref: 'changeset-release/main', repo: null } } }],
|
||||
[
|
||||
'different base',
|
||||
{ pull: { base: { ref: 'develop', repo: { full_name: 'ecklf/lg-luma' } } } },
|
||||
],
|
||||
['manual CI run', { event: { eventName: 'workflow_dispatch' } }],
|
||||
['PR checks', { event: { eventName: 'pull_request' } }],
|
||||
['other branch', { event: { ref: 'refs/heads/changeset-release/main' } }],
|
||||
['force push', { event: { payload: { before: 'previous-commit', forced: true } } }],
|
||||
[
|
||||
'initial push',
|
||||
{ event: { payload: { before: '0000000000000000000000000000000000000000' } } },
|
||||
],
|
||||
];
|
||||
for (const [name, options] of cases) {
|
||||
await t.test(name, async () => {
|
||||
const fixture = gateFixture(options);
|
||||
assert.equal(await isReleaseMerge(fixture), false);
|
||||
assert.deepEqual(fixture.reads, []);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('publishing requires a numeric version increase', async () => {
|
||||
for (const current of ['2.0.3', '2.0.2', '1.99.99']) {
|
||||
assert.equal(await isReleaseMerge(gateFixture({ current })), false);
|
||||
}
|
||||
for (const [previous, current] of [
|
||||
['2.0.3', '2.0.4'],
|
||||
['2.9.9', '2.10.0'],
|
||||
['2.99.99', '3.0.0'],
|
||||
]) {
|
||||
assert.equal(isVersionIncrease(previous, current), true);
|
||||
}
|
||||
await assert.rejects(
|
||||
isReleaseMerge(gateFixture({ current: '3.0.0-beta.1' })),
|
||||
/Invalid numeric release version/,
|
||||
);
|
||||
});
|
||||
|
||||
test('missing PRs and failed GitHub lookups never permit publication', async () => {
|
||||
const fixture = gateFixture();
|
||||
fixture.github.paginate = async () => [];
|
||||
assert.equal(await isReleaseMerge(fixture), false);
|
||||
assert.deepEqual(fixture.reads, []);
|
||||
fixture.github.paginate = async () => {
|
||||
throw new Error('GitHub unavailable');
|
||||
};
|
||||
await assert.rejects(isReleaseMerge(fixture), /GitHub unavailable/);
|
||||
});
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
const out = path.join(root, '.build');
|
||||
await fs.mkdir(out, { recursive: true });
|
||||
const run = (command, args) => {
|
||||
const result = spawnSync(command, args, { cwd: root, stdio: 'inherit', timeout: 120000 });
|
||||
if (result.error) throw result.error;
|
||||
assert.equal(result.status, 0, `${command} failed`);
|
||||
};
|
||||
run('cargo', ['build', '--locked', '--package', 'luma']);
|
||||
const binary = path.resolve(root, process.env.CARGO_TARGET_DIR ?? 'target', 'debug/luma');
|
||||
const temporary = await fs.mkdtemp(path.join(out, 'smoke-'));
|
||||
try {
|
||||
const preferences = path.join(temporary, 'preferences.json');
|
||||
const scenario = async (name, script, extra = []) => {
|
||||
const screenshot = path.join(out, `${name}.png`);
|
||||
const dump = path.join(temporary, `${name}.json`);
|
||||
const diagnostics = path.join(out, `${name}-render.json`);
|
||||
run(binary, [
|
||||
'--preferences',
|
||||
preferences,
|
||||
'--script',
|
||||
script,
|
||||
'--frames',
|
||||
'90',
|
||||
'--screenshot',
|
||||
screenshot,
|
||||
'--dump-state',
|
||||
dump,
|
||||
'--diagnostics',
|
||||
diagnostics,
|
||||
...extra,
|
||||
]);
|
||||
const state = JSON.parse(await fs.readFile(dump, 'utf8'));
|
||||
const render = JSON.parse(await fs.readFile(diagnostics, 'utf8'));
|
||||
assert.equal(
|
||||
render.capture.glyph_failures,
|
||||
0,
|
||||
'Visible vector text must remain valid across cache reuse',
|
||||
);
|
||||
assert.equal(render.last_presented.glyph_failures, 0);
|
||||
assert.equal(render.capture.font_antialiasing, 'vector grayscale coverage');
|
||||
assert.equal(render.capture.missing_glyphs, 0);
|
||||
assert.equal(render.capture.missing_images, 0);
|
||||
state.render = render;
|
||||
assert.equal(state.error, null);
|
||||
const stats = await sharp(screenshot).stats();
|
||||
assert.equal(
|
||||
stats.channels[3].min,
|
||||
255,
|
||||
'UI compositing must preserve opaque framebuffer alpha',
|
||||
);
|
||||
assert.ok(
|
||||
stats.channels.slice(0, 3).some((channel) => channel.stdev > 10),
|
||||
'Framebuffer must contain rendered UI',
|
||||
);
|
||||
return state;
|
||||
};
|
||||
const home = await scenario('home', 'down');
|
||||
assert.deepEqual(home.focus, { App: 'netflix' });
|
||||
const hidden = await scenario('apps', 'apps,down,menu,down,enter');
|
||||
assert.ok(hidden.preferences.hidden.includes('netflix'));
|
||||
assert.deepEqual(hidden.focus, { App: 'youtube.leanback.v4' });
|
||||
// Reload from disk, find Netflix in Hidden, and restore it.
|
||||
const restored = await scenario(
|
||||
'restored',
|
||||
'apps,down,up,right,right,right,right,enter,down,menu,down,enter',
|
||||
);
|
||||
assert.deepEqual(restored.preferences.hidden, []);
|
||||
const searched = await scenario('search', 'apps,type:YouTube,back');
|
||||
assert.equal(searched.query, 'YouTube');
|
||||
assert.deepEqual(searched.apps, ['youtube.leanback.v4']);
|
||||
for (const [id, title] of [
|
||||
['coupangplay', 'Coupang Play'],
|
||||
['com.frograms.watchaplay.webos', 'Watcha Play'],
|
||||
['pooq', 'Wavve'],
|
||||
['com.cmb.rainbowtv', 'Rainbow TV'],
|
||||
['com.lgtvod.kr.app', 'LG TV VOD'],
|
||||
]) {
|
||||
const koreanApp = await scenario(`korean-${id}`, `apps,type:${title},back`);
|
||||
assert.deepEqual(koreanApp.apps, [id]);
|
||||
assert.ok(koreanApp.ui_text.includes(title));
|
||||
}
|
||||
const settings = await scenario('settings', 'settings,down,down,enter,down,enter');
|
||||
assert.equal(settings.preferences.background, 'forest');
|
||||
const saved = await scenario('saved', 'down,hold,right,back');
|
||||
assert.equal(saved.preferences.background, 'forest');
|
||||
assert.deepEqual(saved.preferences.favorites.slice(0, 2), ['youtube.leanback.v4', 'netflix']);
|
||||
const clock = await scenario('clock', 'settings,click:900:284');
|
||||
assert.equal(clock.subpage, 'Clock');
|
||||
assert.equal(clock.focus, 'ClockFormat');
|
||||
assert.ok(clock.ui_text.includes('Clock format'));
|
||||
assert.ok(clock.ui_text.includes('Time format'));
|
||||
const input = await scenario('inputs', 'settings,down,down,down,down,enter,enter,up,enter');
|
||||
assert.equal(input.preferences.input_modes['com.webos.app.hdmi1'], 'Off');
|
||||
assert.equal(input.subpage, null);
|
||||
const closed = await scenario('dialog-closed', 'apps,down,menu,click:100:900');
|
||||
assert.equal(closed.modal, false);
|
||||
assert.deepEqual(closed.focus, { App: 'youtube.leanback.v4' });
|
||||
const fixedTime = ['--time', '2026-10-21T20:41:00+02:00'];
|
||||
const one = await scenario('density-1x', 'home,down', fixedTime);
|
||||
const two = await scenario('density-2x', 'home,down', [...fixedTime, '--capture-scale', '2']);
|
||||
assert.deepEqual(two.render.capture.target, { width: 3840, height: 2160 });
|
||||
assert.equal(two.render.capture.viewport.scale, 2);
|
||||
assert.equal(one.render.capture.viewport.scale, 1);
|
||||
assert.equal(two.render.capture.max_logical_font_px, one.render.capture.max_logical_font_px);
|
||||
for (const capture of [one.render.capture, two.render.capture]) {
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
capture.max_physical_font_px - capture.max_logical_font_px * capture.viewport.scale,
|
||||
) <= 0.126,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
two.render.capture.max_physical_font_px > one.render.capture.max_physical_font_px * 1.9,
|
||||
);
|
||||
assert.ok(two.render.capture.cached_glyphs > 0);
|
||||
assert.ok(two.render.capture.cached_text_runs > 0);
|
||||
assert.ok(two.render.capture.text_geometry_bytes > 0);
|
||||
assert.ok(two.render.capture.text_triangles > 0);
|
||||
assert.equal(
|
||||
two.render.drawable_is_4k,
|
||||
two.render.drawable.width >= 3840 && two.render.drawable.height >= 2160,
|
||||
);
|
||||
const uhd = await sharp(path.join(out, 'density-2x.png')).metadata();
|
||||
assert.equal(uhd.width, 3840);
|
||||
assert.equal(uhd.height, 2160);
|
||||
const low = await sharp(path.join(out, 'density-1x.png'))
|
||||
.extract({ left: 84, top: 265, width: 600, height: 96 })
|
||||
.resize(1200, 192, { kernel: 'cubic' })
|
||||
.raw()
|
||||
.toBuffer();
|
||||
const high = await sharp(path.join(out, 'density-2x.png'))
|
||||
.extract({ left: 168, top: 530, width: 1200, height: 192 })
|
||||
.raw()
|
||||
.toBuffer();
|
||||
assert.notDeepEqual(high, low, '4K vector text must use the drawable pixel scale');
|
||||
const hasWallpaper = await fs.access(path.join(out, 'assets/wallpapers/forest@2x.jpg')).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
assert.equal(
|
||||
two.render.capture.high_resolution_wallpaper,
|
||||
hasWallpaper && two.render.graphics.max_texture_size >= 3840,
|
||||
);
|
||||
const profiled = await scenario('profile', 'home,down', ['--profile-frames', '24', ...fixedTime]);
|
||||
assert.equal(profiled.render.profile.presented, 24);
|
||||
assert.ok(profiled.render.profile.pacing.samples > 0);
|
||||
assert.ok(profiled.render.profile.cpu_submission.mean_ms >= 0);
|
||||
const greetingEditor = 'settings,down,enter,down,down,down,down,enter,enter';
|
||||
for (const name of ['Ana', 'Bob']) {
|
||||
const named = await scenario(
|
||||
`greeting-${name}`,
|
||||
`${greetingEditor},type:${name},back,home`,
|
||||
fixedTime,
|
||||
);
|
||||
assert.equal(named.preferences.greeting_name, name);
|
||||
assert.ok(named.ui_text.includes(`Good evening, ${name}.`));
|
||||
assert.equal(
|
||||
JSON.parse(await fs.readFile(preferences, 'utf8')).greeting_name,
|
||||
name,
|
||||
'Keyboard completion must save without waiting for another SDL input event',
|
||||
);
|
||||
}
|
||||
const greeting = await scenario(
|
||||
'greeting-settings',
|
||||
'settings,down,enter,down,down,down,down,enter,down',
|
||||
);
|
||||
assert.equal(greeting.subpage, 'Greeting');
|
||||
assert.equal(greeting.focus, 'GreetingNameClear');
|
||||
assert.ok(greeting.ui_text.includes('Clear name'));
|
||||
|
||||
const appNamesEditor = 'settings,down,enter,enter';
|
||||
const appNames = await scenario('app-name-settings', appNamesEditor);
|
||||
assert.equal(appNames.subpage, 'AppNames');
|
||||
assert.ok(appNames.ui_text.includes('Show on Home'));
|
||||
assert.ok(appNames.ui_text.includes('Show on Apps'));
|
||||
assert.equal(appNames.preferences.show_home_names, false);
|
||||
assert.equal(appNames.preferences.show_app_names, true);
|
||||
const customNames = await scenario(
|
||||
'home-names-enabled',
|
||||
`${appNamesEditor},enter,down,enter,back,home`,
|
||||
);
|
||||
assert.equal(customNames.preferences.show_home_names, true);
|
||||
assert.equal(customNames.preferences.show_app_names, false);
|
||||
assert.ok(customNames.ui_text.includes('Netflix'));
|
||||
const hiddenNames = await scenario('app-names-disabled', 'apps');
|
||||
assert.equal(hiddenNames.preferences.show_app_names, false);
|
||||
assert.ok(!hiddenNames.ui_text.includes('Netflix'));
|
||||
|
||||
const renamedInput = await scenario(
|
||||
'input-rename-focus',
|
||||
'settings,down,down,down,down,enter,down,enter,down,type:Console,back',
|
||||
);
|
||||
assert.equal(renamedInput.focus, 'InputRename');
|
||||
assert.equal(renamedInput.preferences.input_names['com.webos.app.hdmi2'], 'Console');
|
||||
assert.ok(renamedInput.ui_text.includes('Clear name'));
|
||||
const clockEditor = 'settings,down,enter,down,enter';
|
||||
const timeFormat = await scenario('clock-time-format', `${clockEditor},down,enter`);
|
||||
assert.equal(timeFormat.subpage, 'TimeFormat');
|
||||
assert.deepEqual(timeFormat.focus, { Choice: 0 });
|
||||
assert.ok(timeFormat.ui_text.includes('HH:MM'));
|
||||
assert.ok(timeFormat.ui_text.includes('HH:MM:SS'));
|
||||
const withSeconds = await scenario(
|
||||
'clock-time-format-saved',
|
||||
`${clockEditor},down,enter,down,enter`,
|
||||
);
|
||||
assert.equal(withSeconds.subpage, 'Clock');
|
||||
assert.equal(withSeconds.focus, 'ClockTimeFormat');
|
||||
assert.equal(withSeconds.preferences.show_seconds, true);
|
||||
assert.equal(withSeconds.preferences.hour_24, false);
|
||||
const clockFocus = await scenario('clock-format-focus', `${clockEditor},enter,down`);
|
||||
assert.equal(clockFocus.subpage, 'ClockFormat');
|
||||
assert.deepEqual(clockFocus.focus, { Choice: 1 });
|
||||
const system = await scenario('system-settings', 'settings,down,down,down,down,down,right,down');
|
||||
assert.deepEqual(system.focus, { Setting: 5 });
|
||||
assert.ok(system.ui_text.includes('Refresh apps & inputs'));
|
||||
assert.ok(system.ui_text.includes('Quit app'));
|
||||
console.log(
|
||||
'Native smoke checks passed: rendering/alpha, navigation, persistence, SDL pointer input, DPI-aware glyphs, 4K capture and presentation profiling.',
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const script = await fs.readFile(new URL('../tv/sync-splash.cjs', import.meta.url), 'utf8');
|
||||
const app = 'com.ecklf.luma';
|
||||
const state = '/var/lib/luma-home';
|
||||
|
||||
function fixture({ production = false, config, failWrite = false } = {}) {
|
||||
const directory = `/media/${production ? 'cryptofs' : 'developer'}/apps/usr/palm/applications/${app}`;
|
||||
const files = new Map([
|
||||
[
|
||||
`${directory}/appinfo.json`,
|
||||
Buffer.from(JSON.stringify({ id: app, type: 'native', main: 'luma' })),
|
||||
],
|
||||
[`${directory}/splash.png`, Buffer.from('dunes')],
|
||||
[`${directory}/splash/forest.png`, Buffer.from('forest')],
|
||||
[`${directory}/splash/prism.png`, Buffer.from('prism')],
|
||||
[`${state}/app/splash.png`, Buffer.from('old Home splash')],
|
||||
]);
|
||||
if (config) files.set(`${state}/state.json`, Buffer.from(JSON.stringify(config(directory))));
|
||||
const writes = [];
|
||||
const renames = [];
|
||||
const missing = () => Object.assign(new Error('Missing'), { code: 'ENOENT' });
|
||||
const mockFs = {
|
||||
readFileSync(file, encoding) {
|
||||
if (!files.has(file)) throw missing();
|
||||
const data = files.get(file);
|
||||
return encoding ? data.toString(encoding) : data;
|
||||
},
|
||||
writeFileSync(file, data, options) {
|
||||
assert.ok(file.endsWith('/splash.png.new'));
|
||||
assert.equal(options.mode, 0o644);
|
||||
if (failWrite) throw Object.assign(new Error('Read-only filesystem'), { code: 'EROFS' });
|
||||
writes.push(file);
|
||||
files.set(file, data);
|
||||
},
|
||||
renameSync(from, to) {
|
||||
assert.ok(files.has(from));
|
||||
renames.push(to);
|
||||
files.set(to, files.get(from));
|
||||
files.delete(from);
|
||||
},
|
||||
};
|
||||
return {
|
||||
directory,
|
||||
files,
|
||||
writes,
|
||||
renames,
|
||||
sync(background, overrides = {}) {
|
||||
const logs = [];
|
||||
vm.runInNewContext(
|
||||
script,
|
||||
{
|
||||
require(name) {
|
||||
if (name === 'fs') return mockFs;
|
||||
assert.equal(name, 'path', 'the helper must not invoke system or TV commands');
|
||||
return path.posix;
|
||||
},
|
||||
process: { argv: ['node', JSON.stringify({ directory, background, ...overrides })] },
|
||||
console: { log: (line) => logs.push(line) },
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
assert.deepEqual(logs.map(JSON.parse), [{ returnValue: true, background }]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('normal developer and production tile launches use the saved selection', () => {
|
||||
for (const production of [false, true]) {
|
||||
const mock = fixture({ production });
|
||||
mock.sync('forest');
|
||||
assert.equal(mock.files.get(`${mock.directory}/splash.png`).toString(), 'forest');
|
||||
mock.sync('prism');
|
||||
assert.equal(mock.files.get(`${mock.directory}/splash.png`).toString(), 'prism');
|
||||
assert.deepEqual(mock.renames, [
|
||||
`${mock.directory}/splash.png`,
|
||||
`${mock.directory}/splash.png`,
|
||||
]);
|
||||
assert.ok(!mock.files.has(`${mock.directory}/splash.png.new`));
|
||||
assert.equal(mock.files.get(`${state}/app/splash.png`).toString(), 'old Home splash');
|
||||
}
|
||||
});
|
||||
|
||||
test('only an enabled Home stage belonging to this installation is refreshed', () => {
|
||||
for (const [enabled, matching] of [
|
||||
[true, true],
|
||||
[false, true],
|
||||
[true, false],
|
||||
]) {
|
||||
const mock = fixture({
|
||||
config: (directory) => ({ enabled, appDir: matching ? directory : '/another-installation' }),
|
||||
});
|
||||
mock.sync('prism');
|
||||
assert.equal(mock.files.get(`${mock.directory}/splash.png`).toString(), 'prism');
|
||||
assert.equal(
|
||||
mock.files.get(`${state}/app/splash.png`).toString(),
|
||||
enabled && matching ? 'prism' : 'old Home splash',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('startup repairs a replaced package splash without rewriting an unchanged Home image', () => {
|
||||
const mock = fixture({ config: (directory) => ({ enabled: true, appDir: directory }) });
|
||||
mock.sync('forest');
|
||||
mock.writes.length = 0;
|
||||
mock.sync('forest');
|
||||
assert.deepEqual(mock.writes, []);
|
||||
mock.files.set(`${mock.directory}/splash.png`, Buffer.from('package update default'));
|
||||
mock.sync('forest');
|
||||
assert.deepEqual(mock.writes, [`${mock.directory}/splash.png.new`]);
|
||||
assert.equal(mock.files.get(`${mock.directory}/splash.png`).toString(), 'forest');
|
||||
});
|
||||
|
||||
test('missing sources and failed writes preserve the previous launch splash', () => {
|
||||
for (const failWrite of [false, true]) {
|
||||
const mock = fixture({ failWrite });
|
||||
assert.throws(() => mock.sync(failWrite ? 'forest' : 'missing'), /Missing|Read-only/);
|
||||
assert.equal(mock.files.get(`${mock.directory}/splash.png`).toString(), 'dunes');
|
||||
assert.equal(mock.files.get(`${state}/app/splash.png`).toString(), 'old Home splash');
|
||||
assert.deepEqual(mock.renames, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('unrelated installations and invalid selections cannot update a launch splash', () => {
|
||||
const mock = fixture();
|
||||
for (const background of ['../forest', '', 'forest.png', null]) {
|
||||
assert.throws(() => mock.sync(background), /Invalid splash background/);
|
||||
}
|
||||
for (const directory of ['/etc', `${mock.directory}/..`, `${mock.directory}-other`]) {
|
||||
assert.throws(() => mock.sync('forest', { directory }), /Invalid Luma installation/);
|
||||
}
|
||||
mock.files.set(
|
||||
`${mock.directory}/appinfo.json`,
|
||||
Buffer.from(JSON.stringify({ id: 'another.app', type: 'native', main: 'luma' })),
|
||||
);
|
||||
assert.throws(() => mock.sync('forest'), /installed native Luma package/);
|
||||
assert.deepEqual(mock.writes, []);
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import { spawn } from 'node:child_process';
|
||||
import messages from '../assets/messages.json' with { type: 'json' };
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
const out = path.join(root, '.build');
|
||||
await fs.mkdir(out, { recursive: true });
|
||||
const temporary = await fs.mkdtemp(path.join(out, 'translations-smoke-'));
|
||||
const preferences = path.join(temporary, 'preferences.json');
|
||||
const binary = path.resolve(root, process.env.CARGO_TARGET_DIR ?? 'target', 'debug/luma');
|
||||
const requests = [];
|
||||
const places = {
|
||||
en: { name: 'Vienna', region: 'Vienna', country: 'Austria' },
|
||||
de: { name: 'Wien', region: 'Wien', country: 'Österreich' },
|
||||
zh: { name: '维也纳', region: '维也纳', country: '奥地利' },
|
||||
pt: { name: 'Viena', region: 'Viena', country: 'Áustria' },
|
||||
};
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url, 'http://127.0.0.1');
|
||||
requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams) });
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
if (url.pathname === '/v1/search') {
|
||||
const place = places[url.searchParams.get('language')];
|
||||
assert.ok(place, 'Geocoding must use a supported UI language');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
results: [{ ...place, admin1: place.region, latitude: 48.2085, longitude: 16.3721 }],
|
||||
}),
|
||||
);
|
||||
} else if (url.pathname === '/v1/forecast') {
|
||||
assert.equal(url.searchParams.get('temperature_unit'), 'celsius');
|
||||
response.end(JSON.stringify({ current: { temperature_2m: 14.2, weather_code: 2, is_day: 1 } }));
|
||||
} else {
|
||||
response.writeHead(404);
|
||||
response.end('{}');
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
async function run(command, args) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { cwd: root, stdio: 'inherit' });
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error('Translation smoke child timed out'));
|
||||
}, 45000);
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${command} exited ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
async function scenario(name, script, extra = []) {
|
||||
const dump = path.join(temporary, `${name}.json`);
|
||||
const report = path.join(out, `language-${name}-render.json`);
|
||||
await run(binary, [
|
||||
'--preferences',
|
||||
preferences,
|
||||
'--weather-test-server',
|
||||
origin,
|
||||
'--script',
|
||||
script,
|
||||
'--frames',
|
||||
'120',
|
||||
'--time',
|
||||
'2026-10-21T20:41:00+02:00',
|
||||
'--screenshot',
|
||||
path.join(out, `language-${name}.png`),
|
||||
'--dump-state',
|
||||
dump,
|
||||
'--diagnostics',
|
||||
report,
|
||||
...extra,
|
||||
]);
|
||||
const state = JSON.parse(await fs.readFile(dump, 'utf8'));
|
||||
const render = JSON.parse(await fs.readFile(report, 'utf8'));
|
||||
assert.equal(state.error, null);
|
||||
assert.equal(
|
||||
render.capture.missing_glyphs,
|
||||
0,
|
||||
'Translated text must not become missing-character boxes',
|
||||
);
|
||||
assert.equal(render.capture.glyph_failures, 0);
|
||||
assert.equal(
|
||||
render.capture.missing_images,
|
||||
0,
|
||||
'Settings icons and other translated-screen artwork must be present',
|
||||
);
|
||||
return state;
|
||||
}
|
||||
function contains(state, key, language) {
|
||||
assert.ok(state.ui_text.includes(messages[key][language]), `Missing rendered ${key}/${language}`);
|
||||
}
|
||||
const picker = 'settings,down,enter,down,down,down,enter';
|
||||
const location = 'settings,down,down,down,enter,down,enter';
|
||||
try {
|
||||
await run('cargo', ['build', '--locked', '--package', 'luma']);
|
||||
await fs.writeFile(
|
||||
preferences,
|
||||
JSON.stringify({ language: 'en', show_home_names: true, reduced_motion: true }),
|
||||
);
|
||||
const languages = await scenario('picker-en', picker);
|
||||
assert.equal(languages.subpage, 'Language');
|
||||
for (const name of ['English', 'Deutsch', '简体中文', 'Español', 'Português (Brasil)'])
|
||||
assert.ok(languages.ui_text.includes(name));
|
||||
|
||||
const german = await scenario('de-home', `${picker},down,enter,home,down`);
|
||||
assert.equal(german.preferences.language, 'de');
|
||||
for (const key of [
|
||||
'home',
|
||||
'settings',
|
||||
'favorites',
|
||||
'inputsHeading',
|
||||
'evening',
|
||||
'setWeatherLocation',
|
||||
])
|
||||
contains(german, key, 'de');
|
||||
assert.ok(german.ui_text.includes('Netflix'), 'Installed app names stay intact');
|
||||
const persisted = await scenario('de-settings', 'settings,down');
|
||||
assert.equal(persisted.preferences.language, 'de');
|
||||
contains(persisted, 'language', 'de');
|
||||
|
||||
const chinese = await scenario('zh-home', `${picker},down,enter,home,down`);
|
||||
assert.equal(chinese.preferences.language, 'zh');
|
||||
for (const key of [
|
||||
'home',
|
||||
'apps',
|
||||
'settings',
|
||||
'favorites',
|
||||
'inputsHeading',
|
||||
'evening',
|
||||
'setWeatherLocation',
|
||||
])
|
||||
contains(chinese, key, 'zh');
|
||||
assert.ok(chinese.ui_text.includes('Netflix'));
|
||||
assert.ok(chinese.ui_text.includes('10月21日星期三'));
|
||||
const chineseSettings = await scenario('zh-settings', 'settings,down');
|
||||
contains(chineseSettings, 'language', 'zh');
|
||||
contains(chineseSettings, 'appNames', 'zh');
|
||||
|
||||
const selected = await scenario(
|
||||
'zh-weather',
|
||||
`${location},type:Vienna,back,enter,await-search,down,enter,home,await-weather`,
|
||||
);
|
||||
assert.equal(selected.preferences.weather_location.name, '维也纳');
|
||||
contains(selected, 'partlyCloudy', 'zh');
|
||||
assert.equal(requests.find((request) => request.path === '/v1/search').query.language, 'zh');
|
||||
const forecasts = requests.filter((request) => request.path === '/v1/forecast').length;
|
||||
const changed = await scenario('de-weather', `${picker},up,enter,home,await-weather`);
|
||||
assert.equal(changed.preferences.language, 'de');
|
||||
contains(changed, 'partlyCloudy', 'de');
|
||||
assert.ok(changed.ui_text.includes('维也纳'), 'The saved city name is data, not a UI label');
|
||||
assert.equal(
|
||||
requests.filter((request) => request.path === '/v1/forecast').length,
|
||||
forecasts,
|
||||
'Language changes reuse weather cache',
|
||||
);
|
||||
const germanPlace = await scenario(
|
||||
'de-search',
|
||||
`${location},type:Wien,back,enter,await-search,down,enter`,
|
||||
);
|
||||
assert.equal(germanPlace.preferences.weather_location.name, 'Wien');
|
||||
assert.equal(
|
||||
requests.filter((request) => request.path === '/v1/search').at(-1).query.language,
|
||||
'de',
|
||||
);
|
||||
|
||||
const germanDialog = await scenario('de-dialog', 'apps,down,menu');
|
||||
contains(germanDialog, 'removeFavorite', 'de');
|
||||
contains(germanDialog, 'hideApp', 'de');
|
||||
const saved = JSON.parse(await fs.readFile(preferences, 'utf8'));
|
||||
await fs.writeFile(preferences, JSON.stringify({ ...saved, language: 'zh' }));
|
||||
const chineseDialog = await scenario('zh-dialog', 'apps,down,menu');
|
||||
contains(chineseDialog, 'removeFavorite', 'zh');
|
||||
contains(chineseDialog, 'categoryStreaming', 'zh');
|
||||
const input = await scenario('zh-input-options', 'settings,down,down,down,down,enter,enter');
|
||||
for (const key of ['stateOn', 'stateOff', 'stateAuto', 'inputWhenConnected'])
|
||||
contains(input, key, 'zh');
|
||||
const background = await scenario('zh-background', 'settings,down,down,enter');
|
||||
for (const key of ['slate', 'midnight', 'monumentValley', 'northernLights'])
|
||||
contains(background, key, 'zh');
|
||||
const keyboard = await scenario('zh-keyboard', 'apps,type:Netflix');
|
||||
for (const key of ['findApp', 'space', 'delete', 'clear', 'done']) contains(keyboard, key, 'zh');
|
||||
const weatherSettings = await scenario('zh-weather-settings', 'settings,down,down,down,enter');
|
||||
for (const key of ['showWidget', 'location', 'temperature']) contains(weatherSettings, key, 'zh');
|
||||
assert.ok(
|
||||
!weatherSettings.ui_text.some((label) => label.includes('Open-Meteo')),
|
||||
'Weather credit is not shown in the UI',
|
||||
);
|
||||
await scenario('zh-4k', 'home,down', ['--capture-scale', '2']);
|
||||
|
||||
await fs.writeFile(
|
||||
preferences,
|
||||
JSON.stringify({ language: 'en', show_home_names: true, reduced_motion: true }),
|
||||
);
|
||||
const portuguese = await scenario('pt-home', `${picker},down,down,down,down,enter,home,down`);
|
||||
assert.equal(portuguese.preferences.language, 'pt');
|
||||
for (const key of [
|
||||
'home',
|
||||
'apps',
|
||||
'settings',
|
||||
'favorites',
|
||||
'inputsHeading',
|
||||
'evening',
|
||||
'setWeatherLocation',
|
||||
])
|
||||
contains(portuguese, key, 'pt');
|
||||
assert.ok(portuguese.ui_text.includes('QUARTA-FEIRA, 21 DE OUTUBRO'));
|
||||
assert.ok(
|
||||
portuguese.ui_text.includes('Netflix'),
|
||||
'Installed app names stay intact in Portuguese',
|
||||
);
|
||||
const portugueseSettings = await scenario('pt-settings', 'settings,down');
|
||||
assert.equal(
|
||||
portugueseSettings.preferences.language,
|
||||
'pt',
|
||||
'Portuguese is restored after restart',
|
||||
);
|
||||
assert.ok(portugueseSettings.ui_text.includes('Português (Brasil)'));
|
||||
for (const key of ['language', 'appNames', 'clock']) contains(portugueseSettings, key, 'pt');
|
||||
assert.equal(JSON.parse(await fs.readFile(preferences, 'utf8')).language, 'pt');
|
||||
|
||||
const portugueseDialog = await scenario('pt-dialog', 'apps,down,menu');
|
||||
for (const key of ['removeFavorite', 'hideApp', 'categoryStreaming'])
|
||||
contains(portugueseDialog, key, 'pt');
|
||||
const portugueseKeyboard = await scenario('pt-keyboard', 'apps,type:Netflix');
|
||||
for (const key of ['findApp', 'space', 'delete', 'clear', 'done'])
|
||||
contains(portugueseKeyboard, key, 'pt');
|
||||
const portugueseInputs = await scenario(
|
||||
'pt-input-options',
|
||||
'settings,down,down,down,down,enter,enter',
|
||||
);
|
||||
for (const key of ['stateOn', 'stateOff', 'stateAuto', 'inputWhenConnected'])
|
||||
contains(portugueseInputs, key, 'pt');
|
||||
|
||||
const portugueseWeather = await scenario(
|
||||
'pt-weather',
|
||||
`${location},type:Viena,back,enter,await-search,down,enter,home,await-weather`,
|
||||
);
|
||||
assert.equal(portugueseWeather.preferences.language, 'pt');
|
||||
assert.equal(portugueseWeather.preferences.weather_location.name, 'Viena');
|
||||
assert.equal(portugueseWeather.preferences.weather_location.country, 'Áustria');
|
||||
contains(portugueseWeather, 'partlyCloudy', 'pt');
|
||||
assert.equal(
|
||||
requests.filter((request) => request.path === '/v1/search').at(-1).query.language,
|
||||
'pt',
|
||||
'Geocoding must use the Portuguese locale',
|
||||
);
|
||||
console.log(
|
||||
'Native translation checks passed: saved language selection, shared labels, Portuguese locale formatting and accented glyphs, localized geocoding/weather, unchanged app names, Chinese glyphs and 4K rendering.',
|
||||
);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import { spawn } from 'node:child_process';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
const out = path.join(root, '.build');
|
||||
await fs.mkdir(out, { recursive: true });
|
||||
const temporary = await fs.mkdtemp(path.join(out, 'weather-smoke-'));
|
||||
const preferences = path.join(temporary, 'preferences.json');
|
||||
const cache = path.join(temporary, 'preferences.weather.json');
|
||||
const binary = path.resolve(root, process.env.CARGO_TARGET_DIR ?? 'target', 'debug/luma');
|
||||
const vienna = {
|
||||
name: 'Vienna',
|
||||
region: 'Vienna',
|
||||
country: 'Austria',
|
||||
latitude: 48.2085,
|
||||
longitude: 16.3721,
|
||||
};
|
||||
const paris = {
|
||||
name: 'Paris',
|
||||
region: 'Île-de-France',
|
||||
country: 'France',
|
||||
latitude: 48.8534,
|
||||
longitude: 2.3488,
|
||||
};
|
||||
const reading = { temperature: 14.2, code: 2, is_day: true };
|
||||
const requests = [];
|
||||
let forecastMode = 'ok';
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url, 'http://127.0.0.1');
|
||||
requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams) });
|
||||
assert.equal(request.headers.cookie, undefined);
|
||||
assert.equal(request.headers.referer, undefined);
|
||||
response.on('error', () => {});
|
||||
const json = (value, status = 200) => {
|
||||
response.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify(value));
|
||||
};
|
||||
if (url.pathname === '/v1/search') {
|
||||
const query = url.searchParams.get('name');
|
||||
if (query === 'fail') return json({}, 503);
|
||||
if (query === 'nowhere') return json({ results: [] });
|
||||
if (query === 'malformed') return json({ results: 'invalid' });
|
||||
if (query === 'oversized') return json({ results: [], padding: 'x'.repeat(300000) });
|
||||
if (query === 'redirect') {
|
||||
response.writeHead(302, { Location: '/unexpected' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const place = query === 'Paris' ? paris : vienna;
|
||||
const send = () =>
|
||||
json({
|
||||
results: [
|
||||
{ ...place, admin1: place.region },
|
||||
{ ...place, admin1: place.region },
|
||||
{ name: 'Invalid', latitude: 123, longitude: 16 },
|
||||
],
|
||||
});
|
||||
if (query === 'slow') {
|
||||
const timer = setTimeout(send, 700);
|
||||
response.on('close', () => clearTimeout(timer));
|
||||
} else send();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/v1/forecast') {
|
||||
assert.equal(url.searchParams.get('temperature_unit'), 'celsius');
|
||||
assert.equal(url.searchParams.get('current'), 'temperature_2m,is_day,weather_code');
|
||||
if (forecastMode === 'fail') return json({}, 503);
|
||||
if (forecastMode === 'malformed')
|
||||
return json({ current: { temperature_2m: 500, weather_code: 2, is_day: 1 } });
|
||||
const send = () => json({ current: { temperature_2m: 14.2, weather_code: 2, is_day: 1 } });
|
||||
if (forecastMode === 'slow') {
|
||||
const timer = setTimeout(send, 700);
|
||||
response.on('close', () => clearTimeout(timer));
|
||||
} else send();
|
||||
return;
|
||||
}
|
||||
json({}, 404);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
async function run(command, args) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { cwd: root, stdio: 'inherit' });
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error('Weather smoke child timed out'));
|
||||
}, 30000);
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${command} exited ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
async function writePreferences(patch = {}) {
|
||||
await fs.writeFile(
|
||||
preferences,
|
||||
JSON.stringify({ background: 'dunes', reduced_motion: true, show_weather: true, ...patch }),
|
||||
);
|
||||
}
|
||||
async function writeCache(age = 0) {
|
||||
await fs.writeFile(
|
||||
cache,
|
||||
JSON.stringify({
|
||||
key: '48.2085,16.3721',
|
||||
updated_at: Math.floor(Date.now() / 1000) - age,
|
||||
reading,
|
||||
}),
|
||||
);
|
||||
}
|
||||
async function scenario(name, script, frames = 210) {
|
||||
const dump = path.join(temporary, `${name}.json`);
|
||||
const screenshot = path.join(out, `weather-${name}.png`);
|
||||
await run(binary, [
|
||||
'--preferences',
|
||||
preferences,
|
||||
'--weather-test-server',
|
||||
origin,
|
||||
'--script',
|
||||
script,
|
||||
'--frames',
|
||||
String(frames),
|
||||
'--time',
|
||||
'2026-10-21T20:41:00+02:00',
|
||||
'--screenshot',
|
||||
screenshot,
|
||||
'--dump-state',
|
||||
dump,
|
||||
]);
|
||||
const state = JSON.parse(await fs.readFile(dump, 'utf8'));
|
||||
assert.equal(state.error, null);
|
||||
assert.equal((await sharp(screenshot).stats()).channels[3].min, 255);
|
||||
return state;
|
||||
}
|
||||
const forecastCount = () => requests.filter((request) => request.path === '/v1/forecast').length;
|
||||
const searchCount = () => requests.filter((request) => request.path === '/v1/search').length;
|
||||
const locationPage = 'settings,down,down,down,enter,down,enter';
|
||||
try {
|
||||
await run('cargo', ['build', '--locked', '--package', 'luma']);
|
||||
await writePreferences();
|
||||
await scenario('unconfigured', 'home,down');
|
||||
assert.equal(requests.length, 0, 'No geolocation or automatic city discovery');
|
||||
const short = await scenario('short-query', `${locationPage},enter,back,enter`);
|
||||
assert.equal(short.weather.searching, false);
|
||||
assert.ok(short.weather.message);
|
||||
assert.equal(searchCount(), 0);
|
||||
|
||||
const chosen = await scenario(
|
||||
'choose-location',
|
||||
`${locationPage},type:1000 Wien & west/?,back,enter,await-search,down,enter,home,await-weather`,
|
||||
);
|
||||
assert.equal(chosen.preferences.weather_location.name, 'Vienna');
|
||||
assert.equal(chosen.weather.cache.reading.temperature, 14.2);
|
||||
assert.equal(chosen.weather.failed, false);
|
||||
assert.equal(searchCount(), 1);
|
||||
assert.equal(forecastCount(), 1);
|
||||
assert.equal(
|
||||
requests.find((request) => request.path === '/v1/search').query.name,
|
||||
'1000 Wien & west/?',
|
||||
);
|
||||
assert.equal(JSON.parse(await fs.readFile(cache, 'utf8')).reading.temperature, 14.2);
|
||||
|
||||
await scenario('cache-reopen', 'home,await-weather');
|
||||
assert.equal(forecastCount(), 1);
|
||||
const fahrenheit = await scenario(
|
||||
'fahrenheit',
|
||||
'settings,down,down,down,enter,down,down,enter,down,enter,home,await-weather',
|
||||
);
|
||||
assert.equal(fahrenheit.preferences.temperature_unit, 'fahrenheit');
|
||||
assert.equal(fahrenheit.weather.cache.reading.temperature, 14.2);
|
||||
assert.equal(forecastCount(), 1);
|
||||
const hidden = await scenario('widget-off', 'settings,down,down,down,enter,enter,home');
|
||||
assert.equal(hidden.preferences.show_weather, false);
|
||||
assert.equal(hidden.preferences.weather_location.name, 'Vienna');
|
||||
assert.equal(forecastCount(), 1);
|
||||
await writeCache(20 * 60);
|
||||
await scenario('disabled-stale-cache', 'home');
|
||||
assert.equal(forecastCount(), 1, 'A disabled widget must not refresh even stale weather');
|
||||
|
||||
await writePreferences({ weather_location: vienna });
|
||||
await writeCache(20 * 60);
|
||||
forecastMode = 'fail';
|
||||
const offline = await scenario('saved-offline', 'home,await-weather');
|
||||
assert.equal(offline.weather.failed, true);
|
||||
assert.equal(offline.weather.cache.reading.temperature, 14.2);
|
||||
assert.equal(forecastCount(), 2);
|
||||
await fs.rm(cache);
|
||||
const unavailable = await scenario('unavailable', 'home,await-weather');
|
||||
assert.equal(unavailable.weather.failed, true);
|
||||
assert.equal(unavailable.weather.cache, null);
|
||||
assert.equal(forecastCount(), 3);
|
||||
await writeCache(20 * 60);
|
||||
forecastMode = 'malformed';
|
||||
const invalid = await scenario('invalid-reading', 'home,await-weather');
|
||||
assert.equal(invalid.weather.failed, true);
|
||||
assert.equal(invalid.weather.cache.reading.temperature, 14.2);
|
||||
await fs.rm(cache);
|
||||
|
||||
forecastMode = 'slow';
|
||||
const removed = await scenario('remove-in-flight', `home,${locationPage},click:1716:201,home`);
|
||||
assert.equal(removed.preferences.weather_location, null);
|
||||
assert.equal(removed.weather.cache, null);
|
||||
await assert.rejects(fs.access(cache));
|
||||
|
||||
await writePreferences({ weather_location: vienna });
|
||||
await writeCache(7 * 60 * 60);
|
||||
forecastMode = 'fail';
|
||||
const expired = await scenario('expired-cache', 'home,await-weather');
|
||||
assert.equal(expired.weather.cache, null);
|
||||
assert.equal(expired.weather.failed, true);
|
||||
|
||||
await writePreferences();
|
||||
for (const query of ['nowhere', 'fail', 'malformed', 'oversized', 'redirect']) {
|
||||
const state = await scenario(query, `${locationPage},type:${query},back,enter,await-search`);
|
||||
assert.ok(state.weather.message);
|
||||
assert.equal(state.weather.results.length, 0);
|
||||
}
|
||||
assert.ok(
|
||||
!requests.some((request) => request.path === '/unexpected'),
|
||||
'No redirects to another endpoint',
|
||||
);
|
||||
const latest = await scenario(
|
||||
'latest-search',
|
||||
`${locationPage},type:slow,back,enter,click:900:197,click:1364:726,type:Paris,back,enter,await-search,down,enter`,
|
||||
);
|
||||
assert.equal(latest.preferences.weather_location.name, 'Paris');
|
||||
assert.ok(latest.weather.results.length === 0);
|
||||
console.log(
|
||||
'Native weather smoke checks passed: explicit search, request encoding, cached restart, units, visibility, offline/expired data, removal races, response limits and stale-search suppression.',
|
||||
);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
Reference in new issue
Block a user