fix: show Laya model download progress and surface errors in Settings (#12)
Track download progress in layaService (bytes/total, phase, error state) and expose it through the laya status route. Render a progress bar during model download in Settings via a layaStatusPresentation service, and display download/status errors inline instead of failing silently. Cover progress tracking and status presentation with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -20,6 +20,77 @@ test('status reports an unavailable runtime without throwing', async () => {
|
||||
const status = await service.status();
|
||||
assert.equal(status.runtimeAvailable, false);
|
||||
assert.equal(status.modelPresent, false);
|
||||
assert.equal(status.phase, 'idle');
|
||||
assert.equal(status.downloading, false);
|
||||
assert.equal(status.progressPercent, null);
|
||||
assert.equal(status.bytesDownloaded, null);
|
||||
assert.equal(status.totalBytes, null);
|
||||
assert.equal(status.sizeBytes, 0);
|
||||
assert.equal(status.error, null);
|
||||
});
|
||||
|
||||
test('model download exposes an indeterminate transition and finishes ready', async () => {
|
||||
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-download-'));
|
||||
let finishDownload;
|
||||
const downloadResponse = new Promise(resolve => { finishDownload = resolve; });
|
||||
const service = new LayaService({
|
||||
modelDir,
|
||||
cache: null,
|
||||
progressIntervalMs: 5,
|
||||
fetch: async url => {
|
||||
if (url.endsWith('/health')) return { ok: true, json: async () => ({ status: 'ok' }) };
|
||||
await downloadResponse;
|
||||
return { ok: true, json: async () => ({ answers: {} }) };
|
||||
}
|
||||
});
|
||||
|
||||
const started = service.startModelDownload();
|
||||
assert.equal(started.phase, 'preparing');
|
||||
assert.equal(started.downloading, true);
|
||||
assert.equal(started.progressPercent, null);
|
||||
|
||||
await fs.writeFile(path.join(modelDir, 'partial.bin'), 'partial model');
|
||||
await new Promise(resolve => setTimeout(resolve, 15));
|
||||
const downloading = await service.status();
|
||||
assert.equal(downloading.phase, 'downloading');
|
||||
assert.equal(downloading.downloading, true);
|
||||
assert.ok(downloading.bytesDownloaded > 0);
|
||||
|
||||
finishDownload();
|
||||
await service.downloadPromise;
|
||||
const ready = await service.status();
|
||||
assert.equal(ready.phase, 'ready');
|
||||
assert.equal(ready.downloading, false);
|
||||
assert.equal(ready.modelPresent, true);
|
||||
assert.equal(ready.error, null);
|
||||
});
|
||||
|
||||
test('download failures remain visible in status and retry resets the error', async () => {
|
||||
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-failure-'));
|
||||
let shouldFail = true;
|
||||
const service = new LayaService({
|
||||
modelDir,
|
||||
cache: null,
|
||||
fetch: async url => {
|
||||
if (url.endsWith('/health')) throw new Error('connection refused at https://secret.invalid/token');
|
||||
if (shouldFail) throw new Error('download failed at https://secret.invalid/token');
|
||||
return { ok: true, json: async () => ({ answers: {} }) };
|
||||
}
|
||||
});
|
||||
|
||||
service.startModelDownload();
|
||||
await service.downloadPromise;
|
||||
const failed = await service.status();
|
||||
assert.equal(failed.phase, 'failed');
|
||||
assert.equal(failed.downloading, false);
|
||||
assert.equal(failed.error.message, 'Laya runtime is unavailable');
|
||||
assert.doesNotMatch(JSON.stringify(failed), /secret\.invalid|token/);
|
||||
|
||||
shouldFail = false;
|
||||
const retry = service.startModelDownload();
|
||||
assert.equal(retry.error, null);
|
||||
assert.equal(retry.lastError, null);
|
||||
await service.downloadPromise;
|
||||
});
|
||||
|
||||
test('model deletion remains limited to the configured model directory', async () => {
|
||||
@ -30,10 +101,16 @@ test('model deletion remains limited to the configured model directory', async (
|
||||
await fs.writeFile(path.join(modelDir, 'nested', 'model.bin'), 'model');
|
||||
await fs.writeFile(sentinel, 'keep');
|
||||
|
||||
await clearDirectoryContents(modelDir);
|
||||
const service = new LayaService({ modelDir, cache: null, fetch: async () => { throw new Error('offline'); } });
|
||||
service.downloadState.phase = 'failed';
|
||||
service.downloadState.error = { message: 'previous error', detail: 'safe' };
|
||||
const deleted = await service.deleteModel();
|
||||
|
||||
assert.deepEqual(await fs.readdir(modelDir), []);
|
||||
assert.equal(await fs.readFile(sentinel, 'utf8'), 'keep');
|
||||
assert.equal(deleted.phase, 'idle');
|
||||
assert.equal(deleted.error, null);
|
||||
assert.equal(deleted.sizeBytes, 0);
|
||||
});
|
||||
|
||||
test('batch validation rejects oversized item counts', () => {
|
||||
|
||||
Reference in New Issue
Block a user