diff --git a/src/js/modules/README.md b/src/js/modules/README.md
index 1cda00a..2403042 100644
--- a/src/js/modules/README.md
+++ b/src/js/modules/README.md
@@ -58,6 +58,15 @@ giffgaffAPI.clearCache();
#### `clipboard.js`
Shared clipboard helper. Re-exported by Giffgaff/Simyo `utils.js` so existing import paths stay stable.
+#### `diagnostics.js`
+Issue 诊断导出。业务页初始化后可在控制台执行:
+
+```js
+copyEsimDiagnostics() // 复制脱敏 JSON 到剪贴板,便于粘贴到 GitHub Issue
+```
+
+不包含 token / 密码 / cookie / LPA 等敏感字段。
+
#### `resource-hints.js`
> **状态:未挂载生产页面**。路径已对齐原生 ES 模块静态托管,但 HTML 入口尚未 `import` 本模块。
> 生产页面当前依赖浏览器默认加载与各页已写死的 script/link。
diff --git a/tests/modules/diagnostics.test.js b/tests/modules/diagnostics.test.js
new file mode 100644
index 0000000..6f7a75e
--- /dev/null
+++ b/tests/modules/diagnostics.test.js
@@ -0,0 +1,76 @@
+'use strict';
+
+/**
+ * 诊断信息导出测试
+ */
+
+import {
+ collectDiagnostics,
+ formatDiagnostics,
+ copyDiagnostics,
+ installDiagnosticsGlobal
+} from '../../src/js/modules/diagnostics.js';
+
+describe('diagnostics', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ delete window.copyEsimDiagnostics;
+ delete window.__esimDiagnostics;
+ });
+
+ it('collectDiagnostics 应脱敏敏感字段', () => {
+ const snap = collectDiagnostics({
+ app: 'simyo',
+ state: {
+ sessionToken: 'secret-token',
+ phoneNumber: '0612345678',
+ password: 'should-not-appear',
+ currentStep: 2
+ }
+ });
+
+ expect(snap.app).toBe('simyo');
+ expect(snap.state.sessionToken).toBe('[redacted]');
+ expect(snap.state.password).toBe('[redacted]');
+ expect(snap.state.phoneNumber).toBe('0612345678');
+ expect(snap.state.currentStep).toBe(2);
+ expect(snap.page.hostname).toBeDefined();
+ });
+
+ it('formatDiagnostics 应输出可粘贴 markdown 代码块', () => {
+ const text = formatDiagnostics({ app: 'giffgaff', state: { accessToken: 'tok' } });
+ expect(text).toContain('```json');
+ expect(text).toContain('[redacted]');
+ expect(text).not.toContain('"accessToken": "tok"');
+ });
+
+ it('copyDiagnostics 应调用剪贴板', async () => {
+ navigator.clipboard.writeText.mockResolvedValueOnce(undefined);
+ Object.defineProperty(window, 'isSecureContext', {
+ configurable: true,
+ value: true
+ });
+
+ const text = await copyDiagnostics({ app: 'home' });
+ expect(navigator.clipboard.writeText).toHaveBeenCalled();
+ expect(text).toContain('```json');
+ });
+
+ it('installDiagnosticsGlobal 应挂载 copyEsimDiagnostics', async () => {
+ navigator.clipboard.writeText.mockResolvedValueOnce(undefined);
+ Object.defineProperty(window, 'isSecureContext', {
+ configurable: true,
+ value: true
+ });
+
+ installDiagnosticsGlobal({
+ app: 'simyo',
+ getState: () => ({ sessionToken: 'abc', phoneNumber: '0611111111' })
+ });
+
+ expect(typeof window.copyEsimDiagnostics).toBe('function');
+ const text = await window.copyEsimDiagnostics();
+ expect(text).toContain('[redacted]');
+ expect(text).toContain('0611111111');
+ });
+});
diff --git a/tests/simyo/auth-handler.test.js b/tests/simyo/auth-handler.test.js
index 60957e4..dcb22a5 100644
--- a/tests/simyo/auth-handler.test.js
+++ b/tests/simyo/auth-handler.test.js
@@ -39,7 +39,8 @@ describe('Simyo AuthHandler', () => {
expect(result.sessionToken).toBe('sess-abc');
expect(stateManager.get('sessionToken')).toBe('sess-abc');
expect(stateManager.get('phoneNumber')).toBe('0612345678');
- expect(stateManager.get('password')).toBe('');
+ expect(stateManager.get('password')).toBeUndefined();
+ expect(Object.prototype.hasOwnProperty.call(stateManager.getState(), 'password')).toBe(false);
expect(authHandler.isLoggedIn()).toBe(true);
});
diff --git a/tests/simyo/help.test.js b/tests/simyo/help.test.js
index f3352f0..008755d 100644
--- a/tests/simyo/help.test.js
+++ b/tests/simyo/help.test.js
@@ -3,6 +3,7 @@
*/
import { openHelp } from '../../src/simyo/js/modules/utils.js';
+import * as i18n from '../../src/js/modules/i18n.js';
describe('Simyo openHelp()', () => {
beforeEach(() => {
@@ -31,5 +32,25 @@ describe('Simyo openHelp()', () => {
overlay.click();
expect(document.querySelector('[data-help-overlay="simyo-help"]')).toBeNull();
});
+
+ it('帮助文案应作为文本渲染,不执行 HTML', () => {
+ jest.spyOn(i18n, 't').mockImplementation((key) => {
+ if (key === 'simyo.help.title') return '
XSS';
+ if (key.endsWith('.heading')) return 'Heading';
+ if (key.endsWith('.content')) return '';
+ if (key === 'simyo.help.close') return 'Close';
+ return key;
+ });
+
+ openHelp();
+
+ const overlay = document.querySelector('[data-help-overlay="simyo-help"]');
+ expect(overlay.querySelector('img[src="x"]')).toBeNull();
+ expect(overlay.querySelector('script')).toBeNull();
+ expect(overlay.textContent).toContain('
XSS');
+ expect(overlay.textContent).toContain('');
+
+ i18n.t.mockRestore();
+ });
});