Files
yellowjacket/frontend/test/utils/describe-error.test.ts
T
logan 5830b1ba17 test(frontend): cover the lifecycle, the voice and the repaints
Component and store cases for everything in this series, several of
which exist because the thing they pin is invisible everywhere else:

- `view-lifecycle` and `keyboard-reach` — a document listener count
  that does not grow across a simulated navigate cycle, and a tab
  sequence that reaches the sidebar and plays a row without a mouse.
- `notifications`, `notification-store`, `confirm-dialog`,
  `empty-states` — the four levels, the (level, region, key)
  coalescing window, and loading/failed/empty as three states.
- `card-grid-repaint` — fails if `artists-view`'s or `genres-view`'s
  per-render arrow functions are hoisted to stable fields, which is
  the audit's own recommendation and takes the cards from 1 highlighted
  to 0. It exists for no other reason.
- `lazy-track-details` — reads the five sources and fails on a
  returning static import, the same shape as `TestNoDirectRuntimeEmits`
  and for the same reason: the invariant is about what the code does
  *not* say.
- `now-playing` — a position report that changes nothing must not
  touch the DOM again, and a track change must. The first fails
  against the old unconditional `updated()`.
- `playlist-virtualization`, `list-render-cost`, `selection`, `icons`,
  and the store cases for the library-filter race, the never-settling
  waiter and the per-playlist patch.
2026-08-12 01:20:03 -04:00

138 lines
4.4 KiB
TypeScript

/**
* Eight places in this app render a Go error verbatim, so a person is
* shown `Get "https://musicbrainz.org/ws/2/…": context deadline
* exceeded` and asked to make something of it (errors.M9).
*
* `describeError` is the one map from those strings to a sentence. It
* is deliberately conservative: it recognises the handful of causes a
* user can act on and says something generic about everything else,
* because a wrong guess about a cause is worse than no guess.
*/
import { describe, expect, it } from 'vitest';
import { describeError, explainError } from '@utils/describe-error';
describe('describeError', () => {
const cases: Array<[label: string, raw: string, expected: RegExp]> = [
[
'a timed-out MusicBrainz lookup',
'Get "https://musicbrainz.org/ws/2/artist": context deadline exceeded',
/took too long/i,
],
[
'an http client timeout',
'Get "https://example.com": net/http: request canceled (Client.Timeout exceeded while awaiting headers)',
/took too long/i,
],
[
'a name that does not resolve',
'Get "https://musicbrainz.org": dial tcp: lookup musicbrainz.org: no such host',
/connection|offline|reach/i,
],
[
'a refused connection',
'Post "http://localhost:8080/api": dial tcp 127.0.0.1:8080: connect: connection refused',
/connection|offline|reach/i,
],
[
'a locked database',
"failed to remove 'Music': sql: database is locked",
/busy|in use/i,
],
[
'a file that moved',
'open /music/gone.mp3: no such file or directory',
/not be found|moved/i,
],
['a 404 from a provider', 'unexpected status 404 Not Found', /not be found/i],
[
'a file the app may not read',
'open /music/locked.flac: permission denied',
/permission/i,
],
[
'a folder Windows will not open',
'CreateFile C:\\Music: Access is denied.',
/permission/i,
],
['a cancelled operation', 'context canceled', /cancelled|canceled|stopped/i],
[
'a disk with nothing left',
'write /music/a.mp3: no space left on device',
/space/i,
],
];
for (const [label, raw, expected] of cases) {
it(`describes ${label}`, () => {
expect(describeError(new Error(raw))).toMatch(expected);
});
}
it('never leaks the raw Go string into the sentence', () => {
const raw =
'Get "https://musicbrainz.org/ws/2/artist": context deadline exceeded';
expect(describeError(new Error(raw))).not.toContain('context deadline');
});
it('falls back to something generic rather than guessing', () => {
const described = describeError(new Error('build plan: 7 of 9 rejected'));
expect([described.length > 0, described.includes('build plan')]).toEqual([
true,
false,
]);
});
it('accepts the shapes a rejected binding actually produces', () => {
// Wails rejects with whatever the Go error marshalled to, which is
// often a bare string and occasionally not a string at all.
expect([
describeError('sql: database is locked'),
describeError(null),
describeError({ message: 'permission denied' }),
]).toEqual([
describeError(new Error('sql: database is locked')),
describeError(new Error('')),
describeError(new Error('permission denied')),
]);
});
it('takes a caller-supplied fallback for the unrecognised case', () => {
expect(
describeError(new Error('build plan: 7 of 9 rejected'), 'Nothing was written.'),
).toBe('Nothing was written.');
});
});
/**
* Some backend errors are already sentences — the sentinels this app
* writes for conditions it defined. Those are the most useful thing to
* show, and dropping them for a generic line would be a regression.
*/
describe('explainError', () => {
it('repeats a sentinel the backend wrote for a person', () => {
expect(
explainError(new Error('a library with that name already exists: "Decoy"')),
).toContain('already exists');
});
it('does not repeat a wrapped runtime error', () => {
const described = explainError(
new Error('could not rename library: sql: database is locked'),
);
expect([described.includes('sql:'), /busy/i.test(described)]).toEqual([
false,
true,
]);
});
it('punctuates what it repeats', () => {
expect(explainError(new Error('no candidate selected'))).toBe(
'no candidate selected.',
);
});
});