Files
CloudSSH/tests/editor-content.test.ts
TanXin ac6e7d8f6a release: 发布 v1.14.0 SFTP 在线编辑功能版本 (#112)
## 发布说明

**v1.14.0 SFTP 在线编辑功能版本**

### 版本要点

- **SFTP 在线编辑文件**(Closes #111):在 SFTP 文件管理器中直接编辑远端文本文件。CodeMirror 6
编辑器(shell/YAML/JSON/Python/Markdown/HTML/CSS/Dockerfile/systemd
等语法高亮,配色跟随主题);≤2MB 上限 + 空字节嗅探拒绝二进制;保存前 mtime+size 冲突检测与显式覆盖确认;保留 LF/CRLF
与 BOM(字节级往返);UTF-8 可编辑、GBK/GB18030 只读;随分享会话 \`allowSftp\` 门控并纳入审计
- **编辑器移动端适配**:窄屏近全屏 + 安全区,软键盘弹出跟随可视视口收缩;44px 触摸目标;16px 字号规避 iOS 聚焦强制缩放
- **SFTP 面板桌面宽度有界弹性**:\`clamp(420px, 40vw, 600px)\`,1280 笔记本
512px、1500px 起 600px 封顶
- **修复 SFTP 操作栏按钮文字 WebKit 竖排换行**:全局 nowrap + 整栏横向滚动,新增 webkit-mobile 项目
e2e 守护
- **wrangler 升级至 4.125.0**:修复 CI 部署 Dashboard 元数据获取失败(4.127.1 因
minimumReleaseAge 策略暂缓)

### 提交列表

| 提交 | 说明 |
| ------ | ------ |
| 9ec38d3 | feat: SFTP 文件管理器新增在线编辑文件功能(#111) |
| bf45ee0 | chore: 升级 wrangler 至 4.125.0 修复 Dashboard 元数据获取失败 |
| ed985bd | feat: SFTP 在线编辑器移动端适配 |
| 0bdb1c8 | fix: SFTP 操作栏按钮文字在 WebKit 下竖排换行 |
| a3fbeff | feat: SFTP 面板桌面宽度改为有界弹性 clamp(420px, 40vw, 600px) |
| e7877d3 | release: 发布 v1.14.0 SFTP 在线编辑功能版本(版本号 + CHANGELOG + 构建产物) |

### 关联 Issue / PR

- Closes #111(能添加个在线编辑文件功能吗?)

### 验证结果

- ✅ \`pnpm run verify\` 全绿:typecheck(worker + frontend)/ test 58 文件 698
用例 / build:frontend(单 JS + CSS 内联,84 modules)/ test:e2e 60 用例
- ✅ test 环境 CI 部署成功(wrangler 4.125.0)
- ✅ 新增测试:editor-content 纯函数 20 用例、worker editReadFile 8 用例、e2e 编辑器 5 用例
+ 窄视口 1 用例、操作栏布局双视口 × 双引擎 4 用例
2026-08-30 01:00:22 +08:00

185 lines
6.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
BINARY_SNIFF_BYTES,
containsNullByte,
decodeEditorContent,
detectEol,
EDITOR_MAX_FILE_SIZE,
encodeEditorContent,
isEditorSizeAllowed,
} from '../frontend/src/editor-content';
function utf8(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
function withBom(bytes: Uint8Array): Uint8Array {
const out = new Uint8Array(bytes.length + 3);
out.set([0xef, 0xbb, 0xbf], 0);
out.set(bytes, 3);
return out;
}
function encodeDecoded(original: Uint8Array): Uint8Array {
const decoded = decodeEditorContent(original);
if (!decoded.ok) throw new Error(`unexpected decode failure: ${decoded.reason}`);
return encodeEditorContent(decoded.content.text, {
bom: decoded.content.bom,
eol: decoded.content.eol,
});
}
describe('二进制嗅探', () => {
it('纯文本字节不含 NUL 不误判', () => {
expect(containsNullByte(utf8('hello 配置\nworld\r\n'))).toBe(false);
});
it('嗅探窗口内出现 NUL 判定为二进制', () => {
const bytes = new Uint8Array([0x61, 0x00, 0x62]);
expect(containsNullByte(bytes)).toBe(true);
});
it('嗅探窗口外的 NUL 不参与判定', () => {
const bytes = new Uint8Array(BINARY_SNIFF_BYTES + 4).fill(0x61);
bytes[BINARY_SNIFF_BYTES + 2] = 0;
expect(containsNullByte(bytes)).toBe(false);
});
it('UTF-16 文本因 NUL 被判定为二进制(与 Git 行为一致)', () => {
expect(containsNullByte(new Uint8Array([0x61, 0x00, 0x62, 0x00]))).toBe(true);
});
});
describe('EOL 检测', () => {
it('CRLF 主导时返回 CRLF', () => {
expect(detectEol('a\r\nb\r\nc')).toBe('\r\n');
});
it('LF 主导时返回 LF', () => {
expect(detectEol('a\nb\nc\r\n')).toBe('\n');
});
it('混合换行含孤立 CR 时保守按 LF 归一', () => {
expect(detectEol('a\rb\nc')).toBe('\n');
});
it('空文本默认 LF', () => {
expect(detectEol('')).toBe('\n');
});
});
describe('编辑内容解码', () => {
it('UTF-8 文本解码并保留元信息', () => {
const result = decodeEditorContent(utf8('# title\nserver {\n}\n'));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.content.encoding).toBe('utf-8');
expect(result.content.bom).toBe(false);
expect(result.content.eol).toBe('\n');
expect(result.content.text).toBe('# title\nserver {\n}\n');
});
it('剥离 UTF-8 BOM 并记录标志', () => {
const result = decodeEditorContent(withBom(utf8('key=value')));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.content.bom).toBe(true);
expect(result.content.text).toBe('key=value');
});
it('CRLF 文件检测为 CRLF', () => {
const result = decodeEditorContent(utf8('a\r\nb\r\n'));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.content.eol).toBe('\r\n');
});
it('包含 NUL 的字节拒绝为二进制', () => {
const result = decodeEditorContent(new Uint8Array([0x61, 0x00, 0x62]));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('binary');
});
it('GBK 中文经 GB18030 解码成功(只读路径)', () => {
// “配置” 的 GBK 编码:配=C5E4 置=D6C3
const result = decodeEditorContent(new Uint8Array([0xc5, 0xe4, 0xd6, 0xc3]));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.content.encoding).toBe('gb18030');
expect(result.content.text).toBe('配置');
});
it('UTF-8 与 GB18030 均无法解码时返回 encoding 失败', () => {
// 0xFF 既非合法 UTF-8 序列,也非 GB18030 合法首字节
const result = decodeEditorContent(new Uint8Array([0xff, 0x41]));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('encoding');
});
it('空文件按 UTF-8 空文本处理', () => {
const result = decodeEditorContent(new Uint8Array(0));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.content.text).toBe('');
expect(result.content.encoding).toBe('utf-8');
expect(result.content.bom).toBe(false);
});
});
describe('编辑内容编码回写', () => {
it('LF 无 BOM 文件字节级还原', () => {
const original = utf8('# title\nlisten 80;\n');
expect(Buffer.from(encodeDecoded(original)).equals(Buffer.from(original))).toBe(true);
});
it('CRLF + BOM 文件字节级还原', () => {
const original = withBom(utf8('a\r\nb\r\n'));
expect(Buffer.from(encodeDecoded(original)).equals(Buffer.from(original))).toBe(true);
});
it('编辑后的内容按原 EOL 回写', () => {
const decoded = decodeEditorContent(utf8('a\r\nb\r\n'));
if (!decoded.ok) throw new Error('unexpected decode failure');
const encoded = encodeEditorContent(decoded.content.text.replace('b', 'c'), {
bom: decoded.content.bom,
eol: decoded.content.eol,
});
expect(new TextDecoder().decode(encoded)).toBe('a\r\nc\r\n');
});
it('保存时补回 BOM', () => {
const encoded = encodeEditorContent('hello', { bom: true, eol: '\n' });
expect([encoded[0], encoded[1], encoded[2]]).toEqual([0xef, 0xbb, 0xbf]);
expect(new TextDecoder().decode(encoded.subarray(3))).toBe('hello');
});
it('解码→编码→再解码往返一致', () => {
const original = withBom(utf8('[unit]\r\nAfter=network.target\r\n'));
const first = decodeEditorContent(original);
if (!first.ok) throw new Error('unexpected decode failure');
const second = decodeEditorContent(
encodeEditorContent(first.content.text, {
bom: first.content.bom,
eol: first.content.eol,
})
);
expect(second.ok).toBe(true);
if (!second.ok) return;
expect(second.content).toEqual(first.content);
});
});
describe('在线编辑大小上限', () => {
it('边界值符合 2MB 上限语义', () => {
expect(EDITOR_MAX_FILE_SIZE).toBe(2 * 1024 * 1024);
expect(isEditorSizeAllowed(0)).toBe(true);
expect(isEditorSizeAllowed(EDITOR_MAX_FILE_SIZE)).toBe(true);
expect(isEditorSizeAllowed(EDITOR_MAX_FILE_SIZE + 1)).toBe(false);
expect(isEditorSizeAllowed(-1)).toBe(false);
expect(isEditorSizeAllowed(Number.NaN)).toBe(false);
expect(isEditorSizeAllowed(Number.POSITIVE_INFINITY)).toBe(false);
});
});