From 21c7ce026bd77da33c09ec63e7feb76d82428523 Mon Sep 17 00:00:00 2001 From: Nisconder <2129048448@qq.com> Date: Fri, 13 Nov 2026 13:47:36 +0800 Subject: [PATCH 1/3] Fix five Studio editing and compilation regressions (#3) --- server/server.mjs | 5 +- src/js/main.js | 18 +++- src/js/preview.js | 55 ++++++++--- src/js/visual-math.js | 30 +++++- src/js/workspace.js | 69 +++++++++++++- tests/image-label-regression.test.mjs | 93 ++++++++++++++++++ tests/preview-integration.test.mjs | 59 ++++++++++++ tests/preview.test.mjs | 66 ++++++++++++- tests/server.test.mjs | 7 +- tests/visual-math-ui.test.mjs | 49 ++++++++++ tests/workspace-archive-types.test.mjs | 126 +++++++++++++++++++++++++ 11 files changed, 550 insertions(+), 27 deletions(-) create mode 100644 tests/image-label-regression.test.mjs create mode 100644 tests/preview-integration.test.mjs create mode 100644 tests/workspace-archive-types.test.mjs diff --git a/server/server.mjs b/server/server.mjs index 6319f88..b201746 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -162,7 +162,7 @@ export function createWorkspaceServer({ if (!delivered && job.active) { job.state = 'cancelled'; job.controller.abort(); } }); json(202, {id: job.id}); - Promise.resolve().then(async () => { + job.completion = Promise.resolve().then(async () => { const engines = await getEngines(); if (job.controller.signal.aborted) return; const result = await compiler(payload.project, {kind: payload.kind, mainPath: payload.mainPath, signal: job.controller.signal, engines}); @@ -185,6 +185,9 @@ export function createWorkspaceServer({ if (previewActive?.job === job) previewActive.controller.abort(); if (job.active) { job.state = 'cancelled'; job.controller.abort(); + // Acknowledging cancellation also means the compiler slot is + // free; process termination and temporary-file cleanup can lag abort. + await job.completion; } json(200, {id: job.id, state: job.state}); return; diff --git a/src/js/main.js b/src/js/main.js index a195611..e11f91a 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -229,6 +229,15 @@ function insertBlockFormula() { openFormulaModal('block'); } +// 标签是引用标识符,不能用正文转义规则改写下划线等合法字符。 +function normalizeImageLabel(value) { + const label = String(value ?? '').trim(); + if (/[\\{}\[\],%#$&^~*\s\u0000-\u001f\u007f]/.test(label)) { + throw new Error('图片标签不能包含空白、括号、反斜杠或 LaTeX 控制字符'); + } + return label; +} + // 插入图片 function insertImage(options) { const supplied = options && typeof options.imagePath === 'string'; @@ -237,8 +246,10 @@ function insertImage(options) { if (!imagePath) return; const caption = supplied ? options.caption : prompt('请输入图片标题:', '图片标题'); if (caption === null) return; - const label = supplied ? options.label : prompt('请输入图片标签(用于引用):', 'fig:example'); + let label = supplied ? options.label : prompt('请输入图片标签(用于引用):', 'fig:example'); if (label === null) return; + try { label = normalizeImageLabel(label); } + catch (error) { updateStatus(error.message); return; } const img = document.createElement('img'); img.src = imagePath; @@ -1002,7 +1013,8 @@ function convertNodeToLatex(node) { case 'img': { const imgPath = node.getAttribute('src') || ''; const caption = node.getAttribute('data-caption'); - const label = node.getAttribute('data-label'); + const rawLabel = node.getAttribute('data-label'); + const label = node.getAttribute('data-label-tex') === 'true' ? rawLabel : normalizeImageLabel(rawLabel); const width = node.getAttribute('data-width'); const placement = node.getAttribute('data-placement'); latex = '\\begin{figure}' + (placement === '' ? '' : '[' + (placement || 'htbp') + ']') + '\n'; @@ -1012,7 +1024,7 @@ function convertNodeToLatex(node) { if (caption || node.getAttribute('data-has-caption') === 'true') { latex += ' \\caption{' + (node.getAttribute('data-caption-tex') === 'true' ? caption || '' : escapeLatex(caption || '')) + '}\n'; } - if (label) latex += ' \\label{' + (node.getAttribute('data-label-tex') === 'true' ? label : escapeLatex(label)) + '}\n'; + if (label) latex += ' \\label{' + label + '}\n'; latex += '\\end{figure}' + separator; break; } diff --git a/src/js/preview.js b/src/js/preview.js index b1a10c7..2e26331 100644 --- a/src/js/preview.js +++ b/src/js/preview.js @@ -41,7 +41,10 @@ this.result = null; this.stale = false; this.generation = 0; + this.connectionGeneration = 0; this.jobId = null; + this.submission = null; + this.cancellation = null; this.abort = null; this.disposed = false; this.preview = {state: 'empty', message: '', url: null, key: null, targetKey: null, page: 0, pageCount: 0, maxPages: 100}; @@ -49,7 +52,7 @@ this.previewAbort = null; this.previewTask = Promise.resolve(false); } - get busy() { return this.phase === 'running'; } + get busy() { return this.phase === 'running' || Boolean(this.cancellation); } emit() { if (!this.disposed) this.onChange(this); } async responseJson(response) { let body; @@ -60,6 +63,8 @@ headers() { return {'X-VisualLaTeX-Token': this.token}; } async connect() { if (this.busy || this.disposed) return false; + const connection = ++this.connectionGeneration, generation = this.generation; + const current = () => !this.disposed && !this.busy && generation === this.generation && connection === this.connectionGeneration; if (this.protocol !== 'http:' && this.protocol !== 'https:') { this.phase = 'unavailable'; this.message = '编译预览需要本机服务:在项目目录运行 node server/server.mjs,再打开终端显示的网址。'; @@ -67,6 +72,7 @@ } try { const body = await this.responseJson(await this.fetch('/api/status', {credentials: 'same-origin', cache: 'no-store'})); + if (!current()) return false; if (!body.token || !body.engines) throw new Error('当前页面没有连接到 VisualLaTeX 编译服务。'); this.token = body.token; this.engines = body.engines; @@ -74,6 +80,7 @@ this.message = '选择主文件后编译;项目文件仅发送到本机服务。'; this.emit(); return true; } catch (error) { + if (!current()) return false; this.token = null; this.phase = 'unavailable'; this.message = '无法连接编译服务。请运行 node server/server.mjs 并打开终端网址。' + error.message; @@ -88,21 +95,37 @@ this.emit(); } fresh(request) { return Boolean(this.result && this.result.key === requestKey(request)); } - async deleteJob(id) { - if (!id || !this.token) return; - try { await this.fetch('/api/jobs/' + encodeURIComponent(id), {method: 'DELETE', headers: this.headers(), credentials: 'same-origin', keepalive: true}); } + async deleteJob(id, token = this.token) { + if (!id || !token) return; + try { await this.fetch('/api/jobs/' + encodeURIComponent(id), {method: 'DELETE', headers: {'X-VisualLaTeX-Token': token}, credentials: 'same-origin', keepalive: true}); } catch (_) { /* Server timeout/cleanup still bounds an unreachable job. */ } } + cancelSubmission(submission) { + return submission.cancellation ||= submission.promise.then(job => { + if (typeof job?.id === 'string' && /^[a-zA-Z0-9_-]+$/.test(job.id)) return this.deleteJob(job.id, submission.token); + }, () => {}); + } async cancel() { + if (this.cancellation) return this.cancellation; const id = this.jobId; - this.generation++; + const submission = this.submission, wasBusy = this.busy; + const generation = ++this.generation; this.abort?.abort(); this.abort = null; this.jobId = null; this.stopPreview(); - if (this.busy) { this.phase = 'cancelled'; this.message = '已取消本次编译。'; } + if (wasBusy) { this.phase = 'cancelled'; this.message = '已取消本次编译。'; } + if (!submission && !id) { this.emit(); return; } + const cleanup = submission ? this.cancelSubmission(submission) : this.deleteJob(id); + this.cancellation = cleanup; + if (wasBusy) { this.phase = 'cancelling'; this.message = '正在取消本次编译…'; } this.emit(); - await this.deleteJob(id); + try { await cleanup; } + finally { + if (this.cancellation === cleanup) this.cancellation = null; + if (wasBusy && generation === this.generation) { this.phase = 'cancelled'; this.message = '已取消本次编译。'; } + this.emit(); + } } clear() { this.cancel(); @@ -215,14 +238,19 @@ this.message = request.kind === 'postscript' ? '正在渲染 PostScript…' : '正在编译 LaTeX…'; this.log = ''; this.emit(); - let job; + if (generation !== this.generation) return false; + let job, submission; try { - job = await this.responseJson(await this.fetch('/api/compile', { + // Keep the creation response readable after cancellation: otherwise + // a server job can start before fetch exposes the id needed to delete it. + submission = {token: this.token, promise: this.fetch('/api/compile', { method: 'POST', headers: {...this.headers(), 'Content-Type': 'application/json'}, - credentials: 'same-origin', body: JSON.stringify(request), signal: abort.signal - })); + credentials: 'same-origin', body: JSON.stringify(request) + }).then(response => this.responseJson(response))}; + this.submission = submission; + job = await submission.promise; if (typeof job.id !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(job.id)) throw new Error('编译服务返回的任务编号无效。'); - if (generation !== this.generation) { await this.deleteJob(job.id); return false; } + if (generation !== this.generation) { await this.cancelSubmission(submission); return false; } this.jobId = job.id; const started = Date.now(); for (;;) { @@ -273,9 +301,10 @@ // A failed polling/download request does not stop the engine. // Release the known job before discarding its id, while keeping // the original error visible if cleanup itself cannot connect. - await this.deleteJob(job?.id); + if (submission) await this.cancelSubmission(submission); return false; } finally { + if (this.submission === submission) this.submission = null; if (generation === this.generation) { this.abort = null; this.jobId = null; } } } diff --git a/src/js/visual-math.js b/src/js/visual-math.js index cdfda28..5682a8f 100644 --- a/src/js/visual-math.js +++ b/src/js/visual-math.js @@ -168,7 +168,7 @@ const field = document.getElementById('formulaMathField'); if (!field || !root.customElements?.get('math-field')) return false; const parts = split(source); - session = {parts, dirty:false, values:[], field, node}; + session = {parts, dirty:false, values:[], field, node, advancedSource:source}; const error = document.getElementById('formulaError'); error.textContent = ''; field.readOnly = !safe(source); field.value = safe(source) ? mathValue(parts,node?.getAttribute('data-environment')) : ''; @@ -182,7 +182,14 @@ label.textContent = (entry.kind === 'label' ? '公式标签' : '显示编号')+(parts.rows.length > 1 ? '(第 '+(entry.row+1)+' 行)' : ''); const input = document.createElement('input'); input.value = entry.value; input.setAttribute('aria-label',label.textContent); - input.addEventListener('input',()=> { if(session) session.values[i]=input.value; }); + input.addEventListener('input',()=> { + if (!session) return; + session.values[i] = input.value; + // Advanced edits rebuild the session from this source. Keep + // it current so they cannot resurrect old labels or tags. + try { syncAdvancedInput(); error.textContent = ''; } + catch (problem) { error.textContent = problem.message; } + }); label.appendChild(input); metadata.appendChild(label); }); if (focus) field.focus(); @@ -193,6 +200,11 @@ const {parts,field,values,dirty} = session; return combine(parts,field.value,values,dirty); } + function syncAdvancedInput() { + const source = value(); + document.getElementById('formulaInput').value = source; + if (session) session.advancedSource = source; + } function initialize() { if (!root.MathfieldElement) return; root.MathfieldElement.fontsDirectory = './fonts'; @@ -203,10 +215,22 @@ field.addEventListener('input',()=> { if (!session) return; session.dirty = true; session.advanced = false; - try { document.getElementById('formulaInput').value = value(); } catch (_) { /* Commit reports row validation. */ } + try { syncAdvancedInput(); } catch (_) { /* Commit reports row validation. */ } }); document.getElementById('formulaInput').addEventListener('input',()=> { if (!session) return; + try { + session.parts.metadata.forEach((entry,i) => { + const next = session.values[i]; + if (entry.kind !== 'comment' && next !== undefined && next !== entry.value) validateValue(entry.kind,next); + }); + } catch (problem) { + // Do not replace unsaved invalid fields with an older source + // snapshot. Let the user correct them before changing views. + document.getElementById('formulaInput').value = session.advancedSource; + document.getElementById('formulaError').textContent = problem.message + ' 请先修正标签或编号,再编辑高级输入。'; + return; + } const source=document.getElementById('formulaInput').value; open(source,session.node,false); session.advanced=true; diff --git a/src/js/workspace.js b/src/js/workspace.js index d627c6c..a978b8a 100644 --- a/src/js/workspace.js +++ b/src/js/workspace.js @@ -115,6 +115,54 @@ function workspaceFile(path, bytes) { return {path, kind: 'binary', base64: btoa(binary)}; } +function workspaceArchiveFiles(entries, fileTypes) { + // Overrides are [index, type, byteLength, pathCRC32, contentCRC32]. Binding + // them to both path and bytes makes stale comments from external repacking + // harmless; indexes avoid repeating up to 500 long paths in a 64 KiB comment. + const types = new Map(); + const uint32 = value => Number.isInteger(value) && value >= 0 && value <= 0xffffffff; + if (Array.isArray(fileTypes) && fileTypes.length <= ProjectModel.MAX_FILES) { + for (const record of fileTypes) { + if (!Array.isArray(record) || record.length !== 5 || !Number.isInteger(record[0]) || + record[0] < 0 || record[0] >= entries.length || types.has(record[0]) || + !['utf-8', 'latin1', 'binary'].includes(record[1]) || + !Number.isInteger(record[2]) || record[2] < 0 || record[2] > ProjectModel.MAX_TOTAL_BYTES || + !uint32(record[3]) || !uint32(record[4])) { + types.clear(); + break; + } + types.set(record[0], record); + } + } + return entries.map(({path, bytes}, index) => { + const detected = workspaceFile(path, bytes); + const record = types.get(index); + if (!record || record[2] !== bytes.length || + record[3] !== ProjectArchive.crc32(new TextEncoder().encode(path)) || + record[4] !== ProjectArchive.crc32(bytes)) return detected; + let restored; + try { + if (record[1] === 'binary') { + let binary = ''; + for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192)); + restored = {path, kind: 'binary', base64: btoa(binary)}; + } else { + let text; + if (record[1] === 'utf-8') text = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(bytes); + else { + text = ''; + for (let i = 0; i < bytes.length; i += 8192) text += String.fromCharCode(...bytes.subarray(i, i + 8192)); + } + restored = {path, kind: 'text', text, encoding: record[1]}; + } + // Metadata is a hint, never permission for lossy decoding. + const encoded = workspaceBytes(restored); + if (encoded.length === bytes.length && encoded.every((byte, offset) => byte === bytes[offset])) return restored; + } catch (_) { /* Invalid text metadata falls back to normal byte detection. */ } + return detected; + }); +} + // Locate the clicked protected token within its own source unit, not the first // identical citation elsewhere in the document. Offsets match textarea newlines. function locateWorkspaceToken(editor, target, source) { @@ -275,10 +323,20 @@ class WorkspaceController { this.sync(); return this.model.list().map(file => ({path: file.path, bytes: workspaceBytes(file)})); } - exportComment() { + exportComment(entries = this.exportEntries()) { // ZIP comments keep project settings out of the user's file namespace. - return JSON.stringify({format: 'visual-workspace', version: 1, name: this.model.name, - mainPath: this.model.mainPath, activePath: this.model.activePath}); + const metadata = {format: 'visual-workspace', version: 1, name: this.model.name, + mainPath: this.model.mainPath, activePath: this.model.activePath}; + const fileTypes = []; + this.model.list().forEach((file, index) => { + const bytes = entries[index].bytes; + const detected = workspaceFile(file.path, bytes); + if (detected.kind === file.kind && detected.encoding === file.encoding) return; + fileTypes.push([index, file.kind === 'binary' ? 'binary' : file.encoding, bytes.length, + ProjectArchive.crc32(new TextEncoder().encode(file.path)), ProjectArchive.crc32(bytes)]); + }); + if (fileTypes.length) metadata.fileTypes = fileTypes; + return JSON.stringify(metadata); } } @@ -583,7 +641,8 @@ function initWorkspace() { } bind('btnDownload', () => { app.sync(); const file = app.model.get(); if (file) download(workspaceBytes(file), file.path.split('/').pop()); }); bind('btnExportProject', () => { - const bytes = ProjectArchive.writeZip(app.exportEntries(), {comment: app.exportComment()}); + const entries = app.exportEntries(); + const bytes = ProjectArchive.writeZip(entries, {comment: app.exportComment(entries)}); download(bytes, (app.model.name || 'project').replace(/[\\/:*?"<>|]/g, '_') + '.zip', 'application/zip'); notify('项目 ZIP 已导出,包含所有源码和资源文件'); }); @@ -652,7 +711,7 @@ function initWorkspace() { if (value && value.format === 'visual-workspace' && value.version === 1 && typeof value.name === 'string' && value.name.trim() && value.name.length <= 200) metadata = value; } catch (_) { /* ZIP comments from other tools are not project metadata. */ } - const files = archive.files.map(entry => workspaceFile(entry.path, entry.bytes)); + const files = workspaceArchiveFiles(archive.files, metadata?.fileTypes); const main = files.find(item => item.kind === 'text' && item.path === 'main.tex') || files.find(item => item.kind === 'text' && /\.(tex|ps|eps)$/i.test(item.path)) || files.find(item => item.kind === 'text'); const first = main || files[0]; candidate = new ProjectModel({name: metadata ? metadata.name : file.name.replace(/\.zip$/i, ''), files, diff --git a/tests/image-label-regression.test.mjs b/tests/image-label-regression.test.mjs new file mode 100644 index 0000000..a5ddb3b --- /dev/null +++ b/tests/image-label-regression.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import test from 'node:test'; +import {createDocument, Element} from './helpers/dom.mjs'; + +function fixture() { + const document = createDocument(); + const createElement = document.createElement; + document.createElement = tag => { + const node = createElement(tag); + for (const name of ['src', 'alt']) Object.defineProperty(node, name, { + get() { return this.getAttribute(name); }, + set(value) { this.setAttribute(name, value); }, + }); + return node; + }; + const editor = new Element('div'); + const statuses = []; + const context = vm.createContext({document, TextEncoder, Node: {TEXT_NODE: 3, ELEMENT_NODE: 1}, + __editor: editor, __statuses: statuses}); + for (const name of ['main.js', 'project-model.js', 'visual-references.js']) { + vm.runInContext(fs.readFileSync(new URL('../' + name, import.meta.url), 'utf8'), context); + } + // Only cursor placement and persistence notifications are outside this integration check. + vm.runInContext(`editorArea = __editor; + insertNodeAtCursor = node => Boolean(editorArea.appendChild(node)); + updatePreview = () => {}; saveDraft = () => true; + updateStatus = message => __statuses.push(message);`, context); + return {context, editor, statuses}; +} + +test('inserted image identifiers remain selectable and usable by the reference editor', () => { + for (const label of ['fig:my_plot', '图:结果_2026.1-甲']) { + const {context} = fixture(); + context.insertImage({imagePath: 'plot.png', caption: 'Rate 50% & value_1', label}); + const tex = context.domToLatex(); + assert.ok(tex.includes('\\label{' + label + '}')); + assert.ok(tex.includes(String.raw`\caption{Rate 50\% \& value\_1}`)); + const model = new context.ProjectModel({files: [{path: 'main.tex', kind: 'text', text: tex}], activePath: 'main.tex'}); + const choices = context.VisualReferences.getChoices('reference', model); + assert.equal(choices.length, 1); + assert.equal(choices[0].value, label); + assert.equal(context.VisualReferences.rewrite(String.raw`\ref{old}`, {key: choices[0].value}), '\\ref{' + label + '}'); + } +}); + +test('invalid new image labels leave the document intact and report the input error', () => { + for (const label of ['fig:{bad}', String.raw`fig:bad\name`, 'fig:two words', 'fig:bad%name', 'fig:bad#name', 'fig:bad,name', 'fig:bad*name']) { + const {context, editor, statuses} = fixture(); + editor.innerHTML = '

Keep this document.

'; + const before = context.domToLatex(); + context.insertImage({imagePath: 'plot.png', caption: 'Plot', label}); + assert.equal(context.domToLatex(), before); + assert.equal(editor.querySelectorAll('img').length, 0); + assert.match(statuses.at(-1), /标签/); + } +}); + +test('empty image labels stay optional while surrounding whitespace is normalized', () => { + for (const label of ['', ' ', ' fig:my_plot ']) { + const {context} = fixture(); + context.insertImage({imagePath: 'plot.png', caption: 'Plot', label}); + const tex = context.domToLatex(); + if (label.trim()) assert.ok(tex.includes('\\label{' + label.trim() + '}')); + else assert.doesNotMatch(tex, /\\label\{/); + } +}); + +test('image export rejects invalid legacy user labels instead of emitting malformed TeX', () => { + const {context, editor} = fixture(); + editor.innerHTML = ''; + assert.throws(() => context.domToLatex(), /标签/); +}); + +test('editing an imported image preserves its original TeX label and caption', () => { + for (const label of ['fig:my_plot', String.raw`fig:\customkey`]) { + const {context, editor} = fixture(); + const tex = String.raw`\documentclass{article} +\usepackage{graphicx} +\newcommand{\customkey}{imported} +\begin{document} +\begin{figure}\includegraphics{old.png}\caption{A \textbf{caption}}\label{${label}}\end{figure} +\end{document}`; + context.applyImportedTex(tex, 'imported.tex'); + assert.equal(context.domToLatex(), tex); + editor.querySelector('img').setAttribute('src', 'new.png'); + const edited = context.domToLatex(); + assert.ok(edited.includes(String.raw`\includegraphics{new.png}`)); + assert.ok(edited.includes(String.raw`\caption{A \textbf{caption}}`)); + assert.ok(edited.includes('\\label{' + label + '}')); + } +}); diff --git a/tests/preview-integration.test.mjs b/tests/preview-integration.test.mjs new file mode 100644 index 0000000..e92c991 --- /dev/null +++ b/tests/preview-integration.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import vm from 'node:vm'; +import {createWorkspaceServer} from '../server.mjs'; + +const code = readFileSync(new URL('../preview.js', import.meta.url), 'utf8'); +const PDF = Buffer.from('%PDF-1.4\nfixture\n%%EOF'); +const engines = {latex: {available: true, engine: 'tectonic', label: 'Tectonic'}, postscript: {available: false}}; +const request = {kind: 'latex', mainPath: 'main.tex', project: {format: 'visual-workspace', version: 1, + name: 'Example', mainPath: 'main.tex', activePath: 'main.tex', files: [{path: 'main.tex', kind: 'text', text: 'source'}]}}; + +test('cancelling before the POST response arrives stops the real server job and permits a new compile', {timeout: 5000}, async t => { + let controller, firstSignal, cancellation, releaseEngine; + let compilerCalls = 0; + let reportAbort; + const engineAborted = new Promise(resolve => { reportAbort = resolve; }); + const calls = []; + const server = createWorkspaceServer({enginesProvider: async () => engines, compiler: async (_, {signal}) => { + if (++compilerCalls > 1) return {pdf: PDF, log: 'done', engine: 'Tectonic'}; + firstSignal = signal; + const running = new Promise(resolve => { + releaseEngine = () => resolve({pdf: PDF, log: 'cleanup', engine: 'Tectonic'}); + signal.addEventListener('abort', reportAbort, {once: true}); + }); + // The server has sent its 202, but real fetch has not delivered the job id yet. + assert.equal(controller.jobId, null); + cancellation = controller.cancel(); + return running; + }}); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + t.after(async () => { + releaseEngine?.(); + controller?.destroy(); + await new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }); + }); + const base = 'http://127.0.0.1:' + server.address().port; + const fetcher = (url, init = {}) => { calls.push({url, init}); return fetch(base + url, init); }; + const context = vm.createContext({Blob, AbortController, setTimeout, clearTimeout, fetch: fetcher, + URL: {createObjectURL: () => 'blob:fixture', revokeObjectURL() {}}, location: {protocol: 'http:'}}); + vm.runInContext(code, context); + controller = new context.CompilationController({getRequest: () => structuredClone(request), fetch: fetcher, pollInterval: 1}); + assert.equal(await controller.connect(), true); + const compiling = controller.compile(); + await engineAborted; + assert.equal(controller.busy, true, 'keep compilation disabled during engine shutdown'); + assert.equal(await controller.compile(), false, 'do not submit a new job before cancellation completes'); + releaseEngine(); + assert.equal(await compiling, false); + await cancellation; + assert.equal(controller.phase, 'cancelled'); + assert.equal(firstSignal.aborted, true, 'cancellation reaches the engine even without an initial job id'); + assert.equal(calls.filter(call => call.init.method === 'DELETE').length, 1); + assert.equal(controller.result, null); + assert.equal(await controller.compile(), true, 'the cancelled job no longer blocks the compiler'); + await controller.previewTask; + assert.equal(compilerCalls, 2); + assert.equal(controller.phase, 'succeeded'); +}); diff --git a/tests/preview.test.mjs b/tests/preview.test.mjs index 9defde3..0e9fefa 100644 --- a/tests/preview.test.mjs +++ b/tests/preview.test.mjs @@ -59,6 +59,66 @@ test('file mode makes no requests and explains how to enable local compilation', assert.equal(f.calls.length, 0); }); +test('late reconnect success or failure cannot interrupt a running compilation', async () => { + for (const status of [200, 503]) { + const reconnect = deferred(), progress = deferred(), polling = deferred(); + let statusRequests = 0; + const f = fixture({route: async (url, init) => { + if (url === '/api/status' && ++statusRequests > 1) return reconnect.promise; + if (url === '/api/jobs/job-1' && init.method !== 'DELETE') { polling.resolve(); return progress.promise; } + }}); + await f.controller.connect(); + const connecting = f.controller.connect(); + const compiling = f.controller.compile(); + await polling.promise; + reconnect.resolve(status === 200 ? json({token: 'late-token', engines}) : json({error: 'Disconnected'}, status)); + assert.equal(await connecting, false); + assert.equal(f.controller.busy, true); + assert.equal(f.controller.token, 'test-token'); + assert.equal(await f.controller.compile(), false, 'a second compile remains blocked'); + assert.equal(f.calls.filter(call => call.url === '/api/compile').length, 1); + progress.resolve(json({state: 'succeeded', pdfUrl: '/api/jobs/job-1/pdf'})); + assert.equal(await compiling, true, 'the original result still reaches the preview'); + f.controller.destroy(); + } +}); + +test('a newer connection wins over an older response or error', async () => { + for (const status of [200, 503]) { + const older = deferred(); + let requests = 0; + const f = fixture({route: async url => { + if (url === '/api/status') return ++requests === 1 ? older.promise : json({token: 'new-token', engines}); + }}); + const pending = f.controller.connect(); + assert.equal(await f.controller.connect(), true); + older.resolve(status === 200 ? json({token: 'old-token', engines: {}}) : json({error: 'Old request failed'}, status)); + assert.equal(await pending, false); + assert.equal(f.controller.token, 'new-token'); + assert.equal(f.controller.engines.latex.available, true); + assert.equal(f.controller.phase, 'idle'); + f.controller.destroy(); + } +}); + +test('clearing or destroying a controller invalidates pending connection replies', async () => { + for (const action of ['clear', 'destroy']) { + for (const status of [200, 503]) { + const response = deferred(); + const f = fixture({route: async url => url === '/api/status' ? response.promise : undefined}); + const connecting = f.controller.connect(); + f.controller[action](); + const phase = f.controller.phase, message = f.controller.message; + response.resolve(status === 200 ? json({token: 'late-token', engines}) : json({error: 'Late failure'}, status)); + assert.equal(await connecting, false); + assert.equal(f.controller.token, null); + assert.equal(f.controller.phase, phase); + assert.equal(f.controller.message, message); + f.controller.destroy(); + } + } +}); + test('successful compilation sends an immutable project snapshot and exports the current PDF', async () => { const f = fixture(); const original = structuredClone(f.request); @@ -152,9 +212,13 @@ test('cancellation before POST resolves deletes a late job id without starting a await f.controller.connect(); const running = f.controller.compile(); await ready.promise; - await f.controller.cancel(); + const cancelling = f.controller.cancel(); + assert.equal(f.controller.busy, true, 'wait for the submission and cancellation acknowledgement'); + assert.equal(await f.controller.compile(), false, 'do not race a new job against pending cancellation'); response.resolve(json({id: 'late-job'}, 202)); + await cancelling; assert.equal(await running, false); + assert.equal(f.controller.busy, false); assert.ok(f.calls.some(call => call.url === '/api/jobs/late-job' && call.init.method === 'DELETE')); assert.equal(f.created.length, 0); }); diff --git a/tests/server.test.mjs b/tests/server.test.mjs index 083b405..a233a5f 100644 --- a/tests/server.test.mjs +++ b/tests/server.test.mjs @@ -125,9 +125,14 @@ test('only one job runs at once and cancellation cannot be overwritten by a late const f = await fixture(t, {compiler: (_project, settings) => { signal = settings.signal; return new Promise(resolve => { finish = resolve; }); }}); const {id} = await (await f.compile()).json(); assert.equal((await f.compile()).status, 409); - assert.equal((await f.call('/api/jobs/' + id, {method: 'DELETE'})).status, 200); + let acknowledged = false; + const aborted = new Promise(resolve => signal.addEventListener('abort', resolve, {once: true})); + const cancelling = f.call('/api/jobs/' + id, {method: 'DELETE'}).then(response => { acknowledged = true; return response; }); + await aborted; assert.equal(signal.aborted, true); + assert.equal(acknowledged, false, 'wait for the engine to release the compiler slot'); finish({pdf: PDF, log: 'late', engine: 'tectonic'}); + assert.equal((await cancelling).status, 200); assert.equal((await f.completed(id)).state, 'cancelled'); assert.equal((await f.call('/api/jobs/' + id + '/pdf')).status, 409); }); diff --git a/tests/visual-math-ui.test.mjs b/tests/visual-math-ui.test.mjs index 11d4d67..2dbd8f2 100644 --- a/tests/visual-math-ui.test.mjs +++ b/tests/visual-math-ui.test.mjs @@ -248,3 +248,52 @@ test('formula labels in the reference sidebar open their formula editor while re assert.doesNotMatch(f.app.model.get().text, /eq:second/); assert.equal(f.app.mode, 'visual'); }); + +test('metadata edits stay synchronized before subsequent advanced mathematical edits', async () => { + const f = await visual(String.raw`\begin{equation}x=1\label{eq:old}\tag{A 1}\end{equation}`); + await open(f); + const fields = f.node('formulaMetadata').querySelectorAll('input'); + fields[0].value = 'eq:new'; await f.emit(fields[0], 'input'); + fields[1].value = 'B 2'; await f.emit(fields[1], 'input'); + assert.equal(f.node('formulaInput').value, String.raw`x=1\label{eq:new}\tag{B 2}`); + await f.input('formulaInput', f.node('formulaInput').value.replace('x=1', 'x=2')); + await f.click('btnInsertFormula'); + assert.equal(formula(f).getAttribute('data-formula'), String.raw`x=2\label{eq:new}\tag{B 2}`); + assert.match(f.app.model.get().text, /x=2\\label\{eq:new\}\\tag\{B 2\}/); + assert.doesNotMatch(f.app.model.get().text, /eq:old|A 1/); +}); + +test('cleared metadata stays removed when switching from graphical to advanced editing', async () => { + const f = await visual(String.raw`$x=1\label{eq:old}\tag{A}$`); + await open(f); + f.node('formulaMathField').value = 'x=2'; + await f.emit(f.node('formulaMathField'), 'input'); + for (const field of f.node('formulaMetadata').querySelectorAll('input')) { + field.value = ''; await f.emit(field, 'input'); + } + assert.equal(f.node('formulaInput').value, 'x=2'); + await f.input('formulaInput', f.node('formulaInput').value + '+1'); + await f.click('btnInsertFormula'); + assert.equal(formula(f).getAttribute('data-formula'), 'x=2+1'); + assert.doesNotMatch(f.app.model.get().text, /\\label|\\tag/); +}); + +test('invalid metadata cannot be silently discarded by an advanced edit', async () => { + const f = await visual(String.raw`$x=1\label{eq:old}\tag{A}$`); + const original = f.app.model.get().text; + await open(f); + const fields = f.node('formulaMetadata').querySelectorAll('input'); + fields[1].value = 'B%'; await f.emit(fields[1], 'input'); + fields[0].value = ''; await f.emit(fields[0], 'input'); + await f.input('formulaInput', f.node('formulaInput').value.replace('x=1', 'x=2')); + await f.click('btnInsertFormula'); + assert.equal(f.app.model.get().text, original); + assert.equal(f.node('formulaModal').classList.contains('show'), true); + assert.ok(f.node('formulaError').textContent); + assert.equal(f.node('formulaMetadata').querySelectorAll('input')[0].value, ''); + assert.equal(f.node('formulaMetadata').querySelectorAll('input')[1].value, 'B%'); + fields[1].value = 'B'; await f.emit(fields[1], 'input'); + await f.input('formulaInput', f.node('formulaInput').value.replace('x=1', 'x=2')); + await f.click('btnInsertFormula'); + assert.equal(formula(f).getAttribute('data-formula'), String.raw`x=2\tag{B}`); +}); diff --git a/tests/workspace-archive-types.test.mjs b/tests/workspace-archive-types.test.mjs new file mode 100644 index 0000000..2cff044 --- /dev/null +++ b/tests/workspace-archive-types.test.mjs @@ -0,0 +1,126 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {fixture} from './helpers/workspace-fixture.mjs'; + +const encode = text => new TextEncoder().encode(text); +const text = (path, source, encoding = 'utf-8') => ({path, kind: 'text', text: source, encoding}); +const binary = (path, bytes) => ({path, kind: 'binary', base64: Buffer.from(bytes).toString('base64')}); + +async function exportProject(f) { + const count = f.blobs.length; + await f.click('btnExportProject'); + assert.equal(f.blobs.length, count + 1, f.node('statusText').textContent); + return new Uint8Array(await f.blobs.at(-1).arrayBuffer()); +} + +async function importProject(f, bytes) { + f.node('importProject').files = [{name: 'round-trip.zip', size: bytes.length, + arrayBuffer: async () => bytes.slice().buffer}]; + await f.emit(f.node('importProject'), 'change'); +} + +test('ZIP round-trip retains Latin-1 TeX, BibTeX and custom text with the original active and main file', async () => { + const f = await fixture(); + await f.input('sourceArea', '\\documentclass{article}\r\n\\begin{document}\r\ncafé\r\n\\end{document}\r\n'); + f.prompts.push('latin1'); + await f.click('btnEncoding'); + f.app.model.add(text('legacy.bib', '@book{cafe,title={Café}}\r\n', 'latin1')); + // These Latin-1 bytes also form valid UTF-8: detection alone changes the text. + f.app.model.add(text('custom.source', 'Two Latin-1 characters: \u00c3\u00a9', 'latin1')); + const expected = f.app.model.serialize(); + await importProject(f, await exportProject(f)); + assert.equal(f.app.model.serialize(), expected); + assert.equal(f.app.model.mainPath, 'main.tex'); + assert.equal(f.app.model.activePath, 'main.tex'); + assert.equal(f.node('assetPanel').hidden, true); + await f.click('modeSource'); + assert.equal(f.node('sourcePanel').hidden, false); + assert.equal(f.node('btnEncoding').textContent, 'LATIN1'); +}); + +test('ZIP round-trip preserves explicit UTF-8 text and binary kinds without changing any bytes', async () => { + const f = await fixture(); + const files = [ + text('notes.txt', '\ufeff中文\r\ntext\r\n'), + text('literal.pdf', 'This user-created file is text, despite its extension.'), + text('control.source', 'Text with a literal NUL: \0 and form feed: \f'), + binary('readable.dat', encode('ASCII bytes deliberately kept as a resource')), + binary('image.png', Uint8Array.of(137, 80, 78, 71, 0, 255, 13, 10)), + ]; + f.app.replaceProject(new f.context.ProjectModel({name: 'Kinds and bytes', files, + mainPath: 'notes.txt', activePath: 'readable.dat'})); + const expected = f.app.model.serialize(); + const original = f.app.exportEntries(); + const archive = await exportProject(f); + await importProject(f, archive); + assert.equal(f.app.model.serialize(), expected); + for (const entry of f.app.exportEntries()) { + assert.deepEqual(Array.from(entry.bytes), Array.from(original.find(item => item.path === entry.path).bytes), entry.path); + } + assert.equal(f.node('assetPanel').hidden, false); +}); + +test('older workspace ZIP metadata and third-party ZIPs continue using byte detection', async () => { + const f = await fixture(); + const files = [{path: 'notes.txt', bytes: encode('ordinary UTF-8')}, + {path: 'legacy.ps', bytes: Uint8Array.of(37, 33, 80, 83, 10, 37, 233)}, + {path: 'image.png', bytes: Uint8Array.of(0, 255, 137)}]; + const comment = JSON.stringify({format: 'visual-workspace', version: 1, name: 'Older project', + activePath: 'notes.txt', mainPath: 'legacy.ps'}); + await importProject(f, f.context.ProjectArchive.writeZip(files, {comment})); + assert.equal(f.app.model.name, 'Older project'); + assert.equal(f.app.model.mainPath, 'legacy.ps'); + assert.equal(f.app.model.activePath, 'notes.txt'); + assert.equal(f.app.model.get('legacy.ps').encoding, 'latin1'); + assert.equal(f.app.model.get('image.png').kind, 'binary'); + await importProject(f, f.context.ProjectArchive.writeZip(files, {comment: 'Third-party ZIP comment'})); + assert.equal(f.app.model.get('notes.txt').text, 'ordinary UTF-8'); + assert.equal(f.app.model.get('legacy.ps').encoding, 'latin1'); + assert.equal(f.app.model.get('image.png').kind, 'binary'); +}); + +test('stale type metadata cannot reinterpret externally replaced or renamed ZIP members', async () => { + const f = await fixture(); + f.app.replaceProject(new f.context.ProjectModel({files: [text('legacy.tex', 'café', 'latin1'), text('other.txt', 'UTF-8 中文')]})); + const archive = await f.context.ProjectArchive.readZip(await exportProject(f), {includeComment: true}); + const replacement = encode('é!!'); // Same byte length, so the content binding must also be checked. + await importProject(f, f.context.ProjectArchive.writeZip([{path: 'legacy.tex', bytes: replacement}], {comment: archive.comment})); + assert.equal(f.app.model.get().encoding, 'utf-8'); + assert.equal(f.app.model.get().text, 'é!!'); + await importProject(f, f.context.ProjectArchive.writeZip([{path: 'renamed.tex', bytes: archive.files[0].bytes}], {comment: archive.comment})); + assert.equal(f.app.model.get().kind, 'binary'); + assert.deepEqual(Array.from(f.app.exportEntries()[0].bytes), Array.from(archive.files[0].bytes)); + await importProject(f, f.context.ProjectArchive.writeZip([...archive.files].reverse(), {comment: archive.comment})); + assert.equal(f.app.model.get('other.txt').text, 'UTF-8 中文'); + assert.equal(f.app.model.get('legacy.tex').kind, 'binary'); +}); + +test('malformed and incompatible explicit encodings fall back without replacing invalid UTF-8 bytes', async () => { + const f = await fixture(); + f.app.replaceProject(new f.context.ProjectModel({files: [text('legacy.tex', 'café', 'latin1')]})); + const archive = await f.context.ProjectArchive.readZip(await exportProject(f), {includeComment: true}); + const metadata = JSON.parse(archive.comment); + assert.ok(metadata.fileTypes?.length, 'an explicit encoding is needed for this Latin-1 TeX member'); + const incompatible = structuredClone(metadata); + incompatible.fileTypes[0][1] = 'utf-8'; + for (const fileTypes of [incompatible.fileTypes, [metadata.fileTypes[0], metadata.fileTypes[0]], + [[999, 'latin1', 0, 0, 0]], [['0', 'latin1', 0, 0, 0]], null, {0: metadata.fileTypes[0]}]) { + const comment = JSON.stringify({...metadata, fileTypes}); + await importProject(f, f.context.ProjectArchive.writeZip(archive.files, {comment})); + assert.equal(f.app.model.get().kind, 'binary'); + assert.deepEqual(Array.from(f.app.exportEntries()[0].bytes), Array.from(archive.files[0].bytes)); + } +}); + +test('500 long UTF-8 paths with explicit encodings still fit the ZIP comment limit and restore every file', async () => { + const f = await fixture(); + const files = Array.from({length: 500}, (_, index) => text('中'.repeat(1000) + index + '.txt', 'é', 'latin1')); + f.app.replaceProject(new f.context.ProjectModel({name: '文'.repeat(200), files, + mainPath: files[0].path, activePath: files.at(-1).path})); + const expected = f.app.model.serialize(); + const archive = await exportProject(f); + const decoded = await f.context.ProjectArchive.readZip(archive, {includeComment: true}); + assert.ok(encode(decoded.comment).length <= 65535); + await importProject(f, archive); + assert.ok(f.app.model.serialize() === expected, 'all 500 files and project settings survive the ZIP round-trip'); +}); From e24fcb8000acebbcda90ac230e2e23db0d15eff3 Mon Sep 17 00:00:00 2001 From: Nisconder <2129048448@qq.com> Date: Fri, 13 Nov 2026 14:18:23 +0800 Subject: [PATCH 2/3] Fix six follow-up editing, import, and preview regressions (#4) --- .github/workflows/ci.yml | 6 +- server/compiler.mjs | 9 +- server/server.mjs | 20 ++-- src/js/main.js | 15 ++- src/js/preview.js | 27 +++-- src/js/project-model.js | 16 ++- src/js/workspace.js | 11 +- tests/compiler.test.mjs | 117 +++++++++++++++++++++- tests/editor-boundary-regression.test.mjs | 77 ++++++++++++++ tests/preview-expiry-integration.test.mjs | 43 ++++++++ tests/preview.test.mjs | 66 ++++++++++++ tests/project-model.test.mjs | 57 +++++++++++ tests/server.test.mjs | 45 +++++++++ tests/workspace-import-isolation.test.mjs | 93 +++++++++++++++++ 14 files changed, 582 insertions(+), 20 deletions(-) create mode 100644 tests/editor-boundary-regression.test.mjs create mode 100644 tests/preview-expiry-integration.test.mjs create mode 100644 tests/workspace-import-isolation.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0012c43..e908c48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,10 @@ on: jobs: check: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -16,6 +19,7 @@ jobs: node-version: '22' - name: JS 语法检查 + shell: bash run: | for file in src/js/main.js src/js/workspace.js src/js/project-model.js src/js/project-archive.js src/js/preview.js src/js/visual-math.js src/js/visual-references.js src/js/visual-references-ui.js vendor/mathlive/mathlive.min.js server/compiler.mjs server/pdf-preview.mjs server/server.mjs; do node --check "$file" diff --git a/server/compiler.mjs b/server/compiler.mjs index d50209e..ea089e2 100644 --- a/server/compiler.mjs +++ b/server/compiler.mjs @@ -237,7 +237,14 @@ export async function compileProject(project, options = {}) { } if (/\.eps$/i.test(mainFile)) args.push('-dEPSCrop'); args.push('-f', portable(mainFile)); - await run(engine.executable, args); + // Ghostscript @files avoid the Windows command-line limit without + // widening the per-file grants. Its parser accepts double quotes and + // escaped quotes, not JSON/shell backslash escapes. All paths above + // use forward slashes; finish each quoted argument with whitespace. + const argumentFile = path.join(jobDirectory, 'ghostscript.args'); + await writeFile(argumentFile, args.map(arg => '"' + arg.replace(/"/g, '\\"') + '"\n').join(''), + {encoding: 'utf8', flag: 'wx'}); + await run(engine.executable, ['@' + portable(argumentFile)]); } else if (engine.engine === 'tectonic') { output = path.join(outputDirectory, path.parse(mainFile).name + '.pdf'); let tectonicError; diff --git a/server/server.mjs b/server/server.mjs index b201746..339bb11 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -88,12 +88,15 @@ export function createWorkspaceServer({ jobs.delete(job.id); } function sweep() { - for (const job of jobs.values()) if (!job.active && now() - job.finishedAt >= jobTTL) discard(job); + // Completed results expire after inactivity, so reading a long document + // does not lose its pages merely because compilation finished earlier. + for (const job of jobs.values()) if (!job.active && now() - (job.lastAccessAt ?? job.finishedAt) >= jobTTL) discard(job); } async function previewOperation(job, res, operation) { if (previewActive) throw error('已有页面预览正在生成,请稍后重试。', 409, 'PREVIEW_BUSY'); const active = {job, controller: new AbortController()}; previewActive = active; + job.lastAccessAt = now(); const disconnected = () => { if (!res.writableEnded) active.controller.abort(); }; res.once('close', disconnected); try { @@ -175,7 +178,7 @@ export function createWorkspaceServer({ if (job.controller.signal.aborted) return; Object.assign(job, {state: 'failed', error: problem.message || '编译失败', code: problem.code || 'COMPILE_FAILED', log: String(problem.log || '').slice(-200_000)}); - }).finally(() => { job.active = false; job.finishedAt = now(); }); + }).finally(() => { job.active = false; job.lastAccessAt = job.finishedAt = now(); }); return; } const match = /^\/api\/jobs\/([0-9a-f-]+)(\/pdf|\/preview(?:\/([0-9]+))?)?$/.exec(path); @@ -195,6 +198,7 @@ export function createWorkspaceServer({ if (req.method !== 'GET') throw error('不支持的操作。', 405); if (match[2] === '/pdf') { if (job.state !== 'succeeded') throw error('PDF 尚未生成。', 409, 'PDF_NOT_READY'); + job.lastAccessAt = now(); res.writeHead(200, {'Content-Type': 'application/pdf', 'Content-Length': job.pdf.length, 'Content-Disposition': 'inline; filename="preview.pdf"'}); res.end(job.pdf); @@ -202,7 +206,7 @@ export function createWorkspaceServer({ if (job.state !== 'succeeded') throw error('PDF 尚未生成。', 409, 'PDF_NOT_READY'); const info = job.previewInfo || await previewOperation(job, res, options => previewInfo(job.pdf, options)); if (info.available) job.previewInfo = info; - if (!match[3]) { json(200, info); return; } + if (!match[3]) { job.lastAccessAt = now(); json(200, info); return; } if (!info.available) throw error(info.reason || '没有可用的预览引擎,仍可下载 PDF。', 503, 'PREVIEW_ENGINE_MISSING'); const page = Number(match[3]); if (!Number.isSafeInteger(page) || page < 1 || page > Math.min(info.pageCount, info.maxPages)) { @@ -212,12 +216,16 @@ export function createWorkspaceServer({ : await previewOperation(job, res, options => previewRenderer(job.pdf, page, {...options, pageCount: info.pageCount})); job.previewCache = {page, png}; if (res.destroyed || res.writableEnded) return; + job.lastAccessAt = now(); res.writeHead(200, {'Content-Type': 'image/png', 'Content-Length': png.length, 'X-Preview-Page': String(page), 'X-Preview-Page-Count': String(info.pageCount)}); res.end(png); - } else json(200, {id: job.id, state: job.state, log: job.log, error: job.error, code: job.code, - engine: job.engine, mainPath: job.mainPath, - ...(job.state === 'succeeded' ? {pdfUrl: '/api/jobs/' + job.id + '/pdf'} : {})}); + } else { + job.lastAccessAt = now(); + json(200, {id: job.id, state: job.state, log: job.log, error: job.error, code: job.code, + engine: job.engine, mainPath: job.mainPath, + ...(job.state === 'succeeded' ? {pdfUrl: '/api/jobs/' + job.id + '/pdf'} : {})}); + } return; } if (!['GET', 'HEAD'].includes(req.method)) throw error('不支持的操作。', 405); diff --git a/src/js/main.js b/src/js/main.js index e11f91a..980fe07 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -1043,13 +1043,26 @@ function convertNodeToLatex(node) { } function convertInlineNodes(node) { + const children = Array.from(node.childNodes); + const content = children.filter(child => child.nodeType !== Node.TEXT_NODE || child.textContent !== ''); + // contenteditable keeps an unsourced
in an emptied paragraph so the + // caret remains visible. Imported breaks and breaks beside text are real. + if (/^(p|div)$/i.test(node.tagName) && content.length === 1 && + content[0].nodeType === Node.ELEMENT_NODE && content[0].tagName.toLowerCase() === 'br' && + content[0].getAttribute('data-tex-source') === null) return ''; let latex = ''; let pendingComment = false; - Array.from(node.childNodes).forEach(function(child) { + children.forEach(function(child) { const part = convertNodeToLatex(child); if (!part) return; // 段尾注释的换行可能保存在外部空白节点中;追加文字时必须先结束注释。 if (pendingComment && !/^[ \t]*[\r\n]/.test(part)) latex += '\n'; + else if (/^[A-Za-z@]/.test(part)) { + // A removed space must not turn separate atoms into \LaTeXbook. + // An even run of backslashes ends in a control symbol, not a word. + const command = /(\\+)[A-Za-z@]+$/.exec(latex); + if (command && command[1].length % 2 === 1) latex += '{}'; + } latex += part; pendingComment = child.nodeType === Node.ELEMENT_NODE && child.classList.contains('tex-cmd') && child.getAttribute('hidden') !== null && part.startsWith('%') && !/[\r\n]/.test(part); diff --git a/src/js/preview.js b/src/js/preview.js index 2e26331..787b250 100644 --- a/src/js/preview.js +++ b/src/js/preview.js @@ -47,7 +47,7 @@ this.cancellation = null; this.abort = null; this.disposed = false; - this.preview = {state: 'empty', message: '', url: null, key: null, targetKey: null, page: 0, pageCount: 0, maxPages: 100}; + this.preview = {state: 'empty', message: '', errorCode: null, url: null, key: null, targetKey: null, page: 0, pageCount: 0, maxPages: 100}; this.previewGeneration = 0; this.previewAbort = null; this.previewTask = Promise.resolve(false); @@ -148,7 +148,7 @@ releasePreview() { this.stopPreview(); if (this.preview.url) this.urls.revokeObjectURL(this.preview.url); - this.preview = {state: 'empty', message: '', url: null, key: null, targetKey: null, page: 0, pageCount: 0, maxPages: 100}; + this.preview = {state: 'empty', message: '', errorCode: null, url: null, key: null, targetKey: null, page: 0, pageCount: 0, maxPages: 100}; } previewSession(result) { this.stopPreview(); @@ -166,13 +166,23 @@ if (response.status === 409 && body.code === 'PREVIEW_BUSY' && attempt < 2) { await this.sleep(400, signal); continue; } - throw new Error(body.error || '页面预览暂时不可用;仍可导出 PDF。'); + throw Object.assign(new Error(body.error || '页面预览暂时不可用;仍可导出 PDF。'), {code: body.code}); } } + async retryPreview() { + if (!this.result || this.busy || this.disposed) return false; + const result = this.result, generation = this.generation; + if (this.preview.errorCode !== 'JOB_NOT_FOUND' && await this.loadPreview(result)) return true; + if (this.disposed || generation !== this.generation || this.result !== result || this.preview.errorCode !== 'JOB_NOT_FOUND') return false; + // Rebuild an evicted result only in response to an explicit retry. + // compile() snapshots the current source and guards project changes. + if (!await this.compile()) return false; + return this.previewTask; + } async loadPreview(result = this.result) { if (!result?.jobId || this.disposed) return false; const session = this.previewSession(result); - Object.assign(this.preview, {state: 'loading', message: 'PDF 已生成,正在准备页面预览…', targetKey: result.key, pageCount: 0}); + Object.assign(this.preview, {state: 'loading', message: 'PDF 已生成,正在准备页面预览…', errorCode: null, targetKey: result.key, pageCount: 0}); this.emit(); try { const response = await this.previewResponse('/api/jobs/' + encodeURIComponent(result.jobId) + '/preview', session.abort.signal); @@ -190,6 +200,7 @@ } catch (error) { if (!session.current()) return false; this.preview.state = 'failed'; + this.preview.errorCode = error.code || null; this.preview.message = '页面预览失败:' + error.message + ' PDF 仍可导出。'; this.emit(); return false; } finally { if (session.current()) this.previewAbort = null; } @@ -200,6 +211,7 @@ page > Math.min(this.preview.pageCount, this.preview.maxPages) || this.disposed) return false; const session = this.previewSession(result); this.preview.state = 'loading'; + this.preview.errorCode = null; this.preview.message = '正在加载第 ' + page + ' 页…'; this.emit(); try { @@ -214,7 +226,8 @@ } catch (error) { if (!session.current()) return false; this.preview.state = 'failed'; - this.preview.message = '页面加载失败:' + error.message + ' 可重试或导出 PDF。'; + this.preview.errorCode = error.code || null; + this.preview.message = '页面加载失败:' + error.message + (error.code === 'JOB_NOT_FOUND' ? ' 可重新编译预览或导出已有 PDF。' : ' 可重试或导出 PDF。'); this.emit(); return false; } finally { if (session.current()) this.previewAbort = null; } } @@ -408,6 +421,8 @@ $('btnPreviousPage').disabled = page.state === 'loading' || currentPage <= 1; $('btnNextPage').disabled = page.state === 'loading' || !currentPage || currentPage >= limit; $('btnRetryPage').hidden = !controller.result || !['failed', 'unavailable', 'empty'].includes(page.state); + $('btnRetryPage').disabled = controller.busy; + $('btnRetryPage').textContent = page.errorCode === 'JOB_NOT_FOUND' ? '重新编译预览' : '重试预览'; $('previewStale').hidden = !controller.stale && !(showingImage && page.key !== controller.result.key); $('previewStale').textContent = showingImage && page.key !== controller.result.key ? '当前仍显示上一次编译的页面,正在加载新结果。' : '当前显示上一次结果;请重新编译以反映最新源码。'; @@ -454,7 +469,7 @@ $('btnCompilerRetry').addEventListener('click', () => controller.connect()); $('btnPreviousPage').addEventListener('click', () => { controller.previewTask = controller.loadPage(controller.preview.page - 1); }); $('btnNextPage').addEventListener('click', () => { controller.previewTask = controller.loadPage(controller.preview.page + 1); }); - $('btnRetryPage').addEventListener('click', () => { controller.previewTask = controller.loadPreview(); }); + $('btnRetryPage').addEventListener('click', () => { controller.previewTask = controller.retryPreview(); }); document.addEventListener('keydown', event => { if (event.defaultPrevented || event.target?.closest?.('[role="dialog"]')) return; if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { diff --git a/src/js/project-model.js b/src/js/project-model.js index 2053835..0c5b736 100644 --- a/src/js/project-model.js +++ b/src/js/project-model.js @@ -26,6 +26,17 @@ const pathKey = path => path.normalize('NFC').toLowerCase(); + function reserveFilePath(path, names) { + const key = pathKey(path); + if (names.has(key)) throw new Error('文件已存在:' + path); + for (const name of names) { + if (key.startsWith(name + '/') || name.startsWith(key + '/')) { + throw new Error('文件与目录路径冲突:' + path); + } + } + names.add(key); + } + function checkText(text, encoding) { if (typeof text !== 'string') throw new Error('文本文件必须提供字符串内容'); if (encoding === 'latin1') { @@ -203,8 +214,7 @@ let size = this.totalBytes; const records = files.map(input => { const record = normalizeFile(input); - if (names.has(pathKey(record.file.path))) throw new Error('文件已存在:' + record.file.path); - names.add(pathKey(record.file.path)); + reserveFilePath(record.file.path, names); size += record.size; if (size > MAX_TOTAL_BYTES) throw new Error('项目大小不能超过 50 MB'); return record; @@ -224,8 +234,8 @@ const file = this._files.get(oldPath); if (!file) throw new Error('文件不存在:' + oldPath); const path = validatePath(nextPath); - if (Array.from(this._files.keys()).some(name => name !== oldPath && pathKey(name) === pathKey(path))) throw new Error('文件已存在:' + path); if (oldPath === path) return this.get(path); + reserveFilePath(path, new Set(Array.from(this._files.keys()).filter(name => name !== oldPath).map(pathKey))); this._files = new Map(Array.from(this._files, ([name, entry]) => name === oldPath ? [path, {...entry, path}] : [name, entry])); this._sizes.set(path, this._sizes.get(oldPath)); this._sizes.delete(oldPath); diff --git a/src/js/workspace.js b/src/js/workspace.js index a978b8a..8765b6f 100644 --- a/src/js/workspace.js +++ b/src/js/workspace.js @@ -678,8 +678,17 @@ function initWorkspace() { return entries; } $('importFiles').addEventListener('change', safely(async event => { + const project = app.model; app.sync(); - const entries = await readFiles(Array.from(event.target.files), false); + let entries; + try { entries = await readFiles(Array.from(event.target.files), false); } + catch (error) { + if (app.model === project) throw error; + return; + } + // A project replacement invalidates pending imports, including failures. + // Neither files nor status from the old project belong in the new one. + if (app.model !== project) return; // Reading files is asynchronous: retain any edits made while waiting. // addMany validates the full batch atomically and preserves undo history. app.sync(); diff --git a/tests/compiler.test.mjs b/tests/compiler.test.mjs index edbabdd..40c930f 100644 --- a/tests/compiler.test.mjs +++ b/tests/compiler.test.mjs @@ -25,6 +25,33 @@ function emitPdf(args, options) { return writeFile(target, pdf); } +// Independently decode the quoted arguments accepted by Ghostscript @files. +// Unlike JSON or a shell, only backslash-double-quote is an escape here. +function parseResponseArguments(contents) { + const args = []; + let token = '', quoted = false, started = false; + for (let i = 0; i < contents.length; i++) { + const character = contents[i]; + if (character === '\\' && contents[i + 1] === '"') { token += '"'; i++; started = true; } + else if (character === '"') { quoted = !quoted; started = true; } + else if (/[ \t\r\n\f\v]/.test(character) && !quoted) { + if (started) args.push(token); + token = ''; started = false; + } else { token += character; started = true; } + } + if (quoted) throw new Error('Unterminated response-file quote'); + if (started) args.push(token); + return args; +} +async function expandGhostscriptArguments(args) { + const expanded = []; + for (const arg of args) { + if (arg.startsWith('@')) expanded.push(...parseResponseArguments(await readFile(arg.slice(1), 'utf8'))); + else expanded.push(arg); + } + return expanded; +} + test('engine discovery uses explicit executable paths and reports missing engines', async t => { const directory = await root(t); const binary = path.join(directory, 'engine with spaces' + (process.platform === 'win32' ? '.exe' : '')); @@ -95,6 +122,7 @@ test('PS and binary EPS render through Ghostscript pdfwrite with SAFER and appro const result = await compileProject({...project([source]), mainPath: null}, {kind: 'postscript', mainPath: source.path, engines, temporaryParent, runner: async (command, args, options) => { assert.equal(command, engines.postscript.executable); + args = await expandGhostscriptArguments(args); for (const flag of ['-dSAFER', '-dBATCH', '-dNOPAUSE', '-sDEVICE=pdfwrite', '-f']) assert.ok(args.includes(flag)); assert.equal(args.includes('-dEPSCrop'), extension === 'eps'); assert.ok(args.some(arg => arg.startsWith('--permit-file-read='))); @@ -113,6 +141,7 @@ test('PostScript permits staged resources by relative filename without granting const temporaryParent = await root(t); await compileProject(project([text('drawing.ps', '(parts/shared.ps) run'), text('parts/shared.ps', 'showpage')]), { kind: 'postscript', engines, temporaryParent, runner: async (_, args, options) => { + args = await expandGhostscriptArguments(args); assert.ok(args.includes('--permit-file-read=parts/shared.ps')); assert.ok(args.includes('--permit-file-read=./parts/shared.ps')); assert.ok(!args.includes('--permit-file-read=*')); @@ -127,6 +156,92 @@ test('PostScript permits staged resources by relative filename without granting assert.equal(invoked, false, 'a filename cannot add an extra Ghostscript permission'); }); +test('a maximum-file PostScript project starts a real subprocess with bounded arguments and all resource permissions', async t => { + const temporaryParent = await root(t); + const probe = path.join(temporaryParent, 'mock ghostscript.mjs'); + await writeFile(probe, `import {readFile, writeFile} from 'node:fs/promises'; +${parseResponseArguments.toString()} +${expandGhostscriptArguments.toString()} +const args = await expandGhostscriptArguments(process.argv.slice(2)); +const output = args.find(arg => arg.startsWith('-sOutputFile=')).slice('-sOutputFile='.length); +await writeFile(output, '%PDF-1.7\\nfixture\\n%%EOF'); +process.stdout.write(JSON.stringify(args)); +`); + const files = [text('docs/drawing.ps', 'showpage')]; + for (let i = 0; i < 499; i++) files.push(text('assets/resource-' + i + '-' + 'x'.repeat(130) + '.txt', 'resource')); + let commandLength; + const result = await compileProject(project(files), {kind: 'postscript', engines, temporaryParent, + runner: async (_, args, options) => { + // The old per-file argv exceeds CreateProcess's limit on Windows. + const native = await runProcess(process.execPath, [probe, ...args], options); + assert.equal(native.code, 0, native.log); + commandLength = [process.execPath, probe, ...args].join(' ').length; + assert.ok(commandLength < 4096, 'the native command must not grow with project file count'); + const expanded = JSON.parse(native.log); + assert.ok(expanded.includes('-dSAFER')); + assert.ok(!expanded.includes('-dNOSAFER')); + assert.ok(!expanded.includes('--permit-file-read=*')); + const readGrants = expanded.filter(arg => arg.startsWith('--permit-file-read=')); + assert.equal(readGrants.length, 1 + 2 * files.length); + for (const file of files) { + const relative = path.relative(options.cwd, path.join(options.sourceDirectory, file.path)).replace(/\\/g, '/'); + assert.ok(readGrants.includes('--permit-file-read=' + relative), file.path); + assert.ok(readGrants.includes('--permit-file-read=./' + relative), file.path); + } + return {...native, log: 'Real subprocess started'}; + }}); + assert.deepEqual(result.pdf, pdf); + assert.deepEqual(await readdir(temporaryParent), ['mock ghostscript.mjs'], 'staged files and argument file are removed'); +}); + +test('PostScript argument files preserve special paths without adding switches or permitting extra files', async t => { + const directory = await root(t); + const temporaryParent = path.join(directory, "参数 # @ O'Brien"); + await mkdir(temporaryParent); + const files = [text('docs/图形 main.eps', '%!PS\nshowpage'), text("assets/O'Brien # @ -dNOSAFER.ps", 'resource'), + text('assets/@extra.args', '-dNOSAFER'), text('assets/-dNOSAFER', 'resource'), text('assets/space\u00a0name.ps', 'resource')]; + await compileProject(project(files), {kind: 'postscript', engines, temporaryParent, + runner: async (_, args, options) => { + assert.equal(args.length, 1); + assert.ok(args[0].startsWith('@')); + assert.equal(path.dirname(path.resolve(args[0].slice(1))), path.dirname(options.sourceDirectory), 'arguments stay outside user project files'); + const expanded = await expandGhostscriptArguments(args); + assert.ok(expanded.includes('-dSAFER')); + assert.ok(expanded.includes('-dEPSCrop')); + assert.ok(!expanded.includes('-dNOSAFER')); + assert.ok(!expanded.some(arg => arg.startsWith('@'))); + assert.deepEqual(expanded.slice(-2), ['-f', path.join(options.sourceDirectory, files[0].path).replace(/\\/g, '/')]); + const grants = expanded.filter(arg => arg.startsWith('--permit-file-read=')); + assert.deepEqual(grants, [ + '--permit-file-read=' + options.sourceDirectory.replace(/\\/g, '/') + '/*', + ...files.flatMap(file => { + const relative = path.relative(options.cwd, path.join(options.sourceDirectory, file.path)).replace(/\\/g, '/'); + return ['--permit-file-read=' + relative, '--permit-file-read=./' + relative]; + }) + ]); + await emitPdf(expanded, options); + return {code: 0}; + }}); + assert.deepEqual(await readdir(temporaryParent), []); +}); + +test('PostScript argument files are cleaned after engine failure and cancellation', async t => { + const temporaryParent = await root(t); + for (const cancelled of [false, true]) { + const controller = new AbortController(); + await assert.rejects(compileProject(project([text('drawing.ps', 'showpage')]), { + kind: 'postscript', engines, temporaryParent, signal: controller.signal, + runner: async (_, args) => { + assert.equal(args.length, 1); + await access(args[0].slice(1)); + if (cancelled) controller.abort(); + return {code: 1, log: 'Engine failed'}; + } + }), {code: cancelled ? 'CANCELLED' : 'COMPILE_FAILED'}); + assert.deepEqual(await readdir(temporaryParent), []); + } +}); + test('Tectonic Biber projects never return a PDF with incomplete bibliography as success', async t => { const temporaryParent = await root(t); await assert.rejects(compileProject(project([text('main.tex', 'biblatex with Biber')]), { @@ -150,7 +265,7 @@ test('filesystem-unsafe paths, directory collisions and wrong main kinds are rej for (const file of ['../escape.tex', 'C:/escape.tex', 'NUL.tex', 'trailing. /main.tex', 'dir*/main.tex']) { await assert.rejects(compileProject(project([text(file, 'text')]), options)); } - await assert.rejects(compileProject(project([text('main.tex', 'x'), text('dir', 'file'), text('dir/child.tex', 'child')]), options), {code: 'INVALID_PATH'}); + await assert.rejects(compileProject(project([text('main.tex', 'x'), text('dir', 'file'), text('dir/child.tex', 'child')]), options), {code: 'INVALID_PROJECT'}); await assert.rejects(compileProject(project([text('notes.txt', 'x')]), options), {code: 'INVALID_MAIN'}); await assert.rejects(compileProject(project([text('main.tex', 'x')]), {...options, kind: 'postscript'}), {code: 'INVALID_MAIN'}); assert.equal(calls, 0); diff --git a/tests/editor-boundary-regression.test.mjs b/tests/editor-boundary-regression.test.mjs new file mode 100644 index 0000000..fa24f67 --- /dev/null +++ b/tests/editor-boundary-regression.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {fixture} from './helpers/workspace-fixture.mjs'; + +async function visual(body) { + const f = await fixture(); + const source = '\\documentclass{article}\n\\begin{document}\n' + body + '\n\\end{document}\n'; + await f.input('sourceArea', source); + await f.click('modeVisual'); f.refresh(); + assert.equal(f.app.model.get().text, source, 'opening visual mode must preserve the original source'); + return {f, source}; +} + +test('deleting the space after an imported control word cannot merge the following text into its name', async () => { + for (const body of [String.raw`\LaTeX book`, String.raw`\section{\LaTeX book}`, + String.raw`\textbf{\LaTeX book}`, String.raw`\begin{itemize}\item \LaTeX book\end{itemize}`]) { + const {f} = await visual(body); + const command = f.node('editorArea').querySelectorAll('.tex-cmd') + .find(node => node.getAttribute('data-tex-source') === String.raw`\LaTeX`); + const siblings = command.parentNode.childNodes; + const following = siblings[siblings.indexOf(command) + 1]; + assert.equal(following.textContent, ' book'); + following.textContent = 'book'; + await f.emit(f.node('editorArea'), 'input'); + await f.click('modeSource'); + assert.ok(f.app.model.get().text.includes(String.raw`\LaTeX{}book`)); + assert.doesNotMatch(f.app.model.get().text, /\\LaTeXbook/); + await f.click('modeVisual'); f.refresh(); + assert.ok(f.node('editorArea').querySelectorAll('.tex-cmd') + .some(node => node.getAttribute('data-tex-source') === String.raw`\LaTeX{}`)); + } +}); + +test('empty paragraph placeholders disappear from exported source without creating a line-break command', async () => { + for (const tag of ['p', 'div']) { + const {f, source} = await visual('Alpha\n\nBeta'); + const paragraph = f.node('editorArea').querySelector('p'); + if (tag === 'div') paragraph.tagName = 'DIV'; + // Chromium leaves this placeholder after the user deletes all paragraph text. + paragraph.innerHTML = '
'; + paragraph.appendChild(f.document.createTextNode('')); + await f.emit(f.node('editorArea'), 'input'); + await f.click('modeSource'); + assert.equal(f.app.model.get().text, source.replace('Alpha', '')); + assert.doesNotMatch(f.app.model.get().text, /\\\\/); + } +}); + +test('actual imported and newly inserted line breaks survive the placeholder check', async () => { + const {f, source} = await visual(String.raw`Alpha\\[2pt]Beta`); + const paragraph = f.node('editorArea').querySelector('p'); + paragraph.childNodes[0].textContent = 'Changed'; + await f.emit(f.node('editorArea'), 'input'); + await f.click('modeSource'); + assert.equal(f.app.model.get().text, source.replace('Alpha', 'Changed')); + await f.click('modeVisual'); f.refresh(); + f.node('editorArea').querySelector('p').innerHTML = 'First
Last
'; + await f.emit(f.node('editorArea'), 'input'); + await f.click('modeSource'); + assert.ok(f.app.model.get().text.includes('First\\\\\nLast\\\\\n')); + + const untouched = await visual(String.raw`\\*[1ex]`); + await untouched.f.click('modeSource'); + assert.equal(untouched.f.app.model.get().text, untouched.source, 'an imported lone break owns real TeX source'); +}); + +test('unchanged source and existing command separators remain exact after neighboring edits', async () => { + const body = String.raw`Before \LaTeX book, \LaTeX-book, \LaTeX{}book and \\LaTeX.`; + const {f, source} = await visual(body); + await f.click('modeSource'); + assert.equal(f.app.model.get().text, source); + await f.click('modeVisual'); f.refresh(); + f.node('editorArea').querySelector('p').childNodes[0].textContent = 'After '; + await f.emit(f.node('editorArea'), 'input'); + await f.click('modeSource'); + assert.equal(f.app.model.get().text, source.replace('Before ', 'After ')); +}); diff --git a/tests/preview-expiry-integration.test.mjs b/tests/preview-expiry-integration.test.mjs new file mode 100644 index 0000000..5d83d69 --- /dev/null +++ b/tests/preview-expiry-integration.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import vm from 'node:vm'; +import {createWorkspaceServer} from '../server.mjs'; + +test('real HTTP preview survives active reading and recovers an idle eviction on explicit retry', async t => { + let clock = 1000, compilations = 0, downloads = 0, urlId = 0; + const request = {kind: 'latex', mainPath: 'main.tex', project: {files: [{path: 'main.tex', kind: 'text', text: 'Source'}]}}; + const engines = {latex: {available: true, engine: 'fixture'}, postscript: {available: true}}; + const server = createWorkspaceServer({now: () => clock, enginesProvider: async () => engines, + compiler: async () => { compilations++; return {pdf: Buffer.from('%PDF-1.7\nfixture\n%%EOF'), engine: 'fixture'}; }, + previewInfo: async () => ({available: true, pageCount: 3, maxPages: 100}), + previewRenderer: async (_, page) => Buffer.from('PNG page ' + page)}); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); })); + const base = 'http://127.0.0.1:' + server.address().port; + const fetcher = (url, init) => fetch(base + url, init); + const urls = {createObjectURL: () => 'blob:fixture/' + ++urlId, revokeObjectURL() {}}; + const context = vm.createContext({Blob, AbortController, setTimeout, clearTimeout, URL: urls, fetch: fetcher}); + vm.runInContext(await readFile(new URL('../preview.js', import.meta.url), 'utf8'), context); + const controller = new context.CompilationController({getRequest: () => structuredClone(request), + fetch: fetcher, urls, protocol: 'http:', pollInterval: 1, download: () => { downloads++; }}); + t.after(() => controller.destroy()); + assert.equal(await controller.connect(), true); + assert.equal(await controller.compile(), true); + assert.equal(await controller.previewTask, true); + const previousJob = controller.result.jobId; + for (let minute = 2; minute <= 12; minute += 2) { + clock = 1000 + minute * 60_000; + assert.equal(await controller.loadPage(1 + (minute / 2) % 3), true); + } + clock += 10 * 60_000; + assert.equal(await controller.loadPage(2), false); + assert.equal(controller.preview.errorCode, 'JOB_NOT_FOUND'); + assert.equal(await controller.exportPdf(), true); + assert.equal(downloads, 1); + assert.equal(compilations, 1, 'expiry and cached export never start a compiler'); + assert.equal(await controller.retryPreview(), true); + assert.notEqual(controller.result.jobId, previousJob); + assert.equal(controller.preview.state, 'ready'); + assert.equal(compilations, 2); +}); diff --git a/tests/preview.test.mjs b/tests/preview.test.mjs index 0e9fefa..0506737 100644 --- a/tests/preview.test.mjs +++ b/tests/preview.test.mjs @@ -508,6 +508,72 @@ test('missing Ghostscript falls back without invalidating a successful downloada assert.equal(await f.controller.exportPdf(), true); }); +test('the retry control recompiles an expired result and restores page images', async () => { + let expired = false; + const f = await workspaceFixture({route: async url => { + if (expired && url.startsWith('/api/jobs/job-1/preview')) return json({code: 'JOB_NOT_FOUND', error: 'Result expired'}, 404); + if (url.endsWith('/preview')) return json({available: true, pageCount: 3, maxPages: 100}); + if (/\/preview\/\d+$/.test(url)) return {ok: true, blob: async () => new Blob(['png'], {type: 'image/png'})}; + }}); + await f.controller.compile(); await f.controller.previewTask; + const previousPdf = f.controller.result.url; + const downloads = []; + f.controller.download = url => downloads.push(url); + expired = true; + assert.equal(await f.controller.loadPage(2), false); + assert.equal(f.calls.filter(call => call.url === '/api/compile').length, 1, 'passive page failure does not start a compiler'); + assert.equal(await f.controller.exportPdf(), true, 'cached PDF remains downloadable after server expiry'); + assert.equal(downloads[0], previousPdf); + assert.equal(f.ids.get('btnRetryPage').hidden, false); + f.project.files[0].text += '\nLatest edit'; f.app.onChange(); + f.ids.get('btnRetryPage').emit('click'); + assert.equal(await f.controller.previewTask, true); + assert.equal(f.controller.result.jobId, 'job-2'); + assert.equal(f.controller.preview.state, 'ready'); + assert.equal(f.ids.get('pdfPageView').hidden, false); + const requests = f.calls.filter(call => call.url === '/api/compile'); + assert.equal(requests.length, 2); + assert.match(JSON.parse(requests[1].init.body).project.files[0].text, /Latest edit$/); + f.controller.destroy(); +}); + +test('retry discovers expired preview metadata but ordinary renderer failures do not recompile', async () => { + for (const code of ['JOB_NOT_FOUND', 'PREVIEW_FAILED']) { + let failed = true; + const f = await workspaceFixture({route: async url => { + if (url.endsWith('/preview')) { + if (failed) return json({code, error: 'Preview failed'}, code === 'JOB_NOT_FOUND' ? 404 : 500); + return json({available: true, pageCount: 1}); + } + if (url.endsWith('/preview/1')) return {ok: true, blob: async () => new Blob(['png'], {type: 'image/png'})}; + }}); + await f.controller.compile(); await f.controller.previewTask; + failed = false; + f.ids.get('btnRetryPage').emit('click'); + assert.equal(await f.controller.previewTask, true); + assert.equal(f.calls.filter(call => call.url === '/api/compile').length, code === 'JOB_NOT_FOUND' ? 2 : 1); + f.controller.destroy(); + } +}); + +test('a late expiry response after cancelling retry cannot restart compilation', async () => { + const pending = deferred(), started = deferred(); + let hold = false; + const f = await workspaceFixture({route: async url => { + if (hold && url.endsWith('/preview')) { started.resolve(); return pending.promise; } + }}); + await f.controller.compile(); await f.controller.previewTask; + hold = true; + f.ids.get('btnRetryPage').emit('click'); + const retrying = f.controller.previewTask; + await started.promise; + await f.controller.cancel(); + pending.resolve(json({code: 'JOB_NOT_FOUND', error: 'Result expired'}, 404)); + assert.equal(await retrying, false); + assert.equal(f.calls.filter(call => call.url === '/api/compile').length, 1); + f.controller.destroy(); +}); + test('a delayed page from a previous project cannot create an image after the project is cleared', async () => { const page = deferred(), started = deferred(); const f = fixture({route: async url => { diff --git a/tests/project-model.test.mjs b/tests/project-model.test.mjs index ba16797..308486e 100644 --- a/tests/project-model.test.mjs +++ b/tests/project-model.test.mjs @@ -58,6 +58,63 @@ test('batch import validates everything before adding any file', () => { assert.equal(model.list().length, 1); }); +test('construction and deserialization reject file-directory conflicts in both orders with case and NFC normalization', () => { + for (const [parent, child] of [['sections', 'sections/intro.tex'], ['Sections', 'sections/intro.tex'], + ['café', 'cafe\u0301/intro.tex'], ['folder', 'FOLDER\\nested\\intro.tex']]) { + for (const paths of [[parent, child], [child, parent]]) { + const files = paths.map(path => textFile(path, 'content')); + assert.throws(() => new ProjectModel({files}), /文件与目录路径冲突/); + assert.throws(() => ProjectModel.deserialize({format: 'visual-workspace', version: 1, + name: 'Conflicting project', files, mainPath: paths[0], activePath: paths[0]}), /文件与目录路径冲突/); + } + } +}); + +test('failed batch path conflicts leave files, sizes, current paths and undo/redo untouched', () => { + for (const [existing, conflict] of [['sections', 'sections/chapter.tex'], ['sections/chapter.tex', 'SECTIONS']]) { + const model = new ProjectModel({files: [textFile('main.tex', 'original'), textFile(existing, 'existing')]}); + model.update('first edit'); model.update('second edit'); model.undo(); + const expected = model.serialize(); + const size = model.totalBytes; + assert.throws(() => model.addMany([textFile('would-have-been-added.txt', 'new'), textFile(conflict)]), /文件与目录路径冲突/); + assert.equal(model.serialize(), expected); + assert.equal(model.totalBytes, size); + assert.equal(model.redo().text, 'second edit'); + assert.equal(model.undo().text, 'first edit'); + assert.equal(model.undo().text, 'original'); + } + const empty = new ProjectModel(); + assert.throws(() => empty.addMany([textFile('a/b.tex'), textFile('A')]), /文件与目录路径冲突/); + assert.equal(empty.list().length, 0); + assert.equal(empty.totalBytes, 0); +}); + +test('conflicting renames fail atomically while preserving active/main paths and edit history', () => { + for (const [existing, renamed] of [['Sections', 'sections/main.tex'], ['café/chapter.tex', 'cafe\u0301']]) { + const model = new ProjectModel({files: [textFile('main.tex', 'original'), textFile(existing, 'existing')]}); + model.update('edited'); + const expected = model.serialize(); + const size = model.totalBytes; + assert.throws(() => model.rename('main.tex', renamed), /文件与目录路径冲突/); + assert.equal(model.serialize(), expected); + assert.equal(model.totalBytes, size); + assert.equal(model.undo().text, 'original'); + assert.equal(model.redo().text, 'edited'); + } +}); + +test('valid siblings, similar path prefixes, case-only renames and moving a file under its old name remain supported', () => { + const model = new ProjectModel({files: [textFile('notes', 'old'), textFile('notes-extra/chapter.tex'), + textFile('shared/one.tex'), textFile('shared/two.tex')]}); + model.update('edited'); + model.rename('notes', 'NOTES'); + model.rename('NOTES', 'notes/document.txt'); + assert.equal(model.activePath, 'notes/document.txt'); + assert.equal(model.mainPath, 'notes/document.txt'); + assert.equal(model.undo().text, 'old'); + assert.equal(model.list().length, 4); +}); + test('file mutations update active and main paths while keeping text/history', () => { const model = new ProjectModel({files: [textFile('main.tex', 'old'), textFile('refs.bib', 'bib')]}); model.update('new'); diff --git a/tests/server.test.mjs b/tests/server.test.mjs index a233a5f..34364fb 100644 --- a/tests/server.test.mjs +++ b/tests/server.test.mjs @@ -158,6 +158,51 @@ test('expired results are removed and invalid engine output is rejected', async assert.equal((await invalid.completed(bad.id)).code, 'INVALID_PDF'); }); +test('reading pages renews result lifetime while idle results still expire', async t => { + let clock = 1; + const f = await fixture(t, {now: () => clock, jobTTL: 10, + previewInfo: async () => ({available: true, pageCount: 3, maxPages: 100}), + previewRenderer: async (_, page) => Buffer.from('PNG page ' + page)}); + const {id} = await (await f.compile()).json(); await f.completed(id); + const route = '/api/jobs/' + id; + for (clock of [7, 13, 19, 25]) { + assert.equal((await f.call(route + '/preview/2')).status, 200, 'active readers retain both cached and rendered pages'); + } + clock = 34; + assert.equal((await f.call(route + '/pdf')).status, 200, 'PDF downloads also count as use'); + clock = 43; + assert.equal((await fetch(f.url + route)).status, 403, 'unauthenticated traffic cannot extend a result'); + clock = 44; + assert.equal((await f.call(route)).status, 404, 'an idle result is still removed at the TTL'); +}); + +test('a page render started near expiry renews the job before asynchronous rendering', async t => { + let clock = 1, finish, started, aborted = false; + const rendering = new Promise(resolve => { started = resolve; }); + const f = await fixture(t, {now: () => clock, jobTTL: 10, + previewInfo: async () => ({available: true, pageCount: 1, maxPages: 100}), + previewRenderer: (_, __, {signal}) => new Promise(resolve => { + finish = () => resolve(Buffer.from('PNG page')); + signal.addEventListener('abort', () => { aborted = true; finish(); }, {once: true}); + started(); + })}); + t.after(() => finish?.()); + const {id} = await (await f.compile()).json(); await f.completed(id); + const route = '/api/jobs/' + id; + await f.call(route + '/preview'); + clock = 9; + const pending = f.call(route + '/preview/1'); + await rendering; + clock = 11; + await f.call('/api/status'); // Triggers a sweep without accessing this job. + assert.equal(aborted, false, 'the active request has already renewed its job'); + clock = 18; + finish(); + assert.equal((await pending).status, 200); + clock = 28; + assert.equal((await f.call(route)).status, 404, 'completion renews once but does not retain the job forever'); +}); + test('authenticated preview metadata and PNG pages are cached without delaying PDF downloads', async t => { let metadataCalls = 0; const pages = []; diff --git a/tests/workspace-import-isolation.test.mjs b/tests/workspace-import-isolation.test.mjs new file mode 100644 index 0000000..2c76ece --- /dev/null +++ b/tests/workspace-import-isolation.test.mjs @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {fixture} from './helpers/workspace-fixture.mjs'; + +const encode = source => new TextEncoder().encode(source); + +for (const replacement of ['new project', 'imported project']) { + test('pending Add Files cannot enter a later ' + replacement, async () => { + const f = await fixture(); + const previousModel = f.app.model; + let finishReading; + const bytes = encode('This belongs to the old project.'); + f.node('importFiles').files = [{name: 'old-project-only.txt', size: bytes.length, + arrayBuffer: () => new Promise(resolve => { finishReading = resolve; })}]; + const importing = f.emit(f.node('importFiles'), 'change'); + if (replacement === 'new project') await f.click('btnNewProject'); + else { + const project = JSON.stringify({format: 'visual-workspace', version: 1, name: 'Replacement', + files: [{path: 'main.tex', kind: 'text', text: 'Imported project.'}], activePath: 'main.tex', mainPath: 'main.tex'}); + f.node('importProject').files = [{name: 'replacement.vlproj', size: project.length, text: async () => project}]; + await f.emit(f.node('importProject'), 'change'); + } + assert.notEqual(f.app.model, previousModel); + await f.input('sourceArea', 'The replacement project has its own edits.'); + f.app.persist(); + const expected = f.app.model.serialize(); + const saved = f.storage.get('visuallatex_workspace_v1'); + const status = f.node('statusText').textContent; + finishReading(bytes.buffer); + await importing; + assert.equal(f.app.model.serialize(), expected); + assert.equal(f.storage.get('visuallatex_workspace_v1'), saved); + assert.equal(f.node('sourceArea').value, 'The replacement project has its own edits.'); + assert.equal(f.node('statusText').textContent, status); + assert.equal(previousModel.get('old-project-only.txt'), null); + }); +} + +test('failed reads from a replaced project do not overwrite the current project status', async () => { + const f = await fixture(); + let rejectReading; + f.node('importFiles').files = [{name: 'unavailable.txt', size: 10, + arrayBuffer: () => new Promise((resolve, reject) => { rejectReading = reject; })}]; + const importing = f.emit(f.node('importFiles'), 'change'); + await f.click('btnNewProject'); + f.node('statusText').textContent = 'Current project status'; + const expected = f.app.model.serialize(); + rejectReading(new Error('Old file read failed')); + await importing; + assert.equal(f.app.model.serialize(), expected); + assert.equal(f.node('statusText').textContent, 'Current project status'); +}); + +test('Add Files still reports read failures for its current project', async () => { + const f = await fixture(); + const expected = f.app.model.serialize(); + f.node('importFiles').files = [{name: 'unavailable.txt', size: 10, + arrayBuffer: async () => { throw new Error('Current file read failed'); }}]; + await f.emit(f.node('importFiles'), 'change'); + assert.equal(f.app.model.serialize(), expected); + assert.equal(f.node('statusText').textContent, 'Current file read failed'); +}); + +test('new files, renamed files and ZIP imports reject file-directory conflicts before changing the draft', async () => { + const f = await fixture(); + f.prompts.push('sections/intro.tex'); + await f.click('newFile'); + await f.input('sourceArea', 'An existing chapter.'); + f.app.persist(); + const expected = f.app.model.serialize(); + const saved = f.storage.get('visuallatex_workspace_v1'); + f.prompts.push('SECTIONS'); + await f.click('newFile'); + assert.equal(f.app.model.serialize(), expected); + assert.equal(f.storage.get('visuallatex_workspace_v1'), saved); + assert.match(f.node('statusText').textContent, /文件与目录路径冲突/); + f.app.switchFile('main.tex'); + const beforeRename = f.app.model.serialize(); + f.prompts.push('sections'); + await f.click('renameFile'); + assert.equal(f.app.model.serialize(), beforeRename); + assert.match(f.node('statusText').textContent, /文件与目录路径冲突/); + + const bytes = f.context.ProjectArchive.writeZip([{path: 'notes', bytes: encode('File')}, + {path: 'notes/chapter.tex', bytes: encode('Chapter')}]); + f.node('importProject').files = [{name: 'conflict.zip', size: bytes.length, arrayBuffer: async () => bytes.buffer}]; + const beforeImport = f.storage.get('visuallatex_workspace_v1'); + await f.emit(f.node('importProject'), 'change'); + assert.equal(f.app.model.serialize(), beforeRename); + assert.equal(f.storage.get('visuallatex_workspace_v1'), beforeImport); + assert.match(f.node('statusText').textContent, /文件与目录路径冲突/); + assert.equal(f.app.model.get('sections/intro.tex').text, 'An existing chapter.'); +}); From 9e84e5e10cc456e23056ca6423fa5d99478b0f60 Mon Sep 17 00:00:00 2001 From: nisconder <2129048448@qq.com> Date: Fri, 13 Nov 2026 14:23:00 +0800 Subject: [PATCH 3/3] Adapt new regression tests to upstream source layout --- tests/image-label-regression.test.mjs | 2 +- tests/preview-expiry-integration.test.mjs | 4 ++-- tests/preview-integration.test.mjs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/image-label-regression.test.mjs b/tests/image-label-regression.test.mjs index a5ddb3b..6656773 100644 --- a/tests/image-label-regression.test.mjs +++ b/tests/image-label-regression.test.mjs @@ -20,7 +20,7 @@ function fixture() { const context = vm.createContext({document, TextEncoder, Node: {TEXT_NODE: 3, ELEMENT_NODE: 1}, __editor: editor, __statuses: statuses}); for (const name of ['main.js', 'project-model.js', 'visual-references.js']) { - vm.runInContext(fs.readFileSync(new URL('../' + name, import.meta.url), 'utf8'), context); + vm.runInContext(fs.readFileSync(new URL('../src/js/' + name, import.meta.url), 'utf8'), context); } // Only cursor placement and persistence notifications are outside this integration check. vm.runInContext(`editorArea = __editor; diff --git a/tests/preview-expiry-integration.test.mjs b/tests/preview-expiry-integration.test.mjs index 5d83d69..d893d4f 100644 --- a/tests/preview-expiry-integration.test.mjs +++ b/tests/preview-expiry-integration.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; import vm from 'node:vm'; -import {createWorkspaceServer} from '../server.mjs'; +import {createWorkspaceServer} from '../server/server.mjs'; test('real HTTP preview survives active reading and recovers an idle eviction on explicit retry', async t => { let clock = 1000, compilations = 0, downloads = 0, urlId = 0; @@ -18,7 +18,7 @@ test('real HTTP preview survives active reading and recovers an idle eviction on const fetcher = (url, init) => fetch(base + url, init); const urls = {createObjectURL: () => 'blob:fixture/' + ++urlId, revokeObjectURL() {}}; const context = vm.createContext({Blob, AbortController, setTimeout, clearTimeout, URL: urls, fetch: fetcher}); - vm.runInContext(await readFile(new URL('../preview.js', import.meta.url), 'utf8'), context); + vm.runInContext(await readFile(new URL('../src/js/preview.js', import.meta.url), 'utf8'), context); const controller = new context.CompilationController({getRequest: () => structuredClone(request), fetch: fetcher, urls, protocol: 'http:', pollInterval: 1, download: () => { downloads++; }}); t.after(() => controller.destroy()); diff --git a/tests/preview-integration.test.mjs b/tests/preview-integration.test.mjs index e92c991..408bb98 100644 --- a/tests/preview-integration.test.mjs +++ b/tests/preview-integration.test.mjs @@ -2,9 +2,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import vm from 'node:vm'; -import {createWorkspaceServer} from '../server.mjs'; +import {createWorkspaceServer} from '../server/server.mjs'; -const code = readFileSync(new URL('../preview.js', import.meta.url), 'utf8'); +const code = readFileSync(new URL('../src/js/preview.js', import.meta.url), 'utf8'); const PDF = Buffer.from('%PDF-1.4\nfixture\n%%EOF'); const engines = {latex: {available: true, engine: 'tectonic', label: 'Tectonic'}, postscript: {available: false}}; const request = {kind: 'latex', mainPath: 'main.tex', project: {format: 'visual-workspace', version: 1,