刘光辉
12 小时以前 0dfe84494048ce27ba8449831782128412d3eb13
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
import type { HmrContext, Plugin, ViteDevServer } from 'vite';
 
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import process from 'node:process';
 
import { describe, expect, it, vi } from 'vitest';
 
import { createDevelopmentManifest, onlyOfficeDevelopmentPlugin, resolveOnlyOfficeDevOptions } from '../../vite/onlyoffice-plugin';
 
const now = new Date('2026-08-11T01:02:03Z');
const manifestPath = resolve(process.cwd(), 'config.json');
 
describe('onlyoffice 开发配置', () => {
  it('接受独立 HMR path', () => {
    expect(resolveOnlyOfficeDevOptions({ ONLYOFFICE_PLUGIN_HMR_PATH: '/__hmr/eln-template-annotator' }, now).hmrPath).toBe('/__hmr/eln-template-annotator');
  });
 
  it.each(['__hmr/plugin', '/', '/bad path', '/bad?query'])('拒绝非法 HMR path %s', (hmrPath) => {
    expect(() => resolveOnlyOfficeDevOptions({ ONLYOFFICE_PLUGIN_HMR_PATH: hmrPath }, now)).toThrow('ONLYOFFICE_PLUGIN_HMR_PATH');
  });
 
  it('生成保持 0.1.0 的默认开发配置', () => {
    const options = resolveOnlyOfficeDevOptions({}, now);
 
    expect(options).toEqual({
      basePath: '/onlyoffice-plugins/0.1.0-dev/eln-template-annotator',
      docsUrl: 'http://localhost:20000',
      hmrClientPort: 4173,
      hmrHost: 'localhost',
      hmrProtocol: 'ws',
      host: '0.0.0.0',
      manifestUrl: 'http://localhost:4173/onlyoffice-plugins/0.1.0-dev/eln-template-annotator/config.json?v=20260811T010203',
      port: 4173,
      publicHost: 'localhost',
      release: '0.1.0-dev',
      startupId: '20260811T010203',
    });
  });
 
  it('解析服务端环境变量并由 HMR 协议生成公开 URL', () => {
    const options = resolveOnlyOfficeDevOptions(
      {
        ONLYOFFICE_DOCS_URL: 'https://docs.example.com/office///',
        ONLYOFFICE_PLUGIN_DEV_HOST: '127.0.0.1',
        ONLYOFFICE_PLUGIN_DEV_PORT: '5174',
        ONLYOFFICE_PLUGIN_HMR_CLIENT_PORT: '443',
        ONLYOFFICE_PLUGIN_HMR_HOST: 'hmr.example.com',
        ONLYOFFICE_PLUGIN_HMR_PROTOCOL: 'wss',
        ONLYOFFICE_PLUGIN_PUBLIC_HOST: 'plugins.example.com',
      },
      now,
    );
 
    expect(options).toMatchObject({
      docsUrl: 'https://docs.example.com/office',
      hmrClientPort: 443,
      hmrHost: 'hmr.example.com',
      hmrProtocol: 'wss',
      host: '127.0.0.1',
      manifestUrl: 'https://plugins.example.com:5174/onlyoffice-plugins/0.1.0-dev/eln-template-annotator/config.json?v=20260811T010203',
      port: 5174,
      publicHost: 'plugins.example.com',
    });
  });
 
  it('让 HMR host 和 client port 默认跟随公开 host 与开发端口', () => {
    const options = resolveOnlyOfficeDevOptions(
      {
        ONLYOFFICE_PLUGIN_DEV_PORT: '5174',
        ONLYOFFICE_PLUGIN_PUBLIC_HOST: 'plugins.example.com',
      },
      now,
    );
 
    expect(options.hmrHost).toBe('plugins.example.com');
    expect(options.hmrClientPort).toBe(5174);
  });
 
  it('使用 UTC 生成固定长度的启动标识并支持 bracket IPv6 公开 host', () => {
    const options = resolveOnlyOfficeDevOptions({ ONLYOFFICE_PLUGIN_PUBLIC_HOST: '[2001:db8::1]' }, new Date('2026-01-02T03:04:05+08:00'));
 
    expect(options.startupId).toBe('20260101T190405');
    expect(options.manifestUrl).toBe('http://[2001:db8::1]:4173/onlyoffice-plugins/0.1.0-dev/eln-template-annotator/config.json?v=20260101T190405');
  });
 
  it('监听 host 使用裸 IPv6,URL host 使用 bracket IPv6', () => {
    const options = resolveOnlyOfficeDevOptions(
      {
        ONLYOFFICE_PLUGIN_DEV_HOST: '::1',
        ONLYOFFICE_PLUGIN_HMR_HOST: '[2001:db8::2]',
        ONLYOFFICE_PLUGIN_PUBLIC_HOST: '[2001:db8::1]',
      },
      now,
    );
 
    expect(options.host).toBe('::1');
    expect(options.hmrHost).toBe('[2001:db8::2]');
    expect(options.publicHost).toBe('[2001:db8::1]');
  });
 
  it('拒绝带方括号的监听 IPv6 host', () => {
    expect(() => resolveOnlyOfficeDevOptions({ ONLYOFFICE_PLUGIN_DEV_HOST: '[::1]' }, now)).toThrow('ONLYOFFICE_PLUGIN_DEV_HOST');
  });
 
  it.each([
    ['ONLYOFFICE_PLUGIN_DEV_PORT', '0'],
    ['ONLYOFFICE_PLUGIN_DEV_PORT', '65536'],
    ['ONLYOFFICE_PLUGIN_DEV_PORT', '1.5'],
    ['ONLYOFFICE_PLUGIN_DEV_PORT', ' 4173'],
    ['ONLYOFFICE_PLUGIN_HMR_CLIENT_PORT', ''],
    ['ONLYOFFICE_PLUGIN_HMR_CLIENT_PORT', '01'],
  ])('拒绝非法端口 %s=%j', (name, value) => {
    expect(() => resolveOnlyOfficeDevOptions({ [name]: value }, now)).toThrow(name);
  });
 
  it.each(['file', 'http', 'WS', ''])('拒绝非法 HMR 协议 %j', (protocol) => {
    expect(() => resolveOnlyOfficeDevOptions({ ONLYOFFICE_PLUGIN_HMR_PROTOCOL: protocol }, now)).toThrow('ONLYOFFICE_PLUGIN_HMR_PROTOCOL');
  });
 
  it.each([
    ['ONLYOFFICE_PLUGIN_PUBLIC_HOST', 'http://localhost'],
    ['ONLYOFFICE_PLUGIN_PUBLIC_HOST', 'localhost/path'],
    ['ONLYOFFICE_PLUGIN_PUBLIC_HOST', 'local host'],
    ['ONLYOFFICE_PLUGIN_PUBLIC_HOST', 'localhost:4173'],
    ['ONLYOFFICE_PLUGIN_PUBLIC_HOST', '2001:db8::1'],
    ['ONLYOFFICE_PLUGIN_HMR_HOST', 'hmr.example.com/path'],
    ['ONLYOFFICE_PLUGIN_DEV_HOST', '127.0.0.1\ninvalid'],
  ])('拒绝非法 host %s=%j', (name, value) => {
    expect(() => resolveOnlyOfficeDevOptions({ [name]: value }, now)).toThrow(name);
  });
 
  it.each([
    'file:///tmp/sdk',
    'ftp://docs.example.com',
    'https://user:secret@docs.example.com',
    'https://docs.example.com/office?tenant=test',
    'https://docs.example.com/office#sdk',
    'not a URL',
  ])('拒绝非法 Docs URL %j', (docsUrl) => {
    expect(() => resolveOnlyOfficeDevOptions({ ONLYOFFICE_DOCS_URL: docsUrl }, now)).toThrow('ONLYOFFICE_DOCS_URL');
  });
 
  it('拒绝无效启动时间', () => {
    expect(() => resolveOnlyOfficeDevOptions({}, new Date(Number.NaN))).toThrow('now');
  });
});
 
describe('onlyoffice 开发 manifest', () => {
  const options = resolveOnlyOfficeDevOptions({}, now);
 
  function readSourceManifest(): Record<string, unknown> {
    return JSON.parse(readFileSync(manifestPath, 'utf8')) as Record<string, unknown>;
  }
 
  it('克隆正式 manifest 并只改写开发 iframe URL', () => {
    const source = readSourceManifest();
    const sourceSnapshot = structuredClone(source);
    const manifest = createDevelopmentManifest(source, options);
 
    expect(manifest).not.toBe(source);
    expect(manifest).toMatchObject({
      guid: source.guid,
      minVersion: '9.4.0',
      version: '0.1.0',
    });
    expect(manifest.variations).not.toBe(source.variations);
    expect(manifest).toMatchObject({
      variations: [
        {
          url: 'index.html?v=20260811T010203',
        },
      ],
    });
    expect(source).toEqual(sourceSnapshot);
  });
 
  it('拒绝错误 GUID', () => {
    const source = readSourceManifest();
    source.guid = 'asc.{00000000-0000-0000-0000-000000000000}';
 
    expect(() => createDevelopmentManifest(source, options)).toThrow('GUID');
  });
 
  it('拒绝错误正式版本', () => {
    const source = readSourceManifest();
    source.version = '0.1.1';
 
    expect(() => createDevelopmentManifest(source, options)).toThrow('version');
  });
 
  it.each([undefined, [], [null], [{}], [{ url: 42 }]])('拒绝无有效首个 variation URL 的 manifest:%j', (variations) => {
    const source = readSourceManifest();
    source.variations = variations;
 
    expect(() => createDevelopmentManifest(source, options)).toThrow('variation');
  });
});
 
describe('onlyoffice 开发服务生命周期', () => {
  const options = resolveOnlyOfficeDevOptions({}, now);
 
  function getConfigureServerHook(plugin: Plugin): (server: ViteDevServer) => unknown {
    const hook = plugin.configureServer;
    if (!hook) throw new Error('缺少 configureServer hook');
    return (typeof hook === 'function' ? hook : hook.handler) as (server: ViteDevServer) => unknown;
  }
 
  function getHandleHotUpdateHook(plugin: Plugin): (context: HmrContext) => unknown {
    const hook = plugin.handleHotUpdate;
    if (!hook) throw new Error('缺少 handleHotUpdate hook');
    return (typeof hook === 'function' ? hook : hook.handler) as (context: HmrContext) => unknown;
  }
 
  it('http server 开始监听后只打印一次完整 manifest URL', () => {
    const plugin = onlyOfficeDevelopmentPlugin(options);
    let listeningHandler: (() => void) | undefined;
    const httpServer = {
      once(event: string, handler: () => void) {
        if (event === 'listening') listeningHandler = handler;
      },
    };
    const info = vi.fn();
    const server = {
      config: { logger: { info } },
      httpServer,
      middlewares: { use: vi.fn() },
    } as unknown as ViteDevServer;
 
    getConfigureServerHook(plugin)(server);
    expect(info).not.toHaveBeenCalled();
 
    listeningHandler?.();
    listeningHandler?.();
 
    expect(info).toHaveBeenCalledTimes(1);
    expect(info).toHaveBeenCalledWith(options.manifestUrl);
  });
 
  it.each(['src/main.ts', 'src/onlyoffice/bridge.ts', 'src/onlyoffice/commands.ts', 'src/onlyoffice/types.ts', 'src/composables/useTemplateAnnotator.ts'])(
    '%s 变化时完整刷新 iframe 并阻止旧模块继续热更新',
    (relativePath) => {
      const plugin = onlyOfficeDevelopmentPlugin(options);
      const send = vi.fn();
      const context = {
        file: resolve(process.cwd(), relativePath),
        server: { ws: { send } },
      } as unknown as HmrContext;
 
      const result = getHandleHotUpdateHook(plugin)(context);
 
      expect(result).toEqual([]);
      expect(send).toHaveBeenCalledOnce();
      expect(send).toHaveBeenCalledWith({ path: '*', type: 'full-reload' });
    },
  );
 
  it.each(['src/App.vue', 'src/components/FieldEditor.vue', 'src/styles.css'])('%s 交给 Vite 默认 HMR,不主动完整刷新', (relativePath) => {
    const plugin = onlyOfficeDevelopmentPlugin(options);
    const send = vi.fn();
    const context = {
      file: resolve(process.cwd(), relativePath),
      server: { ws: { send } },
    } as unknown as HmrContext;
 
    const result = getHandleHotUpdateHook(plugin)(context);
 
    expect(result).toBeUndefined();
    expect(send).not.toHaveBeenCalled();
  });
 
  it('config.json 变化时提示重新打开会话、完整刷新且保持本次 startup ID', () => {
    const plugin = onlyOfficeDevelopmentPlugin(options);
    const send = vi.fn();
    const warn = vi.fn();
    const context = {
      file: resolve(process.cwd(), 'config.json'),
      server: { config: { logger: { warn } }, ws: { send } },
    } as unknown as HmrContext;
 
    const result = getHandleHotUpdateHook(plugin)(context);
 
    expect(result).toEqual([]);
    expect(send).toHaveBeenCalledWith({ path: '*', type: 'full-reload' });
    expect(warn).toHaveBeenCalledWith('config.json 已变化,请重启 Dev Server 后重新打开编辑器会话。');
    expect(options.startupId).toBe('20260811T010203');
    expect(options.manifestUrl).toContain(`v=${options.startupId}`);
  });
});
// @vitest-environment node