D2: JobErrorLine component + home/jobs page integration
- Add src/lib/components/JobErrorLine.svelte: reusable error banner with icon, truncated message, accent retry and danger delete buttons; Svelte 5 runes; WCAG AA (role=alert/status, aria-busy, aria-label, focus ring) - Integrate into src/routes/+page.svelte: replace .recent-err-line div with JobErrorLine; add handleRetry function updating jobs array in-place - Integrate into src/routes/jobs/+page.svelte: remove inline .job-err-inline and .row-retry-slot; add JobErrorLine inside .job-info for failed/cancelled; refactor retryJobById/deleteJobById with clean (jobId: string) signatures; done-job delete preserved in row-actions - Add src/tests/job-error-line.test.ts: 8 component rendering tests via @testing-library/svelte + happy-dom covering message, buttons, callbacks, fallback text, and ARIA roles - Install @testing-library/svelte@5 and happy-dom as devDeps for component test support; update vitest.config.ts with svelte plugin, happy-dom env match glob, and browser resolve condition for Svelte 5 client build Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
0810c223e1
commit
dd438d2a0b
Generated
+280
-3
@@ -20,9 +20,11 @@
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@testing-library/svelte": "^5.4.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"happy-dom": "^20.10.6",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
@@ -30,6 +32,21 @@
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
@@ -41,9 +58,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -66,6 +83,16 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
@@ -1245,6 +1272,76 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/svelte": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.4.2.tgz",
|
||||
"integrity": "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "9.x.x || 10.x.x",
|
||||
"@testing-library/svelte-core": "1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0",
|
||||
"vite": "*",
|
||||
"vitest": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vite": {
|
||||
"optional": true
|
||||
},
|
||||
"vitest": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/svelte-core": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz",
|
||||
"integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
@@ -1255,6 +1352,13 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
@@ -1327,6 +1431,23 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
|
||||
@@ -1502,6 +1623,29 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
@@ -1667,6 +1811,19 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-image-size": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
|
||||
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -1823,6 +1980,16 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -1838,6 +2005,13 @@
|
||||
"integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1884,6 +2058,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -2149,6 +2336,25 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "20.10.6",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.10.6.tgz",
|
||||
"integrity": "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"buffer-image-size": "^0.6.4",
|
||||
"entities": "^7.0.1",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -2338,6 +2544,13 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
@@ -2623,6 +2836,16 @@
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -2927,6 +3150,21 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
@@ -2952,6 +3190,13 @@
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@@ -3679,6 +3924,16 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
@@ -3702,6 +3957,28 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/youtube-transcript": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/youtube-transcript/-/youtube-transcript-1.3.1.tgz",
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@testing-library/svelte": "^5.4.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"happy-dom": "^20.10.6",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import type { Job } from '$lib/types.js';
|
||||
|
||||
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);
|
||||
}
|
||||
</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}
|
||||
</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;
|
||||
font-size: 11.5px;
|
||||
color: var(--error-text, #ff8a8a);
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+13
-12
@@ -5,6 +5,7 @@
|
||||
import SourceIcon from '$lib/components/SourceIcon.svelte';
|
||||
import Waveform from '$lib/components/Waveform.svelte';
|
||||
import RecordButton from '$lib/components/RecordButton.svelte';
|
||||
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
|
||||
import { accent } from '$lib/accent.js';
|
||||
|
||||
const ACCENT = $derived($accent.value);
|
||||
@@ -100,6 +101,17 @@
|
||||
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>
|
||||
@@ -271,7 +283,7 @@
|
||||
<div class="recent-title">{job.title || job.source}</div>
|
||||
<div class="recent-meta mono">{jobMeta(job)}</div>
|
||||
{#if job.status === 'failed' && job.error}
|
||||
<div class="recent-err-line" title={job.error}>{job.error}</div>
|
||||
<JobErrorLine {job} accentColor={ACCENT} onRetry={handleRetry} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if job.status !== 'done' && job.status !== 'failed' && job.status !== 'cancelled'}
|
||||
@@ -568,17 +580,6 @@
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.recent-err-line {
|
||||
font-size: 11.5px;
|
||||
color: var(--error-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 3px;
|
||||
font-family: var(--font-mono);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── Utilities ─────────────────────────────────────────── */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { getDisplayJobProgress, getJobStatusColor, getJobStatusLabel, isTerminalJobStatus } from '$lib/job-progress.js';
|
||||
import SourceIcon from '$lib/components/SourceIcon.svelte';
|
||||
import Waveform from '$lib/components/Waveform.svelte';
|
||||
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
|
||||
import { accent } from '$lib/accent.js';
|
||||
|
||||
const ACCENT = $derived($accent.value);
|
||||
@@ -33,23 +34,27 @@
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function deleteJobById(jobId: string) {
|
||||
const job = jobs.find((j) => j.id === jobId);
|
||||
if (!job) return;
|
||||
if (!confirm(`Delete "${job.title || job.id}"?`)) return;
|
||||
const res = await fetch(`/api/jobs/${jobId}`, { method: 'DELETE' });
|
||||
if (res.ok) jobs = jobs.filter((j) => j.id !== jobId);
|
||||
}
|
||||
|
||||
async function deleteJob(e: MouseEvent, job: Job) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Delete "${job.title || job.id}"?`)) return;
|
||||
const res = await fetch(`/api/jobs/${job.id}`, { method: 'DELETE' });
|
||||
if (res.ok) jobs = jobs.filter((j) => j.id !== job.id);
|
||||
await deleteJobById(job.id);
|
||||
}
|
||||
|
||||
async function retryJob(e: MouseEvent, job: Job) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const res = await fetch(`/api/jobs/${job.id}/retry`, { method: 'POST' });
|
||||
async function retryJobById(jobId: string) {
|
||||
const res = await fetch(`/api/jobs/${jobId}/retry`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const updated = await fetch(`/api/jobs/${job.id}`);
|
||||
const updated = await fetch(`/api/jobs/${jobId}`);
|
||||
if (updated.ok) {
|
||||
const j = await updated.json();
|
||||
jobs = jobs.map((x) => (x.id === job.id ? j : x));
|
||||
jobs = jobs.map((x) => (x.id === jobId ? j : x));
|
||||
}
|
||||
} else {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
@@ -99,11 +104,15 @@
|
||||
<span>·</span>
|
||||
<span>{job.audioMode}</span>
|
||||
{/if}
|
||||
{#if ['failed', 'cancelled'].includes(job.status) && job.error}
|
||||
<span>·</span>
|
||||
<span class="job-err-inline" title={job.error}>{job.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ['failed', 'cancelled'].includes(job.status) && job.error}
|
||||
<JobErrorLine
|
||||
{job}
|
||||
accentColor={ACCENT}
|
||||
onRetry={retryJobById}
|
||||
onDelete={deleteJobById}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isTerminalJobStatus(job.status)}
|
||||
@@ -120,27 +129,11 @@
|
||||
<div class="job-pct mono" style="color: {ACCENT}">
|
||||
{getDisplayJobProgress(job, { hasTranscript: Boolean(job.segmentsJson) })}%
|
||||
</div>
|
||||
{:else}
|
||||
<div class="job-pct mono" style="color: {getJobStatusColor(job.status)}">
|
||||
{getJobStatusLabel(job.status)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Retry slot (always visible) -->
|
||||
{#if ['failed', 'cancelled'].includes(job.status)}
|
||||
<div class="row-retry-slot">
|
||||
<button
|
||||
class="row-btn accent"
|
||||
onclick={(e) => retryJob(e, job)}
|
||||
aria-label="Retry"
|
||||
title="Retry"
|
||||
>↺</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Row actions (hover-revealed, delete only) -->
|
||||
<!-- Row actions (hover-revealed, delete for done jobs) -->
|
||||
<div class="row-actions">
|
||||
{#if isTerminalJobStatus(job.status)}
|
||||
{#if job.status === 'done'}
|
||||
<button
|
||||
class="row-btn danger"
|
||||
onclick={(e) => deleteJob(e, job)}
|
||||
@@ -256,21 +249,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row-retry-slot {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.job-err-inline {
|
||||
color: var(--error-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
max-width: 160px;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.job-row[data-status="failed"] .job-name {
|
||||
color: var(--error-text);
|
||||
}
|
||||
@@ -296,14 +274,6 @@
|
||||
.row-btn.danger:hover {
|
||||
background: rgba(255, 90, 90, 0.2);
|
||||
}
|
||||
.row-btn.accent {
|
||||
border-color: color-mix(in oklab, var(--accent) 40%, transparent);
|
||||
background: color-mix(in oklab, var(--accent) 10%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.row-btn.accent:hover {
|
||||
background: color-mix(in oklab, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, fireEvent } from '@testing-library/svelte';
|
||||
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
|
||||
import type { Job } from '$lib/types.js';
|
||||
|
||||
function makeJob(overrides: Partial<Job> = {}): Job {
|
||||
return {
|
||||
id: 'job-1',
|
||||
status: 'failed',
|
||||
title: 'Test Job',
|
||||
source: 'test.mp3',
|
||||
audioMode: 'auto',
|
||||
meanVolume: null,
|
||||
whisperJobId: null,
|
||||
progress: 0,
|
||||
outputDir: null,
|
||||
segmentsJson: null,
|
||||
error: 'transcription timeout',
|
||||
retryCount: 0,
|
||||
nextRetryAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('JobErrorLine component', () => {
|
||||
it('renders error message for failed job', () => {
|
||||
const { getByText } = render(JobErrorLine, {
|
||||
job: makeJob(),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(getByText('transcription timeout')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows retry button labeled ↺', () => {
|
||||
const { getByRole } = render(JobErrorLine, {
|
||||
job: makeJob(),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(getByRole('button', { name: 'Retry' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onRetry with job id when retry button clicked', async () => {
|
||||
const onRetry = vi.fn().mockResolvedValue(undefined);
|
||||
const { getByRole } = render(JobErrorLine, {
|
||||
job: makeJob({ id: 'job-abc' }),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry
|
||||
});
|
||||
await fireEvent.click(getByRole('button', { name: 'Retry' }));
|
||||
expect(onRetry).toHaveBeenCalledWith('job-abc');
|
||||
});
|
||||
|
||||
it('shows fallback text when job.error is null', () => {
|
||||
const { getByText } = render(JobErrorLine, {
|
||||
job: makeJob({ error: null }),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(getByText('Transcription failed')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows delete button when onDelete provided', () => {
|
||||
const { getByRole } = render(JobErrorLine, {
|
||||
job: makeJob(),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined),
|
||||
onDelete: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(getByRole('button', { name: 'Delete' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides delete button when onDelete not provided', () => {
|
||||
const { queryByRole } = render(JobErrorLine, {
|
||||
job: makeJob(),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(queryByRole('button', { name: 'Delete' })).toBeNull();
|
||||
});
|
||||
|
||||
it('container has role alert for failed jobs', () => {
|
||||
const { getByRole } = render(JobErrorLine, {
|
||||
job: makeJob({ status: 'failed' }),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(getByRole('alert')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('container has role status for cancelled jobs', () => {
|
||||
const { getByRole } = render(JobErrorLine, {
|
||||
job: makeJob({ status: 'cancelled' }),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(getByRole('status')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ hot: false })],
|
||||
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'],
|
||||
@@ -14,6 +19,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
conditions: ['browser'],
|
||||
alias: {
|
||||
'$lib': path.resolve('./src/lib')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user