Merge Luma launcher fork into monorepo
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s

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:
Kaloyan DanchevandClaude Opus 4.6 committed 2026-10-24 11:52:55 +03:00
1 parent bc3ca0f3b9
commit cbea5a322f
204 files changed
+25056 -65

No files matched your search

+39
View File
@@ -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/,
);
});
+87
View File
@@ -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 });
}
+37
View File
@@ -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 }
]
+206
View File
@@ -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);
});
+173
View File
@@ -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');
});
+220
View 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
View File
@@ -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 });
}
+142
View File
@@ -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, []);
});
+264
View File
@@ -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 });
}
+244
View File
@@ -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 });
}