mikser-io-drive 0.9.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.
@@ -0,0 +1,95 @@
1
+ import { rename, unlink, open } from 'node:fs/promises'
2
+ import { randomBytes } from 'node:crypto'
3
+ import path from 'node:path'
4
+
5
+ // Make a PUT atomic from the filesystem's point of view.
6
+ //
7
+ // @nephele/adapter-file-system writes straight to the destination:
8
+ //
9
+ // const handle = await fsp.open(this.absolutePath, 'w') // truncates NOW
10
+ // input.pipe(handle.createWriteStream())
11
+ //
12
+ // Measured against a trickling 512KB upload, that has two consequences, and
13
+ // the second is data loss:
14
+ //
15
+ // 1. The file is visible while it grows — 65536, 131072, … 524288 bytes.
16
+ // These folders are mikser sources, so the watcher can import a
17
+ // half-written file and render a truncated page.
18
+ // 2. An interrupted overwrite DESTROYS what was there. `'w'` truncates at
19
+ // open, so a 1600-byte file whose upload died after 196608 bytes was
20
+ // left as 196608 bytes of the new content. Not the old file, not an
21
+ // error — a corrupted file, silently.
22
+ //
23
+ // Both go away by writing to a sibling temp file and renaming on success.
24
+ // rename(2) within a directory is atomic: the watcher sees the file appear
25
+ // complete or not at all, and a failed upload leaves the original untouched
26
+ // because it was never opened for writing.
27
+ //
28
+ // A sibling rather than the OS temp dir, because rename is only atomic within
29
+ // one filesystem and /tmp is frequently another mount.
30
+ export function stageWrites(resource, { onFailure } = {}) {
31
+ if (typeof resource?.setStream !== 'function') return resource
32
+
33
+ return new Proxy(resource, {
34
+ get(target, prop, receiver) {
35
+ if (prop !== 'setStream') {
36
+ const value = Reflect.get(target, prop, receiver)
37
+ return typeof value === 'function' ? value.bind(target) : value
38
+ }
39
+
40
+ return async function setStream(input, user, ...rest) {
41
+ const finalPath = target.absolutePath
42
+ // No path to stage next to — a virtual or non-filesystem
43
+ // resource. Fall through rather than guess.
44
+ if (!finalPath) return target.setStream(input, user, ...rest)
45
+
46
+ const tempPath = path.join(
47
+ path.dirname(finalPath),
48
+ `.${path.basename(finalPath)}.${randomBytes(6).toString('hex')}.part`,
49
+ )
50
+
51
+ let handle
52
+ try {
53
+ handle = await open(tempPath, 'wx')
54
+ await new Promise((resolve, reject) => {
55
+ const stream = handle.createWriteStream()
56
+ stream.on('error', reject)
57
+ stream.on('close', resolve)
58
+ input.on('error', (err) => { stream.destroy(); reject(err) })
59
+ input.pipe(stream)
60
+ })
61
+ await handle.close()
62
+ handle = null
63
+
64
+ // The atomic moment. Everything before this is invisible
65
+ // to the watcher because the temp name is dot-prefixed
66
+ // and suffixed .part; everything after is a complete file.
67
+ await rename(tempPath, finalPath)
68
+
69
+ // The adapter caches these off the old file.
70
+ target.etag = undefined
71
+ target.stats = undefined
72
+ } catch (err) {
73
+ await handle?.close().catch(() => {})
74
+ await unlink(tempPath).catch(() => {})
75
+ onFailure?.(err, finalPath)
76
+ throw err
77
+ }
78
+ }
79
+ },
80
+ })
81
+ }
82
+
83
+ // Wrap an adapter so every resource it hands out stages its writes.
84
+ export function withStagedWrites(adapter, options = {}) {
85
+ const wrap = (method) => async (...args) => stageWrites(await adapter[method](...args), options)
86
+ return new Proxy(adapter, {
87
+ get(target, prop, receiver) {
88
+ if (prop === 'getResource' || prop === 'newResource' || prop === 'newCollection') {
89
+ return wrap(prop)
90
+ }
91
+ const value = Reflect.get(target, prop, receiver)
92
+ return typeof value === 'function' ? value.bind(target) : value
93
+ },
94
+ })
95
+ }
@@ -0,0 +1,74 @@
1
+ // Target for an external WebDAV compliance run.
2
+ //
3
+ // node litmus-server.mjs # boots on :8399
4
+ // TESTS=props litmus http://127.0.0.1:8399/webdav/compliant/ litmus litmus-pw
5
+ //
6
+ // litmus (the neon project's suite) is not a dependency — install it from your
7
+ // distro. Scores are recorded in the README; this file is what produced them,
8
+ // kept so they can be reproduced rather than taken on faith.
9
+ //
10
+ // Boots the WebDAV server on a fixed port for an external compliance run.
11
+ // Two endpoints, because they should NOT score the same:
12
+ //
13
+ // /webdav/default properties+locks 'emulate' — the shipped default
14
+ // /webdav/compliant properties+locks 'meta-files' — full storage
15
+ //
16
+ // litmus's `props` suite sets a dead property and reads it back. 'emulate'
17
+ // reports success without storing, so it is expected to fail those — that is
18
+ // the documented trade, and a compliance suite is exactly the thing that
19
+ // should say so out loud.
20
+ import { mkdtemp, writeFile, mkdir } from 'node:fs/promises'
21
+ import { tmpdir } from 'node:os'
22
+ import path from 'node:path'
23
+ import bcrypt from 'bcryptjs'
24
+ import { runtime } from 'mikser-io'
25
+ import { auth } from 'mikser-io-auth'
26
+ import { webdav } from './index.js'
27
+
28
+ const PORT = Number(process.env.PORT ?? 8399)
29
+
30
+ const dir = await mkdtemp(path.join(tmpdir(), 'litmus-'))
31
+ // A folder PER endpoint. Sharing one made the two suites contaminate each
32
+ // other's leftover /litmus/ collection, which showed up as a suite aborting
33
+ // at `begin` — a harness bug that looked like a server bug.
34
+ await mkdir(path.join(dir, 'default'), { recursive: true })
35
+ await mkdir(path.join(dir, 'compliant'), { recursive: true })
36
+ await mkdir(path.join(dir, 'runtime'), { recursive: true })
37
+ await writeFile(path.join(dir, 'users.htpasswd'), `litmus:${bcrypt.hashSync('litmus-pw', 10)}\n`)
38
+
39
+ const { default: express } = await import('express')
40
+ const app = express()
41
+ runtime.options = { ...runtime.options, app, workingFolder: dir, runtimeFolder: path.join(dir, 'runtime') }
42
+ runtime.config = {}
43
+ runtime.engine = { logger: { info(){}, warn(){}, error(){}, debug(){}, trace(){}, fatal(){} } }
44
+
45
+ const identity = auth({}) // no capability map → authenticated users unscoped
46
+ const plugin = webdav({
47
+ endpoints: {
48
+ default: { folder: 'default' },
49
+ compliant: { folder: 'compliant', properties: 'meta-files', locks: 'meta-files' },
50
+ },
51
+ auth: identity,
52
+ })
53
+
54
+ const load = [], loaded = []
55
+ const core = {
56
+ runtime,
57
+ onLoad: (c) => load.push(c),
58
+ onLoaded: (c) => loaded.push(c),
59
+ useLogger: () => ({ info(){}, warn(){}, error(){}, debug(){}, trace(){} }),
60
+ }
61
+ identity(core)
62
+ plugin(core)
63
+ for (const c of load) await c()
64
+ for (const c of loaded) await c()
65
+
66
+ const server = app.listen(PORT, () => {
67
+ console.log(`litmus target ready on http://127.0.0.1:${PORT}`)
68
+ console.log(` working folder: ${dir}`)
69
+ console.log(` credentials: litmus / litmus-pw`)
70
+ console.log(` endpoints: /webdav/default /webdav/compliant`)
71
+ })
72
+ // litmus uploads and holds connections; do not let node's 5-minute cap
73
+ // truncate a suite mid-run.
74
+ server.requestTimeout = 30 * 60 * 1000
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "mikser-io-drive",
3
+ "version": "0.9.0",
4
+ "description": "WebDAV for mikser-io. Exposes working-folder directories as WebDAV endpoints via Nephele, authenticated through mikser-io-auth's htpasswd identity — so content can be edited from Finder, Explorer or any DAV client and the build picks the change up.",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/almero-digital-marketing/mikser-io-drive.git"
13
+ },
14
+ "keywords": [
15
+ "mikser",
16
+ "mikser-io",
17
+ "webdav",
18
+ "dav",
19
+ "nephele"
20
+ ],
21
+ "author": "",
22
+ "license": "MIT",
23
+ "bugs": {
24
+ "url": "https://github.com/almero-digital-marketing/mikser-io-drive/issues"
25
+ },
26
+ "homepage": "https://github.com/almero-digital-marketing/mikser-io-drive#readme",
27
+ "peerDependencies": {
28
+ "mikser-io": "^9.7.0",
29
+ "zod": "^4.0.0"
30
+ },
31
+ "dependencies": {
32
+ "@nephele/adapter-file-system": "1.0.0-alpha.67",
33
+ "@nephele/plugin-read-only": "1.0.0-alpha.67",
34
+ "nephele": "1.0.0-alpha.67"
35
+ },
36
+ "devDependencies": {
37
+ "bcryptjs": "^3.0.0",
38
+ "express": "^5.2.1",
39
+ "globby": "^15.0.0",
40
+ "mikser-io": "file:../mikser-io",
41
+ "mikser-io-auth": "file:../mikser-io-auth",
42
+ "webdav": "^5.10.0",
43
+ "zod": "^4.0.0"
44
+ }
45
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, writeFile, rm, mkdir, stat, readFile, readdir } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+ import bcrypt from 'bcryptjs'
7
+
8
+ import { runtime } from 'mikser-io'
9
+ import { auth } from 'mikser-io-auth'
10
+ import { drive } from '../index.js'
11
+
12
+ // These two properties are the reason lib/staged-writes.js exists. The
13
+ // underlying adapter writes with open(path, 'w') straight to the destination,
14
+ // which was measured to (1) expose a growing partial file and (2) destroy the
15
+ // previous contents when an upload is interrupted. Both are silent, and both
16
+ // matter because these folders are mikser sources.
17
+ let server, port, dir, content
18
+ const AUTH = 'Basic ' + Buffer.from('alice:alice-pw').toString('base64')
19
+ const CHUNK = 65536
20
+ const CHUNKS = 8
21
+
22
+ before(async () => {
23
+ dir = await mkdtemp(path.join(tmpdir(), 'mikser-drive-atomic-'))
24
+ content = path.join(dir, 'documents')
25
+ await mkdir(content, { recursive: true })
26
+ await mkdir(path.join(dir, 'runtime'), { recursive: true })
27
+ await writeFile(path.join(dir, 'users.htpasswd'), `alice:${bcrypt.hashSync('alice-pw', 10)}\n`)
28
+
29
+ const { default: express } = await import('express')
30
+ const app = express()
31
+ runtime.options = { ...runtime.options, app, workingFolder: dir, runtimeFolder: path.join(dir, 'runtime') }
32
+ runtime.config = { ...runtime.config }
33
+ runtime.engine = { ...runtime.engine, logger: { info(){}, warn(){}, error(){}, debug(){}, trace(){}, fatal(){} } }
34
+
35
+ const identity = auth({})
36
+ const plugin = drive({ endpoints: { content: { folder: 'documents' } }, auth: identity })
37
+ const load = [], loaded = []
38
+ const core = {
39
+ runtime,
40
+ onLoad: (c) => load.push(c),
41
+ onLoaded: (c) => loaded.push(c),
42
+ useLogger: () => ({ info(){}, warn(){}, error(){}, debug(){}, trace(){} }),
43
+ }
44
+ identity(core)
45
+ plugin(core)
46
+ for (const c of load) await c()
47
+ for (const c of loaded) await c()
48
+
49
+ server = await new Promise(r => { const s = app.listen(0, () => r(s)) })
50
+ port = server.address().port
51
+ })
52
+
53
+ after(async () => {
54
+ await new Promise(r => server?.close(r))
55
+ await rm(dir, { recursive: true, force: true })
56
+ })
57
+
58
+ // A body that trickles, so the upload is observable while in flight.
59
+ function slowBody({ chunks = CHUNKS, size = CHUNK, gapMs = 120, abortAt = null } = {}) {
60
+ let i = 0
61
+ return new ReadableStream({
62
+ async pull(controller) {
63
+ if (abortAt != null && i === abortAt) {
64
+ controller.error(new Error('client vanished'))
65
+ return
66
+ }
67
+ if (i >= chunks) { controller.close(); return }
68
+ controller.enqueue(new Uint8Array(size).fill(65 + (i % 26)))
69
+ i++
70
+ await new Promise(r => setTimeout(r, gapMs))
71
+ },
72
+ })
73
+ }
74
+
75
+ const put = (name, body) => fetch(`http://127.0.0.1:${port}/drive/content/${name}`, {
76
+ method: 'PUT', headers: { authorization: AUTH }, body, duplex: 'half',
77
+ })
78
+ const sizeOf = async (p) => { try { return (await stat(p)).size } catch { return null } }
79
+
80
+ describe('a partial upload is never visible at the destination', () => {
81
+ it('the target path does not exist until the upload completes', async () => {
82
+ const target = path.join(content, 'big.md')
83
+ const seen = []
84
+ const poll = setInterval(async () => seen.push(await sizeOf(target)), 100)
85
+ const res = await put('big.md', slowBody())
86
+ clearInterval(poll)
87
+ await new Promise(r => setTimeout(r, 50))
88
+
89
+ assert.ok([201, 204].includes(res.status), `unexpected ${res.status}`)
90
+ assert.equal(await sizeOf(target), CHUNK * CHUNKS, 'the whole file landed')
91
+
92
+ // Without staging, this sequence was [65536, 131072, … 524288].
93
+ const partial = seen.filter(s => s !== null && s > 0 && s < CHUNK * CHUNKS)
94
+ assert.deepEqual(partial, [], `a growing file was visible: ${JSON.stringify(seen)}`)
95
+ assert.ok(seen.length >= 3, 'the poll must have run while the upload was in flight')
96
+ })
97
+
98
+ it('leaves no staging file behind on success', async () => {
99
+ await put('clean.md', slowBody({ chunks: 2, gapMs: 20 }))
100
+ const leftovers = (await readdir(content)).filter(f => f.endsWith('.part'))
101
+ assert.deepEqual(leftovers, [])
102
+ })
103
+ })
104
+
105
+ describe('an interrupted overwrite leaves the original intact', () => {
106
+ it('does not truncate or corrupt the file that was there', async () => {
107
+ // The measured failure without staging: a 1600-byte file became
108
+ // 196608 bytes of the new content. Not the old file, not an error.
109
+ const target = path.join(content, 'precious.md')
110
+ const original = '# original content that matters\n'.repeat(50)
111
+ await writeFile(target, original)
112
+
113
+ await assert.rejects(() => put('precious.md', slowBody({ abortAt: 3 })))
114
+ await new Promise(r => setTimeout(r, 200))
115
+
116
+ assert.equal(await readFile(target, 'utf8'), original)
117
+ })
118
+
119
+ it('cleans up the staging file after a failed upload', async () => {
120
+ await assert.rejects(() => put('doomed.md', slowBody({ abortAt: 2 })))
121
+ await new Promise(r => setTimeout(r, 200))
122
+ const leftovers = (await readdir(content)).filter(f => f.endsWith('.part'))
123
+ assert.deepEqual(leftovers, [], 'a failed upload must not leave a .part file')
124
+ assert.equal(await sizeOf(path.join(content, 'doomed.md')), null,
125
+ 'and must not create the destination at all')
126
+ })
127
+ })
128
+
129
+ describe('atomicWrites: false restores the adapter behaviour', () => {
130
+ it('is available as an escape hatch', async () => {
131
+ // Documented rather than recommended: the point is that the default
132
+ // is the safe one and the unsafe one has to be asked for.
133
+ const { withStagedWrites, stageWrites } = await import('../index.js')
134
+ assert.equal(typeof withStagedWrites, 'function')
135
+ assert.equal(typeof stageWrites, 'function')
136
+ // A resource with no setStream passes through untouched.
137
+ const plain = { absolutePath: '/x' }
138
+ assert.equal(stageWrites(plain), plain)
139
+ })
140
+ })
@@ -0,0 +1,164 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { spawn } from 'node:child_process'
6
+ import path from 'node:path'
7
+ import { createRequire } from 'node:module'
8
+
9
+ // `derived` is a passthrough of what the assets plugin stamped, and it was the
10
+ // one field in the add response I could not verify by unit test: it only exists
11
+ // when a real preset matched and a real derivative was produced. So this runs
12
+ // the whole pipeline — files → assets → renderPreset → sharp — against a live
13
+ // server and asks the tool.
14
+ //
15
+ // The read is three lines. What this pins is that those three lines name the
16
+ // field the pipeline actually writes, which is the half that silently rots when
17
+ // the plugin changes shape.
18
+ //
19
+ // Two mistakes made while building it, both of which produce SILENCE rather
20
+ // than an error and are worth knowing:
21
+ //
22
+ // - a preset is a MODULE with a default export that does the work, not an
23
+ // inline options object. Without the default, renderPreset resolves
24
+ // `runtime.preset` to undefined and every render throws.
25
+ // - `match` runs against the entity ID. `outputFolder` prefixes `name` and
26
+ // `meta.url` but NOT `id`, so a glob written against the URL shape
27
+ // (/media/**) matches nothing, produces no variant, and reports only that
28
+ // none matched.
29
+
30
+ // sharp is mikser-io's dependency, not this package's — the fixture needs it
31
+ // because a preset does the actual image work. Resolved from there rather than
32
+ // from here, and the fixture's node_modules is built from mikser-io's so the
33
+ // spawned build finds it too.
34
+ const require_ = createRequire(import.meta.url)
35
+ const ENGINE = path.resolve(import.meta.dirname, '..', '..', 'mikser-io')
36
+ const hasSharp = (() => {
37
+ try { require_.resolve('sharp', { paths: [ENGINE] }); return true } catch { return false }
38
+ })()
39
+
40
+ const PORT = 4211
41
+ const PNG = Buffer.from(
42
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
43
+ 'base64')
44
+
45
+ let dir, server
46
+
47
+ async function rpc(token, method, params, session) {
48
+ const headers = {
49
+ 'content-type': 'application/json',
50
+ accept: 'application/json, text/event-stream',
51
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
52
+ ...(session.id ? { 'mcp-session-id': session.id } : {}),
53
+ }
54
+ const res = await fetch(`http://127.0.0.1:${PORT}/mcp`,
55
+ { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: session.n++, method, params }) })
56
+ if (res.headers.get('mcp-session-id')) session.id = res.headers.get('mcp-session-id')
57
+ const text = await res.text()
58
+ const frames = text.split('\n').filter(l => l.startsWith('data:')).map(l => JSON.parse(l.slice(5)))
59
+ return (frames.at(-1) ?? JSON.parse(text)).result
60
+ }
61
+
62
+ before(async (t) => {
63
+ if (!hasSharp) return
64
+ dir = await mkdtemp(path.join(tmpdir(), 'mikser-derived-'))
65
+ await mkdir(path.join(dir, 'media'), { recursive: true })
66
+ await mkdir(path.join(dir, 'presets'), { recursive: true })
67
+ await mkdir(path.join(dir, 'documents'), { recursive: true })
68
+
69
+ // node_modules by symlink, so the fixture resolves the working copies.
70
+ const here = path.resolve(import.meta.dirname, '..')
71
+ await mkdir(path.join(dir, 'node_modules'), { recursive: true })
72
+ const { symlink, readdir } = await import('node:fs/promises')
73
+ // Both donors: this package's own deps, plus the engine's — which is where
74
+ // sharp lives, and a preset cannot run without it.
75
+ for (const donor of [path.join(here, 'node_modules'), path.join(ENGINE, 'node_modules')]) {
76
+ for (const entry of await readdir(donor).catch(() => [])) {
77
+ await symlink(path.join(donor, entry), path.join(dir, 'node_modules', entry)).catch(() => {})
78
+ }
79
+ }
80
+ for (const pkg of ['mikser-io', 'mikser-io-mcp', 'mikser-io-drive']) {
81
+ await rm(path.join(dir, 'node_modules', pkg), { recursive: true, force: true })
82
+ await symlink(path.resolve(here, '..', pkg), path.join(dir, 'node_modules', pkg)).catch(() => {})
83
+ }
84
+
85
+ // A preset is a module whose DEFAULT export does the work.
86
+ await writeFile(path.join(dir, 'presets/web.js'),
87
+ "import sharp from 'sharp'\n"
88
+ + 'export const revision = 1\n'
89
+ + "export const format = 'webp'\n"
90
+ + 'export const options = { width: 8 }\n'
91
+ + 'export default ({ entity: { source, destination }, options }) =>\n'
92
+ + ' sharp(source).resize(options).webp().toFile(destination)\n')
93
+
94
+ // `match` against the ID shape, which the files plugin builds from the path
95
+ // inside filesFolder — NOT the URL shape.
96
+ await writeFile(path.join(dir, 'mikser.config.js'),
97
+ "import { files, assets, renderPreset } from 'mikser-io'\n"
98
+ + "import { mcp } from 'mikser-io-mcp'\n"
99
+ + "import { drive } from 'mikser-io-drive'\n"
100
+ + 'export default async () => ({\n'
101
+ + ' plugins: [\n'
102
+ + " mcp({ base: '' }),\n"
103
+ + " files({ filesFolder: 'media', outputFolder: 'media' }),\n"
104
+ + " assets({ assetsFolder: 'derived', presetsFolder: 'presets',\n"
105
+ + " presets: { web: { match: ['/files/**/*.png'] } } }),\n"
106
+ + ' renderPreset(),\n'
107
+ + " drive({ allowRemote: true, endpoints: { media: { folder: 'media' } } }),\n"
108
+ + ' ],\n'
109
+ + '})\n')
110
+
111
+ server = spawn(process.execPath, [
112
+ path.join(dir, 'node_modules/mikser-io/app.js'),
113
+ '--working-folder', dir, '--output-folder', 'out', '--runtime-folder', 'runtime',
114
+ '--watch', '--server', String(PORT), '--url', `http://127.0.0.1:${PORT}`,
115
+ ], { cwd: dir, stdio: 'ignore' })
116
+
117
+ // Wait for it to answer rather than sleeping a guess.
118
+ for (let i = 0; i < 60; i++) {
119
+ try {
120
+ const res = await fetch(`http://127.0.0.1:${PORT}/mcp`, { method: 'POST' })
121
+ if (res.status) break
122
+ } catch { await new Promise(r => setTimeout(r, 1000)) }
123
+ }
124
+ await new Promise(r => setTimeout(r, 3000))
125
+ })
126
+
127
+ after(async () => {
128
+ server?.kill()
129
+ if (dir) await rm(dir, { recursive: true, force: true })
130
+ })
131
+
132
+ describe('derived — the preset passthrough', { skip: hasSharp ? false : 'sharp is not installed' }, () => {
133
+ it('reports the variant a preset actually produced', async () => {
134
+ const session = { id: null, n: 1 }
135
+ await rpc(null, 'initialize',
136
+ { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } }, session)
137
+ await fetch(`http://127.0.0.1:${PORT}/mcp`, {
138
+ method: 'POST',
139
+ headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream',
140
+ 'mcp-session-id': session.id },
141
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
142
+ })
143
+
144
+ const result = await rpc(null, 'tools/call', {
145
+ name: 'mikser_drive_add',
146
+ arguments: { endpoint: 'media', folder: 'shots',
147
+ files: [{ name: 'hero.png', base64: PNG.toString('base64'), mime: 'image/png' }] },
148
+ }, session)
149
+ assert.equal(result?.isError, undefined, result?.content?.[0]?.text)
150
+
151
+ const body = JSON.parse(result.content[0].text)
152
+ const stored = body.files[0]
153
+ assert.equal(stored.imported, true, 'the cycle must have imported it')
154
+ assert.equal(stored.reference, '/media/shots/hero.png')
155
+
156
+ // The field this whole fixture exists for.
157
+ assert.ok(stored.derived, `expected derived variants, got: ${stored.derivedNote ?? '(nothing)'}`)
158
+ assert.ok(stored.derived.web, `expected a "web" variant, got ${JSON.stringify(stored.derived)}`)
159
+ assert.match(stored.derived.web, /\.webp$/,
160
+ 'the preset declares format webp, so the variant URL should carry that extension')
161
+ assert.notEqual(stored.derived.web, stored.reference,
162
+ 'the variant must be a different URL from the original, or nothing was derived')
163
+ })
164
+ })