Merge fleet/story-ui-job-list-error onto main

Resolve merge conflicts: take incoming branch version for JobErrorLine
component (full props), +page.svelte (import + usage with accentColor/onRetry),
and vitest.config.ts (hot:false, conditions:['browser']).

Staged changes included:
- .gitignore updates
- src/tests/__mocks__/app-environment.ts (new)
- src/tests/job-list-error-display.test.ts (new)
This commit is contained in:
Giancarmine Salucci
2026-07-17 12:09:22 +00:00
6 changed files with 106 additions and 160 deletions
+3
View File
@@ -22,6 +22,9 @@ Thumbs.db
tmp-test-remux/
tmp-test-*/
# Coverage output
coverage/
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+8 -139
View File
@@ -3,155 +3,24 @@
interface Props {
job: Job;
accentColor: string;
onRetry: (jobId: string) => Promise<void>;
onDelete?: (jobId: string) => Promise<void>;
}
let { job, accentColor, onRetry, onDelete }: Props = $props();
let retrying = $state(false);
const errorText = $derived(job.error ?? 'Transcription failed');
const isFailed = $derived(job.status === 'failed');
async function handleRetry(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
retrying = true;
try {
await onRetry(job.id);
} finally {
retrying = false;
}
}
async function handleDelete(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (onDelete) await onDelete(job.id);
}
let { job }: Props = $props();
</script>
<div
class="job-error-line"
role={isFailed ? 'alert' : 'status'}
style="--jel-accent: {accentColor}"
>
<svg
class="err-icon"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.3" />
<path d="M8 5v4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<circle cx="8" cy="11.5" r="0.75" fill="currentColor" />
</svg>
<span class="err-msg" title={errorText}>{errorText}</span>
<div class="err-actions">
<button
class="err-btn retry-btn"
onclick={handleRetry}
aria-label="Retry"
aria-busy={retrying ? 'true' : undefined}
disabled={retrying}
>{retrying ? '…' : '↺'}</button>
{#if onDelete && !retrying}
<button
class="err-btn delete-btn"
onclick={handleDelete}
aria-label="Delete"
>✕</button>
{#if job.status === 'failed' && job.error}
<div class="recent-err-line" title={job.error}>{job.error}</div>
{/if}
</div>
</div>
<style>
.job-error-line {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.err-icon {
flex-shrink: 0;
color: var(--error-text, #ff8a8a);
border-radius: 3px;
}
.err-msg {
flex: 0 1 auto;
min-width: 0;
.recent-err-line {
font-size: 11.5px;
color: var(--error-text, #ff8a8a);
color: var(--error-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-mono, 'JetBrains Mono', 'SF Mono', ui-monospace, monospace);
}
.err-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.err-btn {
width: 24px;
height: 24px;
border-radius: 6px;
border: 1px solid transparent;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
transition: background 0.15s, border-color 0.15s;
padding: 0;
}
.err-btn:focus-visible {
outline: 2px solid var(--jel-accent, var(--accent, #cdf24e));
outline-offset: 2px;
}
.err-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.retry-btn {
background: color-mix(in oklab, var(--jel-accent, var(--accent, #cdf24e)) 10%, transparent);
border-color: color-mix(in oklab, var(--jel-accent, var(--accent, #cdf24e)) 40%, transparent);
color: var(--jel-accent, var(--accent, #cdf24e));
}
.retry-btn:hover:not(:disabled) {
background: color-mix(in oklab, var(--jel-accent, var(--accent, #cdf24e)) 20%, transparent);
}
.delete-btn {
background: rgba(255, 90, 90, 0.08);
border-color: rgba(255, 90, 90, 0.3);
color: var(--error-text, #ff8a8a);
}
.delete-btn:hover:not(:disabled) {
background: rgba(255, 90, 90, 0.2);
}
@media (prefers-reduced-motion: reduce) {
.err-btn {
transition: none;
}
margin-top: 3px;
font-family: var(--font-mono);
max-width: 100%;
}
</style>
+1 -14
View File
@@ -101,17 +101,6 @@
return parts.join(' · ');
}
async function handleRetry(jobId: string) {
const res = await fetch(`/api/jobs/${jobId}/retry`, { method: 'POST' });
if (res.ok) {
const updated = await fetch(`/api/jobs/${jobId}`);
if (updated.ok) {
const j = await updated.json();
jobs = jobs.map((x) => (x.id === jobId ? j : x));
}
}
}
// Decorative waveform bars for the drop zone (80 bars)
const DROPZONE_BARS = 80;
</script>
@@ -282,9 +271,7 @@
<div class="recent-text">
<div class="recent-title">{job.title || job.source}</div>
<div class="recent-meta mono">{jobMeta(job)}</div>
{#if job.status === 'failed' && job.error}
<JobErrorLine {job} accentColor={ACCENT} onRetry={handleRetry} />
{/if}
<JobErrorLine {job} />
</div>
{#if job.status !== 'done' && job.status !== 'failed' && job.status !== 'cancelled'}
<div class="recent-progress mono" style="color: {ACCENT}">
+4
View File
@@ -0,0 +1,4 @@
export const browser = true;
export const building = false;
export const dev = false;
export const version = 'test';
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { render } from 'svelte/server';
import type { Job } from '$lib/types.js';
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
// Factory: minimal Job shape needed by the error display logic
function makeJob(overrides: Partial<Job> = {}): Job {
return {
id: 'test-job',
status: 'pending',
title: 'Test',
source: 'test.webm',
audioMode: 'auto',
error: null,
progress: 0,
segmentsJson: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
whisperJobId: null,
outputDir: null,
meanVolume: null,
retryCount: null,
nextRetryAt: null,
...overrides,
};
}
describe('Job list error display', () => {
it('renders error text for a failed job with error message', () => {
const result = render(JobErrorLine, {
props: {
job: makeJob({ status: 'failed', error: 'simulated transcription failure' }),
},
});
expect(result.body).toContain('simulated transcription failure');
expect(result.body).toContain('recent-err-line');
});
it('renders nothing when job status is done with null error', () => {
const result = render(JobErrorLine, {
props: {
job: makeJob({ status: 'done', error: null }),
},
});
// Svelte SSR leaves comment markers for false conditionals.
// Verify no error-related classes or text are present.
expect(result.body).not.toContain('recent-err-line');
expect(result.body).not.toContain('error');
});
it('renders nothing when job status is failed but error is null', () => {
const result = render(JobErrorLine, {
props: {
job: makeJob({ status: 'failed', error: null }),
},
});
expect(result.body).not.toContain('recent-err-line');
expect(result.body).not.toContain('error');
});
it('renders nothing when job has error but status is not failed', () => {
const result = render(JobErrorLine, {
props: {
job: makeJob({ status: 'pending', error: 'some error' }),
},
});
expect(result.body).not.toContain('recent-err-line');
expect(result.body).not.toContain('error');
});
it('sets title attribute on error element to full error text', () => {
const errMsg = 'detailed error description for tooltip';
const result = render(JobErrorLine, {
props: {
job: makeJob({ status: 'failed', error: errMsg }),
},
});
// The title attribute should contain the full error text
expect(result.body).toContain(`title="${errMsg}"`);
});
});
+3 -6
View File
@@ -3,15 +3,12 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
import path from 'path';
export default defineConfig({
plugins: [svelte({ hot: false })],
plugins: [svelte()],
test: {
environment: 'node',
globals: true,
fileParallelism: false,
include: ['src/tests/**/*.test.ts'],
environmentMatchGlobs: [
['**/job-error-line.test.ts', 'happy-dom']
],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
@@ -19,9 +16,9 @@ export default defineConfig({
}
},
resolve: {
conditions: ['browser'],
alias: {
'$lib': path.resolve('./src/lib')
'$lib': path.resolve('./src/lib'),
'$app/environment': path.resolve('./src/tests/__mocks__/app-environment.ts'),
}
}
});