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.
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user