egaki 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +140 -9
- package/dist/cli/cache-utils.d.ts +33 -0
- package/dist/cli/cache-utils.d.ts.map +1 -0
- package/dist/cli/cache-utils.js +157 -0
- package/dist/cli/cached-generate.d.ts +105 -0
- package/dist/cli/cached-generate.d.ts.map +1 -0
- package/dist/cli/cached-generate.js +208 -0
- package/dist/cli/cached-generate.test.d.ts +11 -0
- package/dist/cli/cached-generate.test.d.ts.map +1 -0
- package/dist/cli/cached-generate.test.js +431 -0
- package/dist/cli/cartesia-provider.d.ts +16 -0
- package/dist/cli/cartesia-provider.d.ts.map +1 -0
- package/dist/cli/cartesia-provider.js +184 -0
- package/dist/cli/chatgpt-auth.d.ts.map +1 -0
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.d.ts.map +1 -0
- package/dist/{cli.js → cli/cli.js} +626 -600
- package/dist/cli/clone-generate.d.ts +26 -0
- package/dist/cli/clone-generate.d.ts.map +1 -0
- package/dist/cli/clone-generate.js +148 -0
- package/dist/cli/credentials.d.ts.map +1 -0
- package/dist/{credentials.js → cli/credentials.js} +20 -0
- package/dist/cli/elevenlabs-speech-provider.d.ts +26 -0
- package/dist/cli/elevenlabs-speech-provider.d.ts.map +1 -0
- package/dist/cli/elevenlabs-speech-provider.js +199 -0
- package/dist/cli/generate.d.ts +140 -0
- package/dist/cli/generate.d.ts.map +1 -0
- package/dist/cli/generate.js +604 -0
- package/dist/cli/generate.test.d.ts +2 -0
- package/dist/cli/generate.test.d.ts.map +1 -0
- package/dist/cli/generate.test.js +131 -0
- package/dist/cli/login.d.ts.map +1 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +7 -0
- package/dist/{model-catalog.d.ts → cli/model-catalog.d.ts} +6 -0
- package/dist/cli/model-catalog.d.ts.map +1 -0
- package/dist/{model-catalog.js → cli/model-catalog.js} +714 -4
- package/dist/{models.d.ts → cli/models.d.ts} +12 -5
- package/dist/cli/models.d.ts.map +1 -0
- package/dist/{models.js → cli/models.js} +58 -57
- package/dist/cli/open-browser.d.ts.map +1 -0
- package/dist/cli/openai-speech-provider.d.ts +3 -0
- package/dist/cli/openai-speech-provider.d.ts.map +1 -0
- package/dist/cli/openai-speech-provider.js +28 -0
- package/dist/cli/separation-generate.d.ts +34 -0
- package/dist/cli/separation-generate.d.ts.map +1 -0
- package/dist/cli/separation-generate.js +117 -0
- package/dist/cli/speech-catalog.d.ts +35 -0
- package/dist/cli/speech-catalog.d.ts.map +1 -0
- package/dist/cli/speech-catalog.js +195 -0
- package/dist/cli/speech-generate.d.ts +86 -0
- package/dist/cli/speech-generate.d.ts.map +1 -0
- package/dist/cli/speech-generate.js +156 -0
- package/dist/cli/speech-models.d.ts +8 -0
- package/dist/cli/speech-models.d.ts.map +1 -0
- package/dist/cli/speech-models.js +109 -0
- package/dist/cli/speech-provider.test.d.ts +2 -0
- package/dist/cli/speech-provider.test.d.ts.map +1 -0
- package/dist/cli/speech-provider.test.js +197 -0
- package/dist/cli/subscription.d.ts.map +1 -0
- package/dist/cli/transcription-catalog.d.ts +35 -0
- package/dist/cli/transcription-catalog.d.ts.map +1 -0
- package/dist/cli/transcription-catalog.js +192 -0
- package/dist/cli/transcription-generate.d.ts +62 -0
- package/dist/cli/transcription-generate.d.ts.map +1 -0
- package/dist/cli/transcription-generate.js +144 -0
- package/dist/cli/transcription-models.d.ts +9 -0
- package/dist/cli/transcription-models.d.ts.map +1 -0
- package/dist/cli/transcription-models.js +100 -0
- package/dist/cli/transcription-providers.d.ts +22 -0
- package/dist/cli/transcription-providers.d.ts.map +1 -0
- package/dist/cli/transcription-providers.js +290 -0
- package/dist/cli/video-model-catalog.d.ts.map +1 -0
- package/dist/cli/xai-auth.d.ts.map +1 -0
- package/dist/{xai-auth.js → cli/xai-auth.js} +1 -1
- package/dist/vite/angled-screen.d.ts +57 -0
- package/dist/vite/angled-screen.d.ts.map +1 -0
- package/dist/vite/angled-screen.js +115 -0
- package/dist/vite/app.d.ts +51 -0
- package/dist/vite/app.d.ts.map +1 -0
- package/dist/vite/app.js +150 -0
- package/dist/vite/bands-shader.d.ts +47 -0
- package/dist/vite/bands-shader.d.ts.map +1 -0
- package/dist/vite/bands-shader.js +267 -0
- package/dist/vite/code-block.d.ts +107 -0
- package/dist/vite/code-block.d.ts.map +1 -0
- package/dist/vite/code-block.js +641 -0
- package/dist/vite/components.d.ts +36 -0
- package/dist/vite/components.d.ts.map +1 -0
- package/dist/vite/components.js +113 -0
- package/dist/vite/dispersion-rings-shader.d.ts +44 -0
- package/dist/vite/dispersion-rings-shader.d.ts.map +1 -0
- package/dist/vite/dispersion-rings-shader.js +282 -0
- package/dist/vite/easing-curves.d.ts +129 -0
- package/dist/vite/easing-curves.d.ts.map +1 -0
- package/dist/vite/easing-curves.js +584 -0
- package/dist/vite/easing-curves.test.d.ts +2 -0
- package/dist/vite/easing-curves.test.d.ts.map +1 -0
- package/dist/vite/easing-curves.test.js +200 -0
- package/dist/vite/generated-media-client.d.ts +35 -0
- package/dist/vite/generated-media-client.d.ts.map +1 -0
- package/dist/vite/generated-media-client.js +76 -0
- package/dist/vite/keyframes.d.ts +129 -0
- package/dist/vite/keyframes.d.ts.map +1 -0
- package/dist/vite/keyframes.js +194 -0
- package/dist/vite/layout-editor.d.ts +45 -0
- package/dist/vite/layout-editor.d.ts.map +1 -0
- package/dist/vite/layout-editor.js +587 -0
- package/dist/vite/layout-transition.d.ts +136 -0
- package/dist/vite/layout-transition.d.ts.map +1 -0
- package/dist/vite/layout-transition.js +581 -0
- package/dist/vite/liquid-gradient-shader.d.ts +62 -0
- package/dist/vite/liquid-gradient-shader.d.ts.map +1 -0
- package/dist/vite/liquid-gradient-shader.js +413 -0
- package/dist/vite/mdx-client.d.ts +15 -0
- package/dist/vite/mdx-client.d.ts.map +1 -0
- package/dist/vite/mdx-client.js +265 -0
- package/dist/vite/mdx-parse.d.ts +102 -0
- package/dist/vite/mdx-parse.d.ts.map +1 -0
- package/dist/vite/mdx-parse.js +251 -0
- package/dist/vite/mdx-provided-components.d.ts +18 -0
- package/dist/vite/mdx-provided-components.d.ts.map +1 -0
- package/dist/vite/mdx-provided-components.js +14 -0
- package/dist/vite/mdx-video.d.ts +327 -0
- package/dist/vite/mdx-video.d.ts.map +1 -0
- package/dist/vite/mdx-video.js +596 -0
- package/dist/vite/mdx-video.test.d.ts +9 -0
- package/dist/vite/mdx-video.test.d.ts.map +1 -0
- package/dist/vite/mdx-video.test.js +1945 -0
- package/dist/vite/media-components.d.ts +45 -0
- package/dist/vite/media-components.d.ts.map +1 -0
- package/dist/vite/media-components.js +355 -0
- package/dist/vite/media-duration-store.d.ts +66 -0
- package/dist/vite/media-duration-store.d.ts.map +1 -0
- package/dist/vite/media-duration-store.js +204 -0
- package/dist/vite/player-page.d.ts +35 -0
- package/dist/vite/player-page.d.ts.map +1 -0
- package/dist/vite/player-page.js +720 -0
- package/dist/vite/render-client.d.ts +18 -0
- package/dist/vite/render-client.d.ts.map +1 -0
- package/dist/vite/render-client.js +67 -0
- package/dist/vite/sdk.d.ts +221 -0
- package/dist/vite/sdk.d.ts.map +1 -0
- package/dist/vite/sdk.js +355 -0
- package/dist/vite/server-components.d.ts +55 -0
- package/dist/vite/server-components.d.ts.map +1 -0
- package/dist/vite/server-components.js +156 -0
- package/dist/vite/server-mdx.d.ts +74 -0
- package/dist/vite/server-mdx.d.ts.map +1 -0
- package/dist/vite/server-mdx.js +259 -0
- package/dist/vite/shader-renderer.d.ts +48 -0
- package/dist/vite/shader-renderer.d.ts.map +1 -0
- package/dist/vite/shader-renderer.js +411 -0
- package/dist/vite/store-hooks.d.ts +12 -0
- package/dist/vite/store-hooks.d.ts.map +1 -0
- package/dist/vite/store-hooks.js +93 -0
- package/dist/vite/store.d.ts +75 -0
- package/dist/vite/store.d.ts.map +1 -0
- package/dist/vite/store.js +52 -0
- package/dist/vite/store.test.d.ts +6 -0
- package/dist/vite/store.test.d.ts.map +1 -0
- package/dist/vite/store.test.js +172 -0
- package/dist/vite/tweakpane-hook.d.ts +94 -0
- package/dist/vite/tweakpane-hook.d.ts.map +1 -0
- package/dist/vite/tweakpane-hook.js +450 -0
- package/dist/vite/vite-plugin.d.ts +28 -0
- package/dist/vite/vite-plugin.d.ts.map +1 -0
- package/dist/vite/vite-plugin.js +538 -0
- package/dist/vite/wave-gradient-shader.d.ts +41 -0
- package/dist/vite/wave-gradient-shader.d.ts.map +1 -0
- package/dist/vite/wave-gradient-shader.js +207 -0
- package/package.json +101 -24
- package/skills/egaki/SKILL.md +162 -1
- package/src/cli/cache-utils.ts +169 -0
- package/src/cli/cached-generate.test.ts +531 -0
- package/src/cli/cached-generate.ts +348 -0
- package/src/cli/cartesia-provider.ts +249 -0
- package/src/{cli.ts → cli/cli.ts} +836 -884
- package/src/cli/clone-generate.ts +202 -0
- package/src/{credentials.ts → cli/credentials.ts} +20 -0
- package/src/cli/elevenlabs-speech-provider.ts +243 -0
- package/src/cli/generate.test.ts +166 -0
- package/src/cli/generate.ts +912 -0
- package/src/cli/main.ts +8 -0
- package/src/{model-catalog.ts → cli/model-catalog.ts} +787 -58
- package/src/{models.ts → cli/models.ts} +65 -86
- package/src/cli/openai-speech-provider.ts +34 -0
- package/src/cli/separation-generate.ts +187 -0
- package/src/cli/speech-catalog.ts +253 -0
- package/src/cli/speech-generate.ts +275 -0
- package/src/cli/speech-models.ts +146 -0
- package/src/cli/speech-provider.test.ts +226 -0
- package/src/cli/transcription-catalog.ts +250 -0
- package/src/cli/transcription-generate.ts +214 -0
- package/src/cli/transcription-models.ts +136 -0
- package/src/cli/transcription-providers.ts +427 -0
- package/src/{xai-auth.ts → cli/xai-auth.ts} +1 -1
- package/src/vite/angled-screen.tsx +214 -0
- package/src/vite/app.tsx +197 -0
- package/src/vite/bands-shader.tsx +319 -0
- package/src/vite/code-block.tsx +939 -0
- package/src/vite/components.tsx +278 -0
- package/src/vite/dispersion-rings-shader.tsx +331 -0
- package/src/vite/easing-curves.test.ts +232 -0
- package/src/vite/easing-curves.ts +743 -0
- package/src/vite/generated-media-client.tsx +121 -0
- package/src/vite/keyframes.tsx +347 -0
- package/src/vite/layout-editor.tsx +709 -0
- package/src/vite/layout-transition.tsx +706 -0
- package/src/vite/liquid-gradient-shader.tsx +474 -0
- package/src/vite/mdx-client.tsx +420 -0
- package/src/vite/mdx-parse.ts +343 -0
- package/src/vite/mdx-provided-components.ts +19 -0
- package/src/vite/mdx-video.test.tsx +2219 -0
- package/src/vite/mdx-video.tsx +976 -0
- package/src/vite/media-components.tsx +473 -0
- package/src/vite/media-duration-store.ts +225 -0
- package/src/vite/motion-timing.ts +194 -0
- package/src/vite/player-page.tsx +1107 -0
- package/src/vite/render-client.ts +84 -0
- package/src/vite/sdk.ts +598 -0
- package/src/vite/server-components.tsx +241 -0
- package/src/vite/server-mdx.ts +285 -0
- package/src/vite/shader-renderer.tsx +535 -0
- package/src/vite/store-hooks.ts +117 -0
- package/src/vite/store.test.ts +202 -0
- package/src/vite/store.ts +115 -0
- package/src/vite/styles.css +21 -0
- package/src/vite/tweakpane-hook.tsx +593 -0
- package/src/vite/virtual-modules.d.ts +30 -0
- package/src/vite/vite-plugin.ts +584 -0
- package/src/vite/wave-gradient-shader.tsx +253 -0
- package/README.md +0 -370
- package/dist/antigravity-auth.d.ts +0 -15
- package/dist/antigravity-auth.d.ts.map +0 -1
- package/dist/antigravity-auth.js +0 -544
- package/dist/antigravity-image.d.ts +0 -11
- package/dist/antigravity-image.d.ts.map +0 -1
- package/dist/antigravity-image.js +0 -285
- package/dist/chatgpt-auth.d.ts.map +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/credentials.d.ts.map +0 -1
- package/dist/gateway-model-candidates.generated.d.ts +0 -11
- package/dist/gateway-model-candidates.generated.d.ts.map +0 -1
- package/dist/gateway-model-candidates.generated.js +0 -64
- package/dist/login.d.ts.map +0 -1
- package/dist/model-catalog.d.ts.map +0 -1
- package/dist/models.d.ts.map +0 -1
- package/dist/open-browser.d.ts.map +0 -1
- package/dist/subscription.d.ts.map +0 -1
- package/dist/video-model-catalog.d.ts.map +0 -1
- package/dist/xai-auth.d.ts.map +0 -1
- /package/dist/{chatgpt-auth.d.ts → cli/chatgpt-auth.d.ts} +0 -0
- /package/dist/{chatgpt-auth.js → cli/chatgpt-auth.js} +0 -0
- /package/dist/{credentials.d.ts → cli/credentials.d.ts} +0 -0
- /package/dist/{login.d.ts → cli/login.d.ts} +0 -0
- /package/dist/{login.js → cli/login.js} +0 -0
- /package/dist/{open-browser.d.ts → cli/open-browser.d.ts} +0 -0
- /package/dist/{open-browser.js → cli/open-browser.js} +0 -0
- /package/dist/{subscription.d.ts → cli/subscription.d.ts} +0 -0
- /package/dist/{subscription.js → cli/subscription.js} +0 -0
- /package/dist/{video-model-catalog.d.ts → cli/video-model-catalog.d.ts} +0 -0
- /package/dist/{video-model-catalog.js → cli/video-model-catalog.js} +0 -0
- /package/dist/{xai-auth.d.ts → cli/xai-auth.d.ts} +0 -0
- /package/src/{chatgpt-auth.ts → cli/chatgpt-auth.ts} +0 -0
- /package/src/{login.ts → cli/login.ts} +0 -0
- /package/src/{open-browser.ts → cli/open-browser.ts} +0 -0
- /package/src/{subscription.ts → cli/subscription.ts} +0 -0
- /package/src/{video-model-catalog.ts → cli/video-model-catalog.ts} +0 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Shared caching utilities for egaki generate functions.
|
|
2
|
+
//
|
|
3
|
+
// Provides deterministic cache keys, file lookup, deduplication queues,
|
|
4
|
+
// and stale file management. Used by the cached variants of generateImage,
|
|
5
|
+
// generateSpeech, generateVideo, and transcribeAudio.
|
|
6
|
+
//
|
|
7
|
+
// The project root (for locating public/generated/) is set via setProjectRoot()
|
|
8
|
+
// at startup. In Vite context, server-components.tsx sets it from the virtual
|
|
9
|
+
// module. Falls back to process.cwd() if not set.
|
|
10
|
+
|
|
11
|
+
import { createHash } from 'node:crypto'
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Project root
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
let _projectRoot: string | undefined
|
|
20
|
+
|
|
21
|
+
/** Set the project root directory. Called by the Vite plugin on startup. */
|
|
22
|
+
export function setProjectRoot(root: string) {
|
|
23
|
+
_projectRoot = root
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Get the project root directory. Falls back to cwd if not set. */
|
|
27
|
+
export function getProjectRoot(): string {
|
|
28
|
+
return _projectRoot ?? process.cwd()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Deterministic cache keys
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** Deterministic JSON from a value: keys sorted recursively, undefined
|
|
36
|
+
* values stripped. Safe for nested objects and arrays. */
|
|
37
|
+
export function stableJsonKey(value: unknown): string {
|
|
38
|
+
return JSON.stringify(sortValue(value))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sortValue(value: unknown): unknown {
|
|
42
|
+
if (Array.isArray(value)) return value.map(sortValue)
|
|
43
|
+
if (!value || typeof value !== 'object') return value
|
|
44
|
+
return Object.fromEntries(
|
|
45
|
+
Object.entries(value as Record<string, unknown>)
|
|
46
|
+
.filter(([, v]) => v !== undefined)
|
|
47
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
48
|
+
.map(([k, v]) => [k, sortValue(v)]),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** First 8 hex chars of sha256. */
|
|
53
|
+
export function hashKey(input: string | Uint8Array): string {
|
|
54
|
+
return createHash('sha256').update(input).digest('hex').slice(0, 8)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** First ~40 chars of text, kebab-cased, filesystem-safe. */
|
|
58
|
+
export function promptPrefix(text: string): string {
|
|
59
|
+
const slug = text
|
|
60
|
+
.toLowerCase()
|
|
61
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
62
|
+
.trim()
|
|
63
|
+
.replace(/\s+/g, '-')
|
|
64
|
+
.slice(0, 40)
|
|
65
|
+
.replace(/-$/, '')
|
|
66
|
+
return slug || 'generated'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function extensionFromMediaType(mediaType: string): string {
|
|
70
|
+
const map: Record<string, string> = {
|
|
71
|
+
'image/png': '.png',
|
|
72
|
+
'image/jpeg': '.jpg',
|
|
73
|
+
'image/webp': '.webp',
|
|
74
|
+
'video/mp4': '.mp4',
|
|
75
|
+
'video/webm': '.webm',
|
|
76
|
+
'audio/mpeg': '.mp3',
|
|
77
|
+
'audio/wav': '.wav',
|
|
78
|
+
'audio/opus': '.opus',
|
|
79
|
+
'audio/ogg': '.ogg',
|
|
80
|
+
'audio/aac': '.aac',
|
|
81
|
+
'audio/flac': '.flac',
|
|
82
|
+
}
|
|
83
|
+
return map[mediaType] || '.bin'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Generated directory management
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/** Returns the generated files directory for a given namespace.
|
|
91
|
+
* Creates the directory if it doesn't exist. */
|
|
92
|
+
export function generatedDir(namespace: string): string {
|
|
93
|
+
const root = getProjectRoot()
|
|
94
|
+
const dir = path.join(root, 'public', 'generated', namespace)
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
96
|
+
return dir
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// File lookup
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/** Find an existing cached file by hash in the generated directory.
|
|
104
|
+
* Also checks the stale/ subfolder — if found there, restores it. */
|
|
105
|
+
export function findCachedFile(dir: string, hash: string): string | undefined {
|
|
106
|
+
try {
|
|
107
|
+
const found = fs.readdirSync(dir).find((f) => f.includes(hash))
|
|
108
|
+
if (found) return found
|
|
109
|
+
} catch { /* empty */ }
|
|
110
|
+
try {
|
|
111
|
+
const staleDir = path.join(dir, 'stale')
|
|
112
|
+
const inStale = fs.readdirSync(staleDir).find((f) => f.includes(hash))
|
|
113
|
+
if (inStale) {
|
|
114
|
+
fs.renameSync(path.join(staleDir, inStale), path.join(dir, inStale))
|
|
115
|
+
console.log(`[egaki] restored from stale: ${inStale}`)
|
|
116
|
+
return inStale
|
|
117
|
+
}
|
|
118
|
+
} catch { /* empty */ }
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Find a previous generation with the same prompt prefix (different hash)
|
|
123
|
+
* that can serve as fallback while a new generation is in progress. */
|
|
124
|
+
export function findFallbackFile(dir: string, prefix: string, currentHash: string): { filename: string; inStale: boolean } | undefined {
|
|
125
|
+
const match = (f: string) => f.startsWith(prefix + '-') && !f.includes(currentHash)
|
|
126
|
+
try {
|
|
127
|
+
const inDir = fs.readdirSync(dir).find(match)
|
|
128
|
+
if (inDir) return { filename: inDir, inStale: false }
|
|
129
|
+
} catch { /* empty */ }
|
|
130
|
+
try {
|
|
131
|
+
const staleDir = path.join(dir, 'stale')
|
|
132
|
+
const inStale = fs.readdirSync(staleDir).find(match)
|
|
133
|
+
if (inStale) return { filename: inStale, inStale: true }
|
|
134
|
+
} catch { /* empty */ }
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Stale file management
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/** Move a specific file into the stale/ subfolder of its directory. */
|
|
143
|
+
export function moveFileToStale(dir: string, fallback: { filename: string; inStale: boolean }) {
|
|
144
|
+
if (fallback.inStale) return
|
|
145
|
+
const src = path.join(dir, fallback.filename)
|
|
146
|
+
const staleDir = path.join(dir, 'stale')
|
|
147
|
+
try {
|
|
148
|
+
if (!fs.existsSync(src)) return
|
|
149
|
+
fs.mkdirSync(staleDir, { recursive: true })
|
|
150
|
+
fs.renameSync(src, path.join(staleDir, fallback.filename))
|
|
151
|
+
console.log(`[egaki] moved stale: ${fallback.filename} → stale/`)
|
|
152
|
+
} catch { /* race with another rename, safe to ignore */ }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Generation queue — deduplicates concurrent calls with the same cache key.
|
|
157
|
+
// Stored on globalThis so in-flight promises survive Vite HMR module reloads.
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
export const generationQueue: Map<string, Promise<any>> =
|
|
161
|
+
(globalThis as any).__egakiGenerationQueue ??= new Map<string, Promise<any>>()
|
|
162
|
+
|
|
163
|
+
/** Format key params for logging, omitting undefined values and the _type field. */
|
|
164
|
+
export function formatKeyParams(params: Record<string, unknown>): string {
|
|
165
|
+
const entries = Object.entries(params)
|
|
166
|
+
.filter(([k, v]) => v !== undefined && k !== '_type')
|
|
167
|
+
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
168
|
+
return entries.join(', ')
|
|
169
|
+
}
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for cachedGenerate — the general-purpose persisted memoization HOF.
|
|
3
|
+
*
|
|
4
|
+
* Uses a temp directory as the project root so tests are isolated and don't
|
|
5
|
+
* touch the real public/generated/ folder. Tests verify: cache hit/miss,
|
|
6
|
+
* dedup of concurrent calls, stale fallback management, auto-hashing of
|
|
7
|
+
* Uint8Array params, JSON serialization, progress tracking, error handling,
|
|
8
|
+
* and the getCacheInfo helper.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest'
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import {
|
|
16
|
+
cachedGenerate,
|
|
17
|
+
getCacheInfo,
|
|
18
|
+
getGenerationProgress,
|
|
19
|
+
type CachedGenerateConfig,
|
|
20
|
+
} from './cached-generate.ts'
|
|
21
|
+
import { setProjectRoot } from './cache-utils.ts'
|
|
22
|
+
|
|
23
|
+
let tmpDir: string
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'egaki-cached-generate-test-'))
|
|
27
|
+
setProjectRoot(tmpDir)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
interface TestParams {
|
|
39
|
+
prompt: string
|
|
40
|
+
seed?: number
|
|
41
|
+
inputData?: Uint8Array
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeTestConfig(overrides?: Partial<CachedGenerateConfig<TestParams, { data: string }, { src: string }>>) {
|
|
45
|
+
let callCount = 0
|
|
46
|
+
const config: CachedGenerateConfig<TestParams, { data: string }, { src: string }> = {
|
|
47
|
+
namespace: 'test',
|
|
48
|
+
prefixFrom: (p) => p.prompt,
|
|
49
|
+
generate: async (params) => {
|
|
50
|
+
callCount++
|
|
51
|
+
return { data: `generated-${params.prompt}` }
|
|
52
|
+
},
|
|
53
|
+
serialize: (result) => ({
|
|
54
|
+
bytes: new Uint8Array(Buffer.from(result.data)),
|
|
55
|
+
extension: '.txt',
|
|
56
|
+
}),
|
|
57
|
+
...overrides,
|
|
58
|
+
}
|
|
59
|
+
return { config, getCallCount: () => callCount }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Cache hit / miss
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
describe('cache hit and miss', () => {
|
|
67
|
+
test('first call generates and writes file', async () => {
|
|
68
|
+
const { config, getCallCount } = makeTestConfig()
|
|
69
|
+
const fn = cachedGenerate(config)
|
|
70
|
+
|
|
71
|
+
const result = await fn({ prompt: 'hello world' })
|
|
72
|
+
expect(result).not.toBeInstanceOf(Error)
|
|
73
|
+
const r = result as { src: string }
|
|
74
|
+
expect(r.src).toMatch(/^\/generated\/test\/hello-world-[a-f0-9]{8}\.txt$/)
|
|
75
|
+
expect(getCallCount()).toBe(1)
|
|
76
|
+
|
|
77
|
+
// File exists on disk
|
|
78
|
+
const filePath = path.join(tmpDir, 'public', r.src)
|
|
79
|
+
expect(fs.existsSync(filePath)).toBe(true)
|
|
80
|
+
expect(fs.readFileSync(filePath, 'utf-8')).toBe('generated-hello world')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('second call with same params returns cached (no generation)', async () => {
|
|
84
|
+
const { config, getCallCount } = makeTestConfig()
|
|
85
|
+
const fn = cachedGenerate(config)
|
|
86
|
+
|
|
87
|
+
const r1 = await fn({ prompt: 'hello world' })
|
|
88
|
+
const r2 = await fn({ prompt: 'hello world' })
|
|
89
|
+
|
|
90
|
+
expect(r1).not.toBeInstanceOf(Error)
|
|
91
|
+
expect(r2).not.toBeInstanceOf(Error)
|
|
92
|
+
expect((r1 as { src: string }).src).toBe((r2 as { src: string }).src)
|
|
93
|
+
expect(getCallCount()).toBe(1) // only called once
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('different params generate different files', async () => {
|
|
97
|
+
const { config, getCallCount } = makeTestConfig()
|
|
98
|
+
const fn = cachedGenerate(config)
|
|
99
|
+
|
|
100
|
+
const r1 = await fn({ prompt: 'alpha' })
|
|
101
|
+
const r2 = await fn({ prompt: 'beta' })
|
|
102
|
+
|
|
103
|
+
expect(r1).not.toBeInstanceOf(Error)
|
|
104
|
+
expect(r2).not.toBeInstanceOf(Error)
|
|
105
|
+
expect((r1 as { src: string }).src).not.toBe((r2 as { src: string }).src)
|
|
106
|
+
expect(getCallCount()).toBe(2)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Concurrent dedup
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
describe('deduplication', () => {
|
|
115
|
+
test('concurrent calls with same params return same promise', async () => {
|
|
116
|
+
let resolveGenerate!: (v: { data: string }) => void
|
|
117
|
+
const { config } = makeTestConfig({
|
|
118
|
+
generate: () => new Promise((resolve) => { resolveGenerate = resolve }),
|
|
119
|
+
})
|
|
120
|
+
const fn = cachedGenerate(config)
|
|
121
|
+
|
|
122
|
+
const p1 = fn({ prompt: 'dedup test' })
|
|
123
|
+
const p2 = fn({ prompt: 'dedup test' })
|
|
124
|
+
|
|
125
|
+
// Resolve the single generation
|
|
126
|
+
resolveGenerate({ data: 'result' })
|
|
127
|
+
|
|
128
|
+
const [r1, r2] = await Promise.all([p1, p2])
|
|
129
|
+
expect(r1).not.toBeInstanceOf(Error)
|
|
130
|
+
expect(r2).not.toBeInstanceOf(Error)
|
|
131
|
+
expect((r1 as { src: string }).src).toBe((r2 as { src: string }).src)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Stale fallback management
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
describe('stale management', () => {
|
|
140
|
+
test('changing params moves old file to stale/', async () => {
|
|
141
|
+
const { config } = makeTestConfig()
|
|
142
|
+
const fn = cachedGenerate(config)
|
|
143
|
+
|
|
144
|
+
// Generate first version
|
|
145
|
+
const r1 = await fn({ prompt: 'hello world', seed: 1 })
|
|
146
|
+
expect(r1).not.toBeInstanceOf(Error)
|
|
147
|
+
|
|
148
|
+
const firstFile = path.basename((r1 as { src: string }).src)
|
|
149
|
+
|
|
150
|
+
// Generate with different seed but same prompt prefix
|
|
151
|
+
const r2 = await fn({ prompt: 'hello world', seed: 2 })
|
|
152
|
+
expect(r2).not.toBeInstanceOf(Error)
|
|
153
|
+
|
|
154
|
+
// Old file should be in stale/
|
|
155
|
+
const staleDir = path.join(tmpDir, 'public', 'generated', 'test', 'stale')
|
|
156
|
+
const staleFiles = fs.existsSync(staleDir) ? fs.readdirSync(staleDir) : []
|
|
157
|
+
expect(staleFiles).toContain(firstFile)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Auto-hashing Uint8Array params
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
describe('Uint8Array auto-hashing', () => {
|
|
166
|
+
test('same bytes produce same cache key', async () => {
|
|
167
|
+
const { config, getCallCount } = makeTestConfig()
|
|
168
|
+
const fn = cachedGenerate(config)
|
|
169
|
+
|
|
170
|
+
const data = new Uint8Array([1, 2, 3])
|
|
171
|
+
const r1 = await fn({ prompt: 'with data', inputData: data })
|
|
172
|
+
const r2 = await fn({ prompt: 'with data', inputData: new Uint8Array([1, 2, 3]) })
|
|
173
|
+
|
|
174
|
+
expect(getCallCount()).toBe(1) // same key → cached
|
|
175
|
+
expect((r1 as { src: string }).src).toBe((r2 as { src: string }).src)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('different bytes produce different cache keys', async () => {
|
|
179
|
+
const { config, getCallCount } = makeTestConfig()
|
|
180
|
+
const fn = cachedGenerate(config)
|
|
181
|
+
|
|
182
|
+
await fn({ prompt: 'with data', inputData: new Uint8Array([1, 2, 3]) })
|
|
183
|
+
await fn({ prompt: 'with data', inputData: new Uint8Array([4, 5, 6]) })
|
|
184
|
+
|
|
185
|
+
expect(getCallCount()).toBe(2)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// JSON serialization
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
describe('JSON serialization', () => {
|
|
194
|
+
test('json serialize writes .json and deserialize reads it back', async () => {
|
|
195
|
+
interface JsonResult { items: string[] }
|
|
196
|
+
const fn = cachedGenerate<{ query: string }, JsonResult, JsonResult>({
|
|
197
|
+
namespace: 'json-test',
|
|
198
|
+
prefixFrom: (p) => p.query,
|
|
199
|
+
generate: async (params) => ({ items: [params.query, 'b', 'c'] }),
|
|
200
|
+
serialize: (result) => ({
|
|
201
|
+
json: result,
|
|
202
|
+
extension: '.json' as const,
|
|
203
|
+
}),
|
|
204
|
+
deserialize: ({ filePath }) => JSON.parse(fs.readFileSync(filePath, 'utf-8')),
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const r1 = await fn({ query: 'search term' })
|
|
208
|
+
expect(r1).not.toBeInstanceOf(Error)
|
|
209
|
+
expect((r1 as JsonResult).items).toEqual(['search term', 'b', 'c'])
|
|
210
|
+
|
|
211
|
+
// Second call reads from cache
|
|
212
|
+
const r2 = await fn({ query: 'search term' })
|
|
213
|
+
expect(r2).not.toBeInstanceOf(Error)
|
|
214
|
+
expect((r2 as JsonResult).items).toEqual(['search term', 'b', 'c'])
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Prefix override from serialize
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
describe('prefix override', () => {
|
|
223
|
+
test('serialize can return a prefix to override the filename', async () => {
|
|
224
|
+
const fn = cachedGenerate<{ id: string }, { text: string }, { src: string }>({
|
|
225
|
+
namespace: 'prefix-test',
|
|
226
|
+
prefixFrom: () => 'initial',
|
|
227
|
+
generate: async () => ({ text: 'the actual content is here' }),
|
|
228
|
+
serialize: (result) => ({
|
|
229
|
+
bytes: new Uint8Array(Buffer.from(result.text)),
|
|
230
|
+
extension: '.txt',
|
|
231
|
+
prefix: 'the actual content is here',
|
|
232
|
+
}),
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const r = await fn({ id: '123' })
|
|
236
|
+
expect(r).not.toBeInstanceOf(Error)
|
|
237
|
+
// The filename should use the override prefix, not 'initial'
|
|
238
|
+
expect((r as { src: string }).src).toMatch(/the-actual-content-is-here/)
|
|
239
|
+
expect((r as { src: string }).src).not.toMatch(/initial/)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Progress tracking
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
describe('progress tracking', () => {
|
|
248
|
+
test('generation is registered and unregistered', async () => {
|
|
249
|
+
let resolveGenerate!: (v: { data: string }) => void
|
|
250
|
+
const fn = cachedGenerate<TestParams, { data: string }>({
|
|
251
|
+
namespace: 'progress-test',
|
|
252
|
+
prefixFrom: (p) => p.prompt,
|
|
253
|
+
modelFrom: (p) => `model-${p.seed}`,
|
|
254
|
+
generate: () => new Promise((resolve) => { resolveGenerate = resolve }),
|
|
255
|
+
serialize: (result) => ({
|
|
256
|
+
bytes: new Uint8Array(Buffer.from(result.data)),
|
|
257
|
+
extension: '.txt',
|
|
258
|
+
}),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const promise = fn({ prompt: 'tracking test', seed: 42 })
|
|
262
|
+
|
|
263
|
+
// While generating, progress should show the entry
|
|
264
|
+
const progress = getGenerationProgress()
|
|
265
|
+
expect(progress.summary.total).toBe(1)
|
|
266
|
+
expect(progress.summary.counts['progress-test']).toBe(1)
|
|
267
|
+
expect(progress.generations[0]?.namespace).toBe('progress-test')
|
|
268
|
+
expect(progress.generations[0]?.label).toBe('tracking test')
|
|
269
|
+
expect(progress.generations[0]?.model).toBe('model-42')
|
|
270
|
+
|
|
271
|
+
// Resolve the generation
|
|
272
|
+
resolveGenerate({ data: 'done' })
|
|
273
|
+
await promise
|
|
274
|
+
|
|
275
|
+
// After completion, progress should be empty
|
|
276
|
+
const after = getGenerationProgress()
|
|
277
|
+
expect(after.summary.total).toBe(0)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('failed generation reports error in progress', async () => {
|
|
281
|
+
const fn = cachedGenerate<TestParams, { data: string }>({
|
|
282
|
+
namespace: 'error-progress',
|
|
283
|
+
prefixFrom: (p) => p.prompt,
|
|
284
|
+
generate: async () => { throw new Error('API failed') },
|
|
285
|
+
serialize: (result) => ({
|
|
286
|
+
bytes: new Uint8Array(Buffer.from(result.data)),
|
|
287
|
+
extension: '.txt',
|
|
288
|
+
}),
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
const result = await fn({ prompt: 'fail test' })
|
|
292
|
+
expect(result).toBeInstanceOf(Error)
|
|
293
|
+
expect((result as Error).message).toBe('API failed')
|
|
294
|
+
|
|
295
|
+
// Error should be in the progress drain
|
|
296
|
+
const progress = getGenerationProgress()
|
|
297
|
+
expect(progress.errors.length).toBe(1)
|
|
298
|
+
expect(progress.errors[0]?.error).toBe('API failed')
|
|
299
|
+
expect(progress.errors[0]?.namespace).toBe('error-progress')
|
|
300
|
+
|
|
301
|
+
// Errors drain on read — second call should have none
|
|
302
|
+
const progress2 = getGenerationProgress()
|
|
303
|
+
expect(progress2.errors.length).toBe(0)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Error handling
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
describe('error handling', () => {
|
|
312
|
+
test('generate throwing returns Error', async () => {
|
|
313
|
+
const fn = cachedGenerate<TestParams, { data: string }>({
|
|
314
|
+
namespace: 'error-test',
|
|
315
|
+
prefixFrom: (p) => p.prompt,
|
|
316
|
+
generate: async () => { throw new Error('boom') },
|
|
317
|
+
serialize: (result) => ({
|
|
318
|
+
bytes: new Uint8Array(Buffer.from(result.data)),
|
|
319
|
+
extension: '.txt',
|
|
320
|
+
}),
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
const result = await fn({ prompt: 'error test' })
|
|
324
|
+
expect(result).toBeInstanceOf(Error)
|
|
325
|
+
expect((result as Error).message).toBe('boom')
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test('non-Error throw is wrapped in Error', async () => {
|
|
329
|
+
const fn = cachedGenerate<TestParams, { data: string }>({
|
|
330
|
+
namespace: 'error-test-2',
|
|
331
|
+
prefixFrom: (p) => p.prompt,
|
|
332
|
+
generate: async () => { throw 'string error' },
|
|
333
|
+
serialize: (result) => ({
|
|
334
|
+
bytes: new Uint8Array(Buffer.from(result.data)),
|
|
335
|
+
extension: '.txt',
|
|
336
|
+
}),
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
const result = await fn({ prompt: 'string error test' })
|
|
340
|
+
expect(result).toBeInstanceOf(Error)
|
|
341
|
+
expect((result as Error).message).toBe('string error')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('failed call does not cache — retry regenerates', async () => {
|
|
345
|
+
let shouldFail = true
|
|
346
|
+
const fn = cachedGenerate<TestParams, { data: string }>({
|
|
347
|
+
namespace: 'retry-test',
|
|
348
|
+
prefixFrom: (p) => p.prompt,
|
|
349
|
+
generate: async (params) => {
|
|
350
|
+
if (shouldFail) throw new Error('temporary failure')
|
|
351
|
+
return { data: `ok-${params.prompt}` }
|
|
352
|
+
},
|
|
353
|
+
serialize: (result) => ({
|
|
354
|
+
bytes: new Uint8Array(Buffer.from(result.data)),
|
|
355
|
+
extension: '.txt',
|
|
356
|
+
}),
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
const r1 = await fn({ prompt: 'retry' })
|
|
360
|
+
expect(r1).toBeInstanceOf(Error)
|
|
361
|
+
|
|
362
|
+
shouldFail = false
|
|
363
|
+
const r2 = await fn({ prompt: 'retry' })
|
|
364
|
+
expect(r2).not.toBeInstanceOf(Error)
|
|
365
|
+
expect((r2 as { src: string }).src).toMatch(/\/generated\/retry-test\//)
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Namespace isolation in dedup queue
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
describe('namespace isolation', () => {
|
|
374
|
+
test('same params in different namespaces generate independently', async () => {
|
|
375
|
+
let imageCallCount = 0
|
|
376
|
+
let videoCallCount = 0
|
|
377
|
+
|
|
378
|
+
const imageFn = cachedGenerate<{ prompt: string }, { data: string }>({
|
|
379
|
+
namespace: 'ns-image',
|
|
380
|
+
prefixFrom: (p) => p.prompt,
|
|
381
|
+
generate: async (params) => {
|
|
382
|
+
imageCallCount++
|
|
383
|
+
return { data: `image-${params.prompt}` }
|
|
384
|
+
},
|
|
385
|
+
serialize: (result) => ({
|
|
386
|
+
bytes: new Uint8Array(Buffer.from(result.data)),
|
|
387
|
+
extension: '.png',
|
|
388
|
+
}),
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
const videoFn = cachedGenerate<{ prompt: string }, { data: string }>({
|
|
392
|
+
namespace: 'ns-video',
|
|
393
|
+
prefixFrom: (p) => p.prompt,
|
|
394
|
+
generate: async (params) => {
|
|
395
|
+
videoCallCount++
|
|
396
|
+
return { data: `video-${params.prompt}` }
|
|
397
|
+
},
|
|
398
|
+
serialize: (result) => ({
|
|
399
|
+
bytes: new Uint8Array(Buffer.from(result.data)),
|
|
400
|
+
extension: '.mp4',
|
|
401
|
+
}),
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
const [imgResult, vidResult] = await Promise.all([
|
|
405
|
+
imageFn({ prompt: 'same params' }),
|
|
406
|
+
videoFn({ prompt: 'same params' }),
|
|
407
|
+
])
|
|
408
|
+
|
|
409
|
+
expect(imgResult).not.toBeInstanceOf(Error)
|
|
410
|
+
expect(vidResult).not.toBeInstanceOf(Error)
|
|
411
|
+
expect(imageCallCount).toBe(1)
|
|
412
|
+
expect(videoCallCount).toBe(1)
|
|
413
|
+
// Different src paths
|
|
414
|
+
expect((imgResult as { src: string }).src).toMatch(/ns-image/)
|
|
415
|
+
expect((vidResult as { src: string }).src).toMatch(/ns-video/)
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Recursive Uint8Array hashing
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
describe('recursive binary hashing', () => {
|
|
424
|
+
test('nested Uint8Array values produce stable cache keys', async () => {
|
|
425
|
+
const { config, getCallCount } = makeTestConfig()
|
|
426
|
+
const fn = cachedGenerate(config)
|
|
427
|
+
|
|
428
|
+
// Pass nested binary data inside an object
|
|
429
|
+
const r1 = await fn({ prompt: 'nested', inputData: new Uint8Array([10, 20, 30]) } as any)
|
|
430
|
+
const r2 = await fn({ prompt: 'nested', inputData: new Uint8Array([10, 20, 30]) } as any)
|
|
431
|
+
|
|
432
|
+
expect(getCallCount()).toBe(1) // same nested bytes → cached
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Serialize failure handling
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
describe('serialize failure', () => {
|
|
441
|
+
test('serialize throwing unregisters progress and returns Error', async () => {
|
|
442
|
+
// Drain any leftover errors from previous tests
|
|
443
|
+
getGenerationProgress()
|
|
444
|
+
|
|
445
|
+
const fn = cachedGenerate<TestParams, { data: string }>({
|
|
446
|
+
namespace: 'serialize-fail',
|
|
447
|
+
prefixFrom: (p) => p.prompt,
|
|
448
|
+
generate: async () => ({ data: 'ok' }),
|
|
449
|
+
serialize: () => { throw new Error('serialize exploded') },
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
const result = await fn({ prompt: 'serialize test' })
|
|
453
|
+
expect(result).toBeInstanceOf(Error)
|
|
454
|
+
expect((result as Error).message).toBe('serialize exploded')
|
|
455
|
+
|
|
456
|
+
// Progress should be clean — no stuck entries
|
|
457
|
+
const progress = getGenerationProgress()
|
|
458
|
+
expect(progress.summary.total).toBe(0)
|
|
459
|
+
// Error should be in the drain
|
|
460
|
+
expect(progress.errors.length).toBe(1)
|
|
461
|
+
expect(progress.errors[0]?.error).toBe('serialize exploded')
|
|
462
|
+
})
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// cacheKey config excludes non-identity fields
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
describe('cacheKey config', () => {
|
|
470
|
+
test('excluded fields do not affect cache identity', async () => {
|
|
471
|
+
let callCount = 0
|
|
472
|
+
const fn = cachedGenerate<{ query: string; label?: string }, { data: string }>({
|
|
473
|
+
namespace: 'cachekey-test',
|
|
474
|
+
prefixFrom: (p) => p.label ?? p.query,
|
|
475
|
+
cacheKey: ({ label, ...rest }) => rest,
|
|
476
|
+
generate: async (params) => {
|
|
477
|
+
callCount++
|
|
478
|
+
return { data: `result-${params.query}` }
|
|
479
|
+
},
|
|
480
|
+
serialize: (result) => ({
|
|
481
|
+
bytes: new Uint8Array(Buffer.from(result.data)),
|
|
482
|
+
extension: '.txt',
|
|
483
|
+
}),
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
await fn({ query: 'search', label: 'label-a' })
|
|
487
|
+
await fn({ query: 'search', label: 'label-b' })
|
|
488
|
+
|
|
489
|
+
// Same query, different labels → should be same cache entry
|
|
490
|
+
expect(callCount).toBe(1)
|
|
491
|
+
})
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// getCacheInfo helper
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
describe('getCacheInfo', () => {
|
|
499
|
+
test('returns cache miss before any generation', () => {
|
|
500
|
+
const info = getCacheInfo('test-ns', { prompt: 'hello' }, 'hello')
|
|
501
|
+
expect(info.cached).toBeUndefined()
|
|
502
|
+
expect(info.hash).toMatch(/^[a-f0-9]{8}$/)
|
|
503
|
+
expect(info.prefix).toBe('hello')
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
test('returns cache hit after generation', async () => {
|
|
507
|
+
const { config } = makeTestConfig()
|
|
508
|
+
const fn = cachedGenerate(config)
|
|
509
|
+
|
|
510
|
+
await fn({ prompt: 'cached info test' })
|
|
511
|
+
|
|
512
|
+
const info = getCacheInfo('test', { prompt: 'cached info test' }, 'cached info test')
|
|
513
|
+
expect(info.cached).toBeDefined()
|
|
514
|
+
expect(info.cached).toMatch(/cached-info-test-[a-f0-9]{8}\.txt/)
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
test('returns fallbackSrc when a previous generation exists', async () => {
|
|
518
|
+
const { config } = makeTestConfig()
|
|
519
|
+
const fn = cachedGenerate(config)
|
|
520
|
+
|
|
521
|
+
// Generate first version
|
|
522
|
+
await fn({ prompt: 'fallback test', seed: 1 })
|
|
523
|
+
|
|
524
|
+
// Query for a different version that doesn't exist yet
|
|
525
|
+
// Version 1 is in the main dir and should be found as fallback
|
|
526
|
+
const info = getCacheInfo('test', { prompt: 'fallback test', seed: 2 }, 'fallback test')
|
|
527
|
+
expect(info.cached).toBeUndefined()
|
|
528
|
+
expect(info.fallbackSrc).toBeDefined()
|
|
529
|
+
expect(info.fallbackSrc).toMatch(/\/generated\/test\/fallback-test-/)
|
|
530
|
+
})
|
|
531
|
+
})
|