const { test } = require('node:test') const assert = require('node:assert/strict') const fs = require('node:fs') const vm = require('node:vm') const ts = require('typescript') const React = require('react') const { renderToStaticMarkup } = require('react-dom/server') function setup() { const api = {} const revoked = [] let count = 0 vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/chatAttachments.ts'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: api, TextDecoder, createImageBitmap: async file => { if (file.invalid) throw Error('invalid') return { width: file.width ?? 10, height: file.height ?? 10, close() {} } }, URL: { createObjectURL: () => `blob:fixture-${++count}`, revokeObjectURL: url => revoked.push(url) } }) return { api, read: (files, existing = []) => api.readChatAttachments(files, existing, { imagesEnabled: true }), revoked } } const image = (extra = {}) => ({ name: 'screen.png', size: 100, ...extra }) test('image and text attachments coexist without counting image bytes as text context', async () => { const h = setup() const files = await h.read([image({ size: 5 * 1024 * 1024 }), { name: 'config.yaml', size: 10, arrayBuffer: async () => new TextEncoder().encode('robot: mini').buffer }]) assert.equal(files[0].kind, 'image') assert.equal(files[0].previewUrl, 'blob:fixture-1') assert.equal(files[1].content, 'robot: mini') }) test('bounds image counts, byte sizes, decoded pixels and rejects malformed images', async () => { const h = setup() await assert.rejects(h.read([image({ size: 5 * 1024 * 1024 + 1 })]), /5MB/) await assert.rejects(h.read([image({ width: 4001, height: 4001 })]), /1600만/) await assert.rejects(h.read([image({ invalid: true })]), /읽지 못/) await assert.rejects(h.read(Array.from({ length: 4 }, () => image())), /3장/) assert.equal(h.revoked.length, 3) }) test('a mixed batch failure revokes only newly created image previews', async () => { const h = setup() const existing = await h.read([image()]) await assert.rejects(h.read([image(), { name: 'file.pdf', size: 20 }], existing), /PDF/) assert.deepEqual(h.revoked, ['blob:fixture-2']) assert.equal(existing[0].previewUrl, 'blob:fixture-1') }) test('restores cached multipart filenames while preserving valid Unicode and Latin-1 names', () => { const { api } = setup() const name = '스크린샷 2026-10-19 14-30-45.png' assert.equal(api.displayAttachmentName(Buffer.from(name).toString('latin1')), name) for (const value of [name, 'screen.png', 'caf\u00e9.png', '\uD83D\uDCF7.png']) assert.equal(api.displayAttachmentName(value), value) }) function previewHarness() { const { api } = setup() const exports = {} let preview = null vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatImageAttachments.tsx'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, { exports, document: { body: {} }, require(name) { if (name === 'react') return { ...React, useEffect() {}, useRef: () => ({ current: null }), useState: () => [preview, value => { preview = value }] } if (name === 'react-dom') return { createPortal: child => child } if (name === '../i18n') return { t: key => key, useLocaleRender() {} } if (name === './chatAttachments') return api return require(name) } }) const find = (node, predicate) => { if (!React.isValidElement(node)) return null if (predicate(node)) return node return React.Children.toArray(node.props.children).map(child => find(child, predicate)).find(Boolean) } return { render: props => exports.ChatImageAttachments(props), find } } for (const dark of [true, false]) { test(`draft combines thumbnail, truncated filename and removal in one bounded item (${dark ? 'dark' : 'light'})`, () => { const h = previewHarness() const name = '스크린샷-긴파일이름-'.repeat(12) + '.png' let removed const props = { images: [{ name, url: 'blob:fixture' }], dark, onRemove: image => { removed = image.url } } const tree = h.render(props) const item = h.find(tree, node => node.props['data-chat-image-draft']) assert.ok(item) assert.match(item.props.className, /w-56.*max-w-full/) assert.ok(h.find(item, node => node.type === 'img')) const label = h.find(item, node => node.type === 'span') assert.equal(label.props.children, name) assert.match(label.props.className, /truncate/) const zoom = h.find(item, node => node.type === 'button' && node.props.title === name) assert.equal(zoom.props.title, name) h.find(item, node => node.type === 'button' && node.props['aria-label'] === `첨부 제거: ${name}`).props.onClick() assert.equal(removed, 'blob:fixture') const busyItem = h.find(h.render({ ...props, removeDisabled: true }), node => node.props['data-chat-image-draft']) assert.equal(h.find(busyItem, node => node.type === 'button' && node.props['aria-label'] === `첨부 제거: ${name}`).props.disabled, true) }) } test('cached garbled names are restored in the thumbnail label and enlarged dialog', () => { const h = previewHarness() const name = '스크린샷 2026-10-19 14-30-45.png' const props = { images: [{ name: Buffer.from(name).toString('latin1'), url: 'https://files.test/image.png' }], variant: 'sent' } const tree = h.render(props) assert.equal(h.find(tree, node => node.type === 'button').props.title, name) assert.equal(h.find(tree, node => node.props['data-chat-image-draft']), undefined) h.find(tree, node => node.type === 'button').props.onClick() const html = renderToStaticMarkup(h.render(props)) assert.match(html, /]*aria-label="스크린샷 2026-10-19 14-30-45.png"/) assert.ok(html.includes(`>${name}`)) })