Files
luma-home/tests/home-splash.test.mjs
Kaloyan DanchevandClaude Opus 4.6 cbea5a322f
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
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>
2026-10-24 11:52:55 +03:00

207 lines
7.4 KiB
JavaScript

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);
});