import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { resolveClaudeAttachments } from './attachment-resolver.mjs'; import { createRequestKey } from './request-key.mjs'; import { isInside, resolveOutputRoot, resolveRunDirectory } from './run-paths.mjs'; import { readState, writeState } from './state-store.mjs'; const projectRoot = path.resolve(import.meta.dirname, '..', '..', '..', '..'); function tempDirectory(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), `fmode-image-set-${prefix}-`)); } test('request key is deterministic', () => { const a = createRequestKey({ sessionId: 's', promptId: 'p', action: 'generate', planHash: 'h', extra: { b: 2, a: 1 } }); const b = createRequestKey({ sessionId: 's', promptId: 'p', action: 'generate', planHash: 'h', extra: { a: 1, b: 2 } }); assert.equal(a, b); }); test('run paths reject project escape and skill source outputs', () => { const packageRoot = path.join(projectRoot, 'fmode-image-set'); assert.throws(() => resolveOutputRoot({ workspaceRoot: projectRoot, outputRoot: path.resolve(projectRoot, '..', 'escape') }), /固定/); assert.throws(() => resolveOutputRoot({ workspaceRoot: projectRoot, outputRoot: path.join(projectRoot, 'custom-output') }), /固定/); assert.throws(() => resolveOutputRoot({ workspaceRoot: packageRoot, packageRoot }), /Skill 源码/); const paths = resolveRunDirectory({ workspaceRoot: projectRoot, packageRoot, runId: 'run_abcdef' }); assert.equal(isInside(projectRoot, paths.runDirectory), true); }); test('Claude attachment filters require exact session, prompt, and cwd fields', () => { const config = tempDirectory('claude-strict'); const logs = path.join(config, 'projects', 'fixture'); fs.mkdirSync(logs, { recursive: true }); const records = [ { timestamp: '2026-01-01T00:00:00Z', cwd: projectRoot, message: { id: 'p1', role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }] } }, { timestamp: '2026-01-01T00:01:00Z', sessionId: 's1', message: { id: 'p1', role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'BBBB' } }] } } ]; fs.writeFileSync(path.join(logs, 's1.jsonl'), records.map(JSON.stringify).join('\n')); try { assert.equal(resolveClaudeAttachments({ claudeConfigDir: config, sessionId: 's1', promptId: 'p1', cwd: projectRoot }).length, 0); } finally { fs.rmSync(config, { recursive: true, force: true }); } }); test('state checksum detects tampering', () => { const run = tempDirectory('state'); try { writeState(run, { status: 'planned', value: 1 }); assert.equal(readState(run).value, 1); const file = path.join(run, 'state.json'); const doc = JSON.parse(fs.readFileSync(file, 'utf8')); doc.payload.value = 2; fs.writeFileSync(file, JSON.stringify(doc)); assert.throws(() => readState(run), /完整性/); } finally { fs.rmSync(run, { recursive: true, force: true }); } }); test('latest Claude user request with no image never reuses older attachment', () => { const config = tempDirectory('claude'); const logs = path.join(config, 'projects', 'fixture'); fs.mkdirSync(logs, { recursive: true }); const records = [ { timestamp: '2026-01-01T00:00:00Z', sessionId: 's1', cwd: projectRoot, message: { id: 'p1', role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }] } }, { timestamp: '2026-01-01T00:01:00Z', sessionId: 's1', cwd: projectRoot, message: { id: 'p2', role: 'user', content: [{ type: 'text', text: 'no image' }] } } ]; fs.writeFileSync(path.join(logs, 's1.jsonl'), records.map(JSON.stringify).join('\n')); try { const current = resolveClaudeAttachments({ claudeConfigDir: config, sessionId: 's1', cwd: projectRoot }); assert.equal(current.length, 0); const exact = resolveClaudeAttachments({ claudeConfigDir: config, sessionId: 's1', promptId: 'p1', cwd: projectRoot }); assert.equal(exact.length, 1); } finally { fs.rmSync(config, { recursive: true, force: true }); } });