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,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