feishu-user-plugin 1.3.10 → 1.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ // src/test-lark-desktop.js — unit tests for src/auth/lark-desktop.js
2
+ // + src/auth/credentials.js larkHash bindings.
3
+ // Plain assert + fixture-based; no external deps.
4
+ //
5
+ // Run: `node src/test-lark-desktop.js`
6
+
7
+ 'use strict';
8
+
9
+ const assert = require('assert');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+
14
+ const FIX_ROOT = path.join(os.tmpdir(), 'feishu-test-sdk-storage-' + process.pid + '-' + Date.now());
15
+
16
+ function makeFixture(hashes) {
17
+ fs.mkdirSync(FIX_ROOT, { recursive: true });
18
+ for (const [hash, mtimeOffset] of hashes) {
19
+ const dir = path.join(FIX_ROOT, hash);
20
+ fs.mkdirSync(dir, { recursive: true });
21
+ const dbPath = path.join(dir, 'cookie_store.db');
22
+ fs.writeFileSync(dbPath, 'fake');
23
+ const t = (Date.now() / 1000) + mtimeOffset;
24
+ fs.utimesSync(dbPath, t, t);
25
+ }
26
+ }
27
+
28
+ function cleanupFixture() {
29
+ fs.rmSync(FIX_ROOT, { recursive: true, force: true });
30
+ }
31
+
32
+ const ld = require('./auth/lark-desktop');
33
+
34
+ // --- Task 1: read-only basics ---
35
+
36
+ function testListAccountHashes() {
37
+ cleanupFixture();
38
+ makeFixture([
39
+ ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', -100],
40
+ ['bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 0],
41
+ ['cccccccccccccccccccccccccccccccc', -50],
42
+ ['notahash', 0], // should be filtered out (not 32-hex)
43
+ ['DEADBEEFDEADBEEFDEADBEEFDEADBEEF', 0], // uppercase — also filtered (we accept lowercase only)
44
+ ]);
45
+ // uppercase entry has no cookie_store.db tweaking — make sure dir exists at minimum
46
+ // (already created; just confirming file presence)
47
+ const list = ld.listAccountHashes({ dir: FIX_ROOT });
48
+ assert.strictEqual(list.length, 3, `filters non-hex names; got ${list.length}: ${list.map(h=>h.hash).join(',')}`);
49
+ assert.strictEqual(list[0].hash, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'sorted by mtime desc');
50
+ assert.strictEqual(list[2].hash, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
51
+ assert.ok(typeof list[0].mtimeMs === 'number');
52
+ assert.ok(list[0].dir.endsWith('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'));
53
+ cleanupFixture();
54
+ console.log('PASS: listAccountHashes');
55
+ }
56
+
57
+ function testListAccountHashesEmpty() {
58
+ cleanupFixture();
59
+ fs.mkdirSync(FIX_ROOT, { recursive: true });
60
+ // No hash dirs — should return []
61
+ assert.deepStrictEqual(ld.listAccountHashes({ dir: FIX_ROOT }), []);
62
+ cleanupFixture();
63
+ // Non-existent dir
64
+ assert.deepStrictEqual(ld.listAccountHashes({ dir: '/nonexistent-' + Date.now() }), []);
65
+ console.log('PASS: listAccountHashes empty / missing');
66
+ }
67
+
68
+ function testListAccountHashesIgnoresMissingDb() {
69
+ cleanupFixture();
70
+ fs.mkdirSync(path.join(FIX_ROOT, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), { recursive: true });
71
+ // No cookie_store.db file inside — entry should be skipped (we treat
72
+ // "no DB → never logged in / cleared" so it can't represent an active account)
73
+ const list = ld.listAccountHashes({ dir: FIX_ROOT });
74
+ assert.strictEqual(list.length, 0);
75
+ cleanupFixture();
76
+ console.log('PASS: listAccountHashes ignores hash dirs without cookie_store.db');
77
+ }
78
+
79
+ function testMostRecentHash() {
80
+ cleanupFixture();
81
+ makeFixture([
82
+ ['1111111111111111111111111111aaaa', -200],
83
+ ['2222222222222222222222222222bbbb', 0],
84
+ ]);
85
+ const top = ld.mostRecentHash({ dir: FIX_ROOT });
86
+ assert.strictEqual(top.hash, '2222222222222222222222222222bbbb');
87
+ assert.strictEqual(ld.mostRecentHash({ dir: '/nonexistent-' + Date.now() }), null);
88
+ cleanupFixture();
89
+ console.log('PASS: mostRecentHash');
90
+ }
91
+
92
+ function testGetSdkStorageDirSafety() {
93
+ const dir = ld.getSdkStorageDir();
94
+ if (process.platform === 'darwin') {
95
+ assert.ok(dir === null || typeof dir === 'string');
96
+ } else {
97
+ assert.strictEqual(dir, null);
98
+ }
99
+ console.log('PASS: getSdkStorageDir platform safety');
100
+ }
101
+
102
+ // --- Task 2: profile hash bindings on credentials.js ---
103
+
104
+ function testProfileHashBindings() {
105
+ const sandbox = path.join(os.tmpdir(), 'feishu-test-creds-' + process.pid + '-' + Date.now());
106
+ fs.mkdirSync(path.join(sandbox, '.feishu-user-plugin'), { recursive: true, mode: 0o700 });
107
+ const credPath = path.join(sandbox, '.feishu-user-plugin', 'credentials.json');
108
+
109
+ const baseFile = {
110
+ version: 1,
111
+ active: 'default',
112
+ profiles: {
113
+ default: { LARK_APP_ID: 'cli_aaa' },
114
+ work: { LARK_APP_ID: 'cli_bbb' },
115
+ },
116
+ profileHints: {},
117
+ };
118
+ fs.writeFileSync(credPath, JSON.stringify(baseFile, null, 2));
119
+
120
+ const origHome = process.env.HOME;
121
+ process.env.HOME = sandbox;
122
+
123
+ // Force re-require so any cached internal state in credentials.js is fresh.
124
+ // (credentials.js doesn't actually cache — it re-reads on every call — but
125
+ // belt-and-suspenders.)
126
+ delete require.cache[require.resolve('./auth/credentials')];
127
+ const credentials = require('./auth/credentials');
128
+
129
+ try {
130
+ // Initially unbound
131
+ assert.strictEqual(credentials.findProfileByHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), null);
132
+ assert.strictEqual(credentials.getProfileLarkHash('default'), null);
133
+ assert.strictEqual(credentials.getProfileLarkHash('work'), null);
134
+
135
+ // Bind default
136
+ credentials.setProfileLarkHash('default', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
137
+ assert.strictEqual(credentials.getProfileLarkHash('default'), 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
138
+ assert.strictEqual(credentials.findProfileByHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 'default');
139
+
140
+ // Bind work
141
+ credentials.setProfileLarkHash('work', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
142
+ assert.strictEqual(credentials.findProfileByHash('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'), 'work');
143
+ // default still bound
144
+ assert.strictEqual(credentials.findProfileByHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 'default');
145
+
146
+ // Validation: bad hex
147
+ assert.throws(() => credentials.setProfileLarkHash('default', 'not-hex'),
148
+ /must be 32-char hex/);
149
+ // Validation: missing profile
150
+ assert.throws(() => credentials.setProfileLarkHash('nope', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'),
151
+ /not found/);
152
+
153
+ // findProfileByHash with bad input → null
154
+ assert.strictEqual(credentials.findProfileByHash('not-hex'), null);
155
+ assert.strictEqual(credentials.findProfileByHash(null), null);
156
+ assert.strictEqual(credentials.findProfileByHash(undefined), null);
157
+
158
+ // Clear by passing null
159
+ credentials.setProfileLarkHash('default', null);
160
+ assert.strictEqual(credentials.getProfileLarkHash('default'), null);
161
+ assert.strictEqual(credentials.findProfileByHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), null);
162
+ } finally {
163
+ process.env.HOME = origHome;
164
+ fs.rmSync(sandbox, { recursive: true, force: true });
165
+ delete require.cache[require.resolve('./auth/credentials')];
166
+ }
167
+ console.log('PASS: profile hash bindings');
168
+ }
169
+
170
+ // --- Task 4: detectSwitch logic (pure) ---
171
+
172
+ function testDetectSwitchDebounce() {
173
+ const result = ld.detectSwitch({
174
+ prevSnapshot: {},
175
+ lastSwitchAt: Date.now() - 1000, // 1s ago, < 5s debounce
176
+ seenUnboundHashes: new Set(),
177
+ listFn: () => [{ hash: 'a'.repeat(32), mtimeMs: Date.now(), dir: '/x' }],
178
+ credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => null, findProfileByHash: () => 'default' },
179
+ });
180
+ assert.deepStrictEqual(result, { switchTo: null, isUnbound: false });
181
+ console.log('PASS: detectSwitch debounce');
182
+ }
183
+
184
+ function testDetectSwitchAlreadyOnMostRecent() {
185
+ const HASH = 'a'.repeat(32);
186
+ const result = ld.detectSwitch({
187
+ prevSnapshot: {},
188
+ lastSwitchAt: 0,
189
+ seenUnboundHashes: new Set(),
190
+ listFn: () => [{ hash: HASH, mtimeMs: Date.now(), dir: '/x' }],
191
+ credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => HASH, findProfileByHash: () => 'default' },
192
+ });
193
+ assert.deepStrictEqual(result, { switchTo: null, isUnbound: false });
194
+ console.log('PASS: detectSwitch already on most-recent');
195
+ }
196
+
197
+ function testDetectSwitchNoMtimeAdvance() {
198
+ const HASH = 'a'.repeat(32);
199
+ const fixedMtime = Date.now() - 10_000;
200
+ const result = ld.detectSwitch({
201
+ prevSnapshot: { [HASH]: fixedMtime },
202
+ lastSwitchAt: 0,
203
+ seenUnboundHashes: new Set(),
204
+ listFn: () => [{ hash: HASH, mtimeMs: fixedMtime, dir: '/x' }],
205
+ credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => 'b'.repeat(32), findProfileByHash: () => 'work' },
206
+ });
207
+ assert.deepStrictEqual(result, { switchTo: null, isUnbound: false });
208
+ console.log('PASS: detectSwitch no mtime advance');
209
+ }
210
+
211
+ function testDetectSwitchValid() {
212
+ const HASH = 'a'.repeat(32);
213
+ const result = ld.detectSwitch({
214
+ prevSnapshot: { [HASH]: 1000 },
215
+ lastSwitchAt: 0,
216
+ seenUnboundHashes: new Set(),
217
+ listFn: () => [{ hash: HASH, mtimeMs: 5000, dir: '/x' }],
218
+ credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => 'b'.repeat(32), findProfileByHash: () => 'work' },
219
+ });
220
+ assert.deepStrictEqual(result, { switchTo: { hash: HASH, profile: 'work' }, isUnbound: false });
221
+ console.log('PASS: detectSwitch valid switch');
222
+ }
223
+
224
+ function testDetectSwitchUnboundEmitsOnce() {
225
+ const HASH = 'a'.repeat(32);
226
+ const seen = new Set();
227
+ const logs = [];
228
+ const log = (msg) => logs.push(msg);
229
+ const args = {
230
+ prevSnapshot: { [HASH]: 1000 },
231
+ lastSwitchAt: 0,
232
+ seenUnboundHashes: seen,
233
+ listFn: () => [{ hash: HASH, mtimeMs: Date.now(), dir: '/x' }],
234
+ credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => null, findProfileByHash: () => null },
235
+ log,
236
+ };
237
+ let r = ld.detectSwitch(args);
238
+ assert.deepStrictEqual(r, { switchTo: null, isUnbound: true, hash: HASH });
239
+ assert.strictEqual(logs.length, 1, 'first call emits hint');
240
+ assert.match(logs[0], /not bound to any MCP profile/);
241
+ assert.match(logs[0], new RegExp(`--bind-hash ${HASH}`));
242
+ r = ld.detectSwitch(args);
243
+ assert.strictEqual(logs.length, 1, 'second call deduplicated');
244
+ console.log('PASS: detectSwitch unbound emits hint once per session');
245
+ }
246
+
247
+ function testDetectSwitchUnboundStaleNoHint() {
248
+ // mtime older than UNBOUND_FRESH_WINDOW_MS → no hint emitted
249
+ const HASH = 'a'.repeat(32);
250
+ const seen = new Set();
251
+ const logs = [];
252
+ const r = ld.detectSwitch({
253
+ prevSnapshot: { [HASH]: 1000 },
254
+ lastSwitchAt: 0,
255
+ seenUnboundHashes: seen,
256
+ listFn: () => [{ hash: HASH, mtimeMs: Date.now() - 120_000, dir: '/x' }], // 2 min ago
257
+ credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => null, findProfileByHash: () => null },
258
+ log: (msg) => logs.push(msg),
259
+ });
260
+ // Stale unbound hash: still reports isUnbound=true but doesn't add to seen / doesn't log
261
+ assert.strictEqual(r.isUnbound, true);
262
+ assert.strictEqual(logs.length, 0);
263
+ assert.strictEqual(seen.size, 0);
264
+ console.log('PASS: detectSwitch unbound stale → no hint');
265
+ }
266
+
267
+ function testDetectSwitchEmptyList() {
268
+ const r = ld.detectSwitch({
269
+ prevSnapshot: {},
270
+ lastSwitchAt: 0,
271
+ seenUnboundHashes: new Set(),
272
+ listFn: () => [],
273
+ credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => null, findProfileByHash: () => null },
274
+ });
275
+ assert.deepStrictEqual(r, { switchTo: null, isUnbound: false });
276
+ console.log('PASS: detectSwitch empty list');
277
+ }
278
+
279
+ // --- Run all ---
280
+
281
+ function run() {
282
+ testListAccountHashes();
283
+ testListAccountHashesEmpty();
284
+ testListAccountHashesIgnoresMissingDb();
285
+ testMostRecentHash();
286
+ testGetSdkStorageDirSafety();
287
+ testProfileHashBindings();
288
+ testDetectSwitchDebounce();
289
+ testDetectSwitchAlreadyOnMostRecent();
290
+ testDetectSwitchNoMtimeAdvance();
291
+ testDetectSwitchValid();
292
+ testDetectSwitchUnboundEmitsOnce();
293
+ testDetectSwitchUnboundStaleNoHint();
294
+ testDetectSwitchEmptyList();
295
+ console.log('\nAll lark-desktop tests passed.');
296
+ }
297
+
298
+ if (require.main === module) {
299
+ run();
300
+ }
@@ -1,39 +0,0 @@
1
- #!/usr/bin/env node
2
- // Render docs/og.svg → docs/og.png at 1200x630.
3
- //
4
- // Idempotent. Run `node scripts/generate-og-image.js` after editing
5
- // docs/og.svg. Commit both the SVG (source) and the PNG (asset used
6
- // by social-media unfurls and `<meta property="og:image">`).
7
- //
8
- // Why PNG: Twitter / WeChat / 飞书 unfurls don't render SVG `og:image`.
9
-
10
- 'use strict';
11
-
12
- const fs = require('fs');
13
- const path = require('path');
14
- const { Resvg } = require('@resvg/resvg-js');
15
-
16
- const svgPath = path.join(__dirname, '..', 'docs', 'og.svg');
17
- const pngPath = path.join(__dirname, '..', 'docs', 'og.png');
18
-
19
- const svg = fs.readFileSync(svgPath, 'utf8');
20
-
21
- const resvg = new Resvg(svg, {
22
- fitTo: { mode: 'width', value: 1200 },
23
- // Try to use system fonts so Chinese characters render. resvg-js loads
24
- // fonts from `font.fontDirs` and `font.defaultFontFamily`. On macOS
25
- // /System/Library/Fonts has PingFang SC; on Linux CI, jekyll-seo-tag
26
- // doesn't run this script anyway — only humans do, locally.
27
- font: {
28
- fontDirs: ['/System/Library/Fonts', '/Library/Fonts', '/usr/share/fonts'],
29
- loadSystemFonts: true,
30
- defaultFontFamily: 'PingFang SC',
31
- },
32
- background: '#0d1117',
33
- });
34
-
35
- const pngBuffer = resvg.render().asPng();
36
- fs.writeFileSync(pngPath, pngBuffer);
37
-
38
- const sizeKb = (pngBuffer.length / 1024).toFixed(1);
39
- console.log(`OK: wrote ${pngPath} (${pngBuffer.length} bytes / ${sizeKb} KiB)`);