2026-09-22 14:12:13 +02:00
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
2026-08-30 15:11:25 +02:00
|
|
|
import fs from 'node:fs';
|
|
|
|
|
import os from 'node:os';
|
|
|
|
|
import path from 'node:path';
|
2026-10-02 18:14:23 +02:00
|
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
|
import { pathToFileURL } from 'node:url';
|
2026-08-30 15:11:25 +02:00
|
|
|
import yaml from 'js-yaml';
|
|
|
|
|
import type { CliFinding } from '../doctor.mts';
|
|
|
|
|
import {
|
|
|
|
|
EXIT_CODES,
|
|
|
|
|
EXIT_INTERNAL,
|
|
|
|
|
collectAlbumIds,
|
|
|
|
|
collectPasswords,
|
|
|
|
|
exitCodeFor,
|
|
|
|
|
currentUid,
|
|
|
|
|
findUnwritable,
|
|
|
|
|
formatReport,
|
|
|
|
|
frontmatterPassword,
|
2026-09-22 14:12:13 +02:00
|
|
|
gatherFindings,
|
2026-08-30 15:11:25 +02:00
|
|
|
reportWritable,
|
|
|
|
|
loadDotEnv,
|
|
|
|
|
resolveCredentials,
|
|
|
|
|
shouldUseColor,
|
|
|
|
|
wrap,
|
|
|
|
|
type EnvLike,
|
|
|
|
|
} from '../doctor.mts';
|
|
|
|
|
import type { DoctorFinding } from '../../lib/admin/doctor';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The CLI's own logic — the gathering, the formatting and the exit-code
|
|
|
|
|
* mapping. The checks themselves are covered by lib/__tests__/doctor.test.ts
|
|
|
|
|
* and are imported, not reimplemented (#521).
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
function tmpdir(): string {
|
|
|
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'folio-doctor-'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('exitCodeFor', () => {
|
|
|
|
|
it('maps clean to 0 and the rest upwards', () => {
|
|
|
|
|
expect(exitCodeFor('ok')).toBe(0);
|
|
|
|
|
expect(exitCodeFor('warn')).toBe(1);
|
|
|
|
|
expect(exitCodeFor('error')).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps the internal failure code out of the finding range', () => {
|
|
|
|
|
expect(EXIT_INTERNAL).toBeGreaterThan(Math.max(...Object.values(EXIT_CODES)));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('collectAlbumIds', () => {
|
|
|
|
|
it('finds standalone, subpage and section albums in one pass', () => {
|
|
|
|
|
const gallery = yaml.load(`
|
|
|
|
|
albums:
|
|
|
|
|
- "11111111-1111-4111-8111-111111111111"
|
|
|
|
|
subpages:
|
|
|
|
|
- name: Travel
|
|
|
|
|
albums:
|
|
|
|
|
- "22222222-2222-4222-8222-222222222222"
|
|
|
|
|
- name: Japan
|
|
|
|
|
sections:
|
|
|
|
|
- title: Kyoto
|
|
|
|
|
albums:
|
|
|
|
|
- "33333333-3333-4333-8333-333333333333"
|
|
|
|
|
`);
|
|
|
|
|
expect(collectAlbumIds(gallery)).toEqual([
|
|
|
|
|
'11111111-1111-4111-8111-111111111111',
|
|
|
|
|
'22222222-2222-4222-8222-222222222222',
|
|
|
|
|
'33333333-3333-4333-8333-333333333333',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reads the ID off the object form that carries a title or a password', () => {
|
|
|
|
|
const gallery = yaml.load(`
|
|
|
|
|
albums:
|
|
|
|
|
- "44444444-4444-4444-8444-444444444444": Hokkaido
|
|
|
|
|
- "55555555-5555-4555-8555-555555555555":
|
|
|
|
|
title: Kyoto
|
|
|
|
|
sort: filename
|
|
|
|
|
`);
|
|
|
|
|
expect(collectAlbumIds(gallery)).toEqual([
|
|
|
|
|
'44444444-4444-4444-8444-444444444444',
|
|
|
|
|
'55555555-5555-4555-8555-555555555555',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles the map form of subpages', () => {
|
|
|
|
|
const gallery = yaml.load(`
|
|
|
|
|
subpages:
|
|
|
|
|
Travel:
|
|
|
|
|
albums:
|
|
|
|
|
- "66666666-6666-4666-8666-666666666666"
|
|
|
|
|
`);
|
|
|
|
|
expect(collectAlbumIds(gallery)).toEqual(['66666666-6666-4666-8666-666666666666']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/** hero and assetOrder hold asset UUIDs — reporting them as albums would
|
|
|
|
|
* turn every configured hero image into a "does not exist" error. */
|
|
|
|
|
it('does not mistake asset IDs for album IDs', () => {
|
|
|
|
|
const gallery = yaml.load(`
|
|
|
|
|
hero:
|
|
|
|
|
- "77777777-7777-4777-8777-777777777777"
|
|
|
|
|
albums:
|
|
|
|
|
- "88888888-8888-4888-8888-888888888888":
|
|
|
|
|
sort: manual
|
|
|
|
|
assetOrder:
|
|
|
|
|
- "99999999-9999-4999-8999-999999999999"
|
|
|
|
|
`);
|
|
|
|
|
expect(collectAlbumIds(gallery)).toEqual(['88888888-8888-4888-8888-888888888888']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/** validateUuid() drops these rather than passing a typo on to Immich. */
|
|
|
|
|
it('drops entries that are not UUIDs, and deduplicates', () => {
|
|
|
|
|
const gallery = yaml.load(`
|
|
|
|
|
albums:
|
|
|
|
|
- "your-album-uuid-1"
|
|
|
|
|
- "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
|
|
|
|
subpages:
|
|
|
|
|
- name: Travel
|
|
|
|
|
albums:
|
|
|
|
|
- "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
|
|
|
|
`);
|
|
|
|
|
expect(collectAlbumIds(gallery)).toEqual(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('survives an empty or non-object document', () => {
|
|
|
|
|
expect(collectAlbumIds(null)).toEqual([]);
|
|
|
|
|
expect(collectAlbumIds('just a string')).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('collectPasswords', () => {
|
|
|
|
|
it('finds a password wherever it sits, and labels it by path', () => {
|
|
|
|
|
const gallery = yaml.load(`
|
|
|
|
|
subpages:
|
|
|
|
|
- name: Projects
|
|
|
|
|
password: "hunter2"
|
|
|
|
|
albums:
|
|
|
|
|
- "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb":
|
|
|
|
|
password: "scrypt:a:b"
|
|
|
|
|
`);
|
|
|
|
|
const found = collectPasswords(gallery, 'gallery.yaml');
|
|
|
|
|
expect(found).toHaveLength(2);
|
|
|
|
|
expect(found[0].label).toContain('Projects');
|
|
|
|
|
expect(found[0].value).toBe('hunter2');
|
|
|
|
|
expect(found[1].label).toContain('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('picks up the site password from settings.yaml', () => {
|
|
|
|
|
const settings = yaml.load('sitePassword: "scrypt:a:b"');
|
|
|
|
|
const found = collectPasswords(settings, 'settings.yaml');
|
|
|
|
|
expect(found).toHaveLength(1);
|
|
|
|
|
expect(found[0].label).toBe('settings.yaml sitePassword');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('ignores an empty password key', () => {
|
|
|
|
|
expect(collectPasswords(yaml.load('password: ""'), 'gallery.yaml')).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('frontmatterPassword', () => {
|
|
|
|
|
it('reads a quoted or bare value', () => {
|
|
|
|
|
expect(frontmatterPassword('---\ntitle: A\npassword: "scrypt:a:b"\n---\n\nbody')).toBe(
|
|
|
|
|
'scrypt:a:b',
|
|
|
|
|
);
|
|
|
|
|
expect(frontmatterPassword('---\npassword: hunter2\n---\n')).toBe('hunter2');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns null without frontmatter, or without a password in it', () => {
|
|
|
|
|
expect(frontmatterPassword('# Just markdown')).toBeNull();
|
|
|
|
|
expect(frontmatterPassword('---\ntitle: A\n---\n')).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/** The body of an entry may well talk about passwords. */
|
|
|
|
|
it('does not look past the frontmatter', () => {
|
|
|
|
|
expect(frontmatterPassword('---\ntitle: A\n---\n\npassword: not-one\n')).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('resolveCredentials', () => {
|
|
|
|
|
it('prefers the environment over install.json', () => {
|
|
|
|
|
const dir = tmpdir();
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
path.join(dir, 'install.json'),
|
|
|
|
|
JSON.stringify({ apiUrl: 'http://file:2283', apiKey: 'file-key', authSecret: 'file-secret' }),
|
|
|
|
|
);
|
|
|
|
|
const creds = resolveCredentials(dir, { IMMICH_API_URL: 'http://env:2283' });
|
|
|
|
|
expect(creds.apiUrl).toBe('http://env:2283');
|
|
|
|
|
expect(creds.apiKey).toBe('file-key');
|
|
|
|
|
expect(creds.authSecret).toBe('file-secret');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('treats an unparseable install.json as absent', () => {
|
|
|
|
|
const dir = tmpdir();
|
|
|
|
|
fs.writeFileSync(path.join(dir, 'install.json'), '{ not json');
|
|
|
|
|
expect(resolveCredentials(dir, {})).toEqual({ apiUrl: '', apiKey: '', authSecret: '' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('strips the trailing slash and empties an invalid URL, as env.ts does', () => {
|
|
|
|
|
const dir = tmpdir();
|
|
|
|
|
expect(resolveCredentials(dir, { IMMICH_API_URL: 'http://immich:2283/' }).apiUrl).toBe(
|
|
|
|
|
'http://immich:2283',
|
|
|
|
|
);
|
|
|
|
|
expect(resolveCredentials(dir, { IMMICH_API_URL: 'not a url' }).apiUrl).toBe('');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('loadDotEnv', () => {
|
|
|
|
|
it('reads .env.local ahead of .env, and never overwrites a real variable', () => {
|
|
|
|
|
const dir = tmpdir();
|
|
|
|
|
fs.writeFileSync(path.join(dir, '.env'), 'IMMICH_API_KEY=from-env\nAUTH_SECRET=from-env\n');
|
|
|
|
|
fs.writeFileSync(path.join(dir, '.env.local'), 'AUTH_SECRET="from-local"\n');
|
|
|
|
|
const env: EnvLike = { IMMICH_API_KEY: 'from-process' };
|
|
|
|
|
loadDotEnv(dir, env);
|
|
|
|
|
expect(env.IMMICH_API_KEY).toBe('from-process');
|
|
|
|
|
expect(env.AUTH_SECRET).toBe('from-local');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('drops trailing comments on unquoted values and keeps quoted ones whole', () => {
|
|
|
|
|
const dir = tmpdir();
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
path.join(dir, '.env'),
|
|
|
|
|
'A=plain # a comment\nB="quoted # hash"\nexport C=exported\n',
|
|
|
|
|
);
|
|
|
|
|
const env: EnvLike = {};
|
|
|
|
|
loadDotEnv(dir, env);
|
|
|
|
|
expect(env.A).toBe('plain');
|
|
|
|
|
expect(env.B).toBe('quoted # hash');
|
|
|
|
|
expect(env.C).toBe('exported');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is silent when neither file exists', () => {
|
|
|
|
|
const env: EnvLike = {};
|
|
|
|
|
expect(() => loadDotEnv(tmpdir(), env)).not.toThrow();
|
|
|
|
|
expect(env).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('findUnwritable', () => {
|
|
|
|
|
it('reports nothing for a writable content directory', () => {
|
|
|
|
|
const dir = tmpdir();
|
|
|
|
|
fs.mkdirSync(path.join(dir, 'journal'));
|
|
|
|
|
expect(findUnwritable(dir)).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/** The app creates these on first write; only one that exists and refuses
|
|
|
|
|
* writes is a fault. */
|
|
|
|
|
it('does not report a directory that has not been created yet', () => {
|
|
|
|
|
expect(findUnwritable(tmpdir())).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reports the owner alongside a path it cannot write', () => {
|
|
|
|
|
const dir = tmpdir();
|
|
|
|
|
const backups = path.join(dir, '.backups');
|
|
|
|
|
fs.mkdirSync(backups);
|
|
|
|
|
fs.chmodSync(backups, 0o555);
|
|
|
|
|
try {
|
|
|
|
|
const found = findUnwritable(dir);
|
|
|
|
|
// Running as root, 0555 is still writable — the case cannot be staged.
|
|
|
|
|
if (currentUid() === 0) return;
|
|
|
|
|
expect(found).toHaveLength(1);
|
|
|
|
|
expect(found[0].label).toBe('content/.backups');
|
|
|
|
|
expect(found[0].ownerUid).toBe(fs.statSync(backups).uid);
|
|
|
|
|
} finally {
|
|
|
|
|
fs.chmodSync(backups, 0o755);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The CLI tests whoever typed the command; the panel tests the app's own user.
|
|
|
|
|
* On a Docker deployment those differ, and calling that an error accused a
|
|
|
|
|
* healthy install (#551).
|
|
|
|
|
*/
|
|
|
|
|
describe('reportWritable', () => {
|
|
|
|
|
const mine = { label: 'content/.backups', ownerUid: 1000 };
|
|
|
|
|
const theirs = { label: 'content/.backups', ownerUid: 1001 };
|
|
|
|
|
|
|
|
|
|
it('passes when everything is writable', () => {
|
|
|
|
|
const finding = reportWritable([], 1000);
|
|
|
|
|
expect(finding.level).toBe('ok');
|
|
|
|
|
expect(finding.note).toBeUndefined();
|
|
|
|
|
expect(finding.title).toBe('content/ is writable');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is a real error when the CLI runs as the owner', () => {
|
|
|
|
|
const finding = reportWritable([mine], 1000);
|
|
|
|
|
expect(finding.level).toBe('error');
|
|
|
|
|
expect(finding.note).toBeUndefined();
|
|
|
|
|
expect(finding.detail).toContain('Saving from the admin panel will fail');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is a note, not an error, when the owner is someone else', () => {
|
|
|
|
|
const finding = reportWritable([theirs], 1000);
|
|
|
|
|
expect(finding.level).toBe('ok');
|
|
|
|
|
expect(finding.note).toBe(true);
|
|
|
|
|
expect(finding.title).toContain('uid 1000');
|
|
|
|
|
expect(finding.title).toContain('uid 1001');
|
|
|
|
|
expect(finding.detail).toContain('docker compose exec');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not downgrade a mixed result to a note', () => {
|
|
|
|
|
const finding = reportWritable([mine, theirs], 1000);
|
|
|
|
|
expect(finding.level).toBe('ok');
|
|
|
|
|
expect(finding.note).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('claims nothing on a platform without uids', () => {
|
|
|
|
|
const finding = reportWritable([theirs], null);
|
|
|
|
|
expect(finding.level).toBe('ok');
|
|
|
|
|
expect(finding.note).toBe(true);
|
|
|
|
|
expect(finding.title).not.toContain('undefined');
|
|
|
|
|
expect(finding.detail).toContain('may not be the account the app');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps the id the panel uses, whatever the outcome', () => {
|
|
|
|
|
for (const finding of [
|
|
|
|
|
reportWritable([], 1000),
|
|
|
|
|
reportWritable([mine], 1000),
|
|
|
|
|
reportWritable([theirs], 1000),
|
|
|
|
|
reportWritable([theirs], null),
|
|
|
|
|
]) {
|
|
|
|
|
expect(finding.id).toBe('content-writable');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('wrap', () => {
|
|
|
|
|
it('breaks at word boundaries and indents every line', () => {
|
|
|
|
|
expect(wrap('one two three four', 9, ' ')).toEqual([' one two', ' three', ' four']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaves a token longer than the width intact', () => {
|
|
|
|
|
expect(wrap('aaaaaaaaaaaa', 4, '')).toEqual(['aaaaaaaaaaaa']);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('formatReport', () => {
|
|
|
|
|
const findings: DoctorFinding[] = [
|
|
|
|
|
{ id: 'a', level: 'ok', title: 'Fine', detail: 'Nothing to do.' },
|
|
|
|
|
{ id: 'b', level: 'error', title: 'Broken', detail: 'Fix it.' },
|
|
|
|
|
{ id: 'c', level: 'warn', title: 'Odd', detail: 'Have a look.' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
it('groups by level, worst first', () => {
|
|
|
|
|
const report = formatReport(findings);
|
|
|
|
|
expect(report.indexOf('ERRORS (1)')).toBeLessThan(report.indexOf('WARNINGS (1)'));
|
|
|
|
|
expect(report.indexOf('WARNINGS (1)')).toBeLessThan(report.indexOf('PASSED (1)'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('ends with a count of each level', () => {
|
|
|
|
|
expect(formatReport(findings)).toContain('1 error, 1 warning, 1 check passed.');
|
|
|
|
|
expect(formatReport([])).toContain('0 errors, 0 warnings, 0 checks passed.');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('omits a level nobody hit', () => {
|
|
|
|
|
const report = formatReport([findings[0]]);
|
|
|
|
|
expect(report).not.toContain('ERRORS');
|
|
|
|
|
expect(report).not.toContain('WARNINGS');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('emits no escape sequences unless colour is asked for', () => {
|
|
|
|
|
const ansi = /\u001b\[/;
|
|
|
|
|
expect(ansi.test(formatReport(findings))).toBe(false);
|
|
|
|
|
expect(ansi.test(formatReport(findings, { color: true }))).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The proxy-hop finding carries level `ok` so it cannot skew the exit code,
|
|
|
|
|
// but the CLI never checked it — listing it under PASSED would claim a check
|
|
|
|
|
// that did not run.
|
|
|
|
|
describe('notes', () => {
|
|
|
|
|
const proxy: CliFinding = {
|
|
|
|
|
id: 'proxy-hops',
|
|
|
|
|
level: 'ok',
|
|
|
|
|
note: true,
|
|
|
|
|
title: 'TRUSTED_PROXY_HOPS is 0 (unset) — not verifiable from the terminal',
|
|
|
|
|
detail: 'Open the Diagnostics panel to have it measured.',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it('needs the flag, not the id: the same check can be a real finding', () => {
|
|
|
|
|
const notANote = { ...proxy, note: undefined };
|
|
|
|
|
const report = formatReport([notANote]);
|
|
|
|
|
expect(report).not.toContain('NOTES');
|
|
|
|
|
expect(report).toContain('PASSED (1)');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('lists a note in its own group rather than under PASSED', () => {
|
|
|
|
|
const report = formatReport([...findings, proxy]);
|
|
|
|
|
expect(report).toContain('NOTES (1)');
|
|
|
|
|
expect(report).toContain('PASSED (1)');
|
|
|
|
|
expect(report).toContain(proxy.title);
|
|
|
|
|
expect(report.indexOf(proxy.title)).toBeGreaterThan(report.indexOf('NOTES (1)'));
|
|
|
|
|
expect(report.indexOf(proxy.title)).toBeLessThan(report.indexOf('PASSED (1)'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('sits between the warnings and the passes', () => {
|
|
|
|
|
const report = formatReport([...findings, proxy]);
|
|
|
|
|
expect(report.indexOf('WARNINGS (1)')).toBeLessThan(report.indexOf('NOTES (1)'));
|
|
|
|
|
expect(report.indexOf('NOTES (1)')).toBeLessThan(report.indexOf('PASSED (1)'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is counted separately, not as a passed check', () => {
|
|
|
|
|
expect(formatReport([...findings, proxy])).toContain(
|
|
|
|
|
'1 error, 1 warning, 1 check passed, 1 note.',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('says nothing about notes when there are none', () => {
|
|
|
|
|
const report = formatReport(findings);
|
|
|
|
|
expect(report).not.toContain('NOTES');
|
|
|
|
|
expect(report).toContain('1 error, 1 warning, 1 check passed.');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('shouldUseColor', () => {
|
|
|
|
|
it('follows the terminal by default', () => {
|
|
|
|
|
expect(shouldUseColor({}, true)).toBe(true);
|
|
|
|
|
expect(shouldUseColor({}, false)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('honours NO_COLOR above everything', () => {
|
|
|
|
|
expect(shouldUseColor({ NO_COLOR: '1', FORCE_COLOR: '1' }, true)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('honours FORCE_COLOR when the output is piped', () => {
|
|
|
|
|
expect(shouldUseColor({ FORCE_COLOR: '1' }, false)).toBe(true);
|
|
|
|
|
expect(shouldUseColor({ FORCE_COLOR: '0' }, false)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-09-22 14:12:13 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* `if (albums.length)` used to skip the album check entirely when Immich
|
|
|
|
|
* answered with an empty list — an API key regenerated under a different
|
|
|
|
|
* account, say — so every connection check passed while every album page
|
|
|
|
|
* was silently empty (#629). gatherFindings is the whole pipeline; these
|
|
|
|
|
* cover it end to end rather than just the pure checkAlbumIds function.
|
|
|
|
|
*/
|
|
|
|
|
describe('gatherFindings — album checks (#629)', () => {
|
|
|
|
|
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
|
|
|
|
|
|
|
|
|
|
function envFor(dir: string): EnvLike {
|
|
|
|
|
return {
|
|
|
|
|
INSTALL_CONTENT_DIR: dir,
|
|
|
|
|
IMMICH_API_URL: 'https://immich.example',
|
|
|
|
|
IMMICH_API_KEY: 'key',
|
|
|
|
|
AUTH_SECRET: 'x'.repeat(32),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stubEmptyAlbumsFetch() {
|
|
|
|
|
const fetchMock = vi.fn(async (url: string) => {
|
|
|
|
|
if (url.includes('/albums')) {
|
|
|
|
|
return { ok: true, json: async () => [] } as Response;
|
|
|
|
|
}
|
|
|
|
|
return { ok: true, json: async () => ({}) } as Response;
|
|
|
|
|
});
|
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('reports every configured album as missing when Immich returns an empty list', async () => {
|
|
|
|
|
const dir = tmpdir();
|
|
|
|
|
try {
|
|
|
|
|
fs.writeFileSync(path.join(dir, 'gallery.yaml'), `albums:\n - ${ALBUM_ID}\n`);
|
|
|
|
|
stubEmptyAlbumsFetch();
|
|
|
|
|
|
|
|
|
|
const findings = await gatherFindings(dir, envFor(dir));
|
|
|
|
|
const albumFinding = findings.find((f) => f.id === 'album-ids');
|
|
|
|
|
|
|
|
|
|
expect(albumFinding?.level).toBe('error');
|
|
|
|
|
expect(albumFinding?.title).toContain('1 of 1');
|
|
|
|
|
} finally {
|
|
|
|
|
vi.unstubAllGlobals();
|
|
|
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('still warns "no albums configured" when none are configured at all', async () => {
|
|
|
|
|
const dir = tmpdir();
|
|
|
|
|
try {
|
|
|
|
|
fs.writeFileSync(path.join(dir, 'gallery.yaml'), 'albums: []\n');
|
|
|
|
|
stubEmptyAlbumsFetch();
|
|
|
|
|
|
|
|
|
|
const findings = await gatherFindings(dir, envFor(dir));
|
|
|
|
|
const albumFinding = findings.find((f) => f.id === 'album-ids');
|
|
|
|
|
|
|
|
|
|
expect(albumFinding?.level).toBe('warn');
|
|
|
|
|
expect(albumFinding?.title).toContain('No albums configured');
|
|
|
|
|
} finally {
|
|
|
|
|
vi.unstubAllGlobals();
|
|
|
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-02 18:14:23 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* `npm run doctor` runs the settings-values check the panel runs (QA A-14).
|
|
|
|
|
* Under Vitest an extensionless import resolves fine, so the second test
|
|
|
|
|
* loads the script in a real Node process: that is the runtime which refuses
|
|
|
|
|
* one, and the reason the CLI could not run this check before.
|
|
|
|
|
*/
|
|
|
|
|
describe('gatherFindings — setting values (QA A-14)', () => {
|
|
|
|
|
it('warns about values the resolvers would clamp or replace', async () => {
|
|
|
|
|
const dir = tmpdir();
|
|
|
|
|
try {
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
path.join(dir, 'settings.yaml'),
|
|
|
|
|
'theme:\n accent: rot\ncontact:\n retentionDays: 400\n',
|
|
|
|
|
);
|
|
|
|
|
const findings = await gatherFindings(dir, { INSTALL_CONTENT_DIR: dir });
|
|
|
|
|
const finding = findings.find((f) => f.id === 'settings-values');
|
|
|
|
|
expect(finding?.level).toBe('warn');
|
|
|
|
|
expect(finding?.detail).toContain('accent colour, message retention');
|
|
|
|
|
} finally {
|
|
|
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('loads under Node type stripping, without a bundler resolving paths', () => {
|
|
|
|
|
const script = path.resolve(__dirname, '../doctor.mts');
|
|
|
|
|
const result = spawnSync(
|
|
|
|
|
process.execPath,
|
|
|
|
|
[
|
|
|
|
|
'--disable-warning=MODULE_TYPELESS_PACKAGE_JSON',
|
|
|
|
|
'--input-type=module',
|
|
|
|
|
'-e',
|
|
|
|
|
`const m = await import(${JSON.stringify(pathToFileURL(script).href)});` +
|
|
|
|
|
'process.stdout.write(typeof m.gatherFindings);',
|
|
|
|
|
],
|
|
|
|
|
{ encoding: 'utf8' },
|
|
|
|
|
);
|
|
|
|
|
expect(result.stderr).toBe('');
|
|
|
|
|
expect(result.stdout).toBe('function');
|
|
|
|
|
});
|
|
|
|
|
});
|