feishu-user-plugin 1.3.7 → 1.3.9
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.
- package/.claude-plugin/plugin.json +13 -3
- package/CHANGELOG.md +87 -0
- package/README.md +20 -4
- package/package.json +10 -6
- package/proto/lark.proto +10 -0
- package/scripts/capture-feishu-protobuf.js +86 -0
- package/scripts/check-changelog.js +31 -0
- package/scripts/check-docs-sync.js +41 -0
- package/scripts/check-tool-count.js +32 -7
- package/scripts/decode-feishu-protobuf.js +115 -0
- package/scripts/explore-card-protobuf.js +144 -0
- package/scripts/explore-image-minimize.js +163 -0
- package/scripts/generate-release-artifacts.js +318 -0
- package/scripts/probe-feishu-docx.js +203 -0
- package/scripts/sync-server-json.js +71 -0
- package/scripts/sync-team-skills.sh +109 -7
- package/scripts/test-wiki-attach-fallback.js +71 -0
- package/scripts/test-ws-events.js +84 -0
- package/skills/feishu-user-plugin/SKILL.md +77 -5
- package/skills/feishu-user-plugin/references/CLAUDE.md +208 -297
- package/src/auth/cookie.js +30 -0
- package/src/auth/credentials.js +85 -0
- package/src/auth/profile-router.js +248 -0
- package/src/auth/uat.js +231 -0
- package/src/cli.js +86 -42
- package/src/clients/official/base.js +12 -248
- package/src/clients/user.js +19 -31
- package/src/config.js +13 -8
- package/src/events/cursor.js +103 -0
- package/src/events/event-buffer.js +103 -0
- package/src/events/event-log.js +151 -0
- package/src/events/index.js +12 -0
- package/src/events/lockfile.js +126 -0
- package/src/events/owner.js +73 -0
- package/src/events/ws-server.js +156 -0
- package/src/oauth.js +48 -7
- package/src/resolver.js +10 -0
- package/src/server.js +285 -3
- package/src/setup.js +100 -11
- package/src/test-all.js +12 -9
- package/src/test-events-cursor.js +56 -0
- package/src/test-events-lockfile.js +36 -0
- package/src/test-events-log.js +67 -0
- package/src/test-events-owner.js +64 -0
- package/src/test-fixtures/doc-blocks/sample-1.json +1256 -0
- package/src/test-read-doc-markdown.js +61 -0
- package/src/test-switch-profile.js +171 -0
- package/src/tools/_registry.js +1 -0
- package/src/tools/diagnostics.js +10 -3
- package/src/tools/docs.js +93 -3
- package/src/tools/events.js +174 -0
- package/src/tools/messaging-bot.js +2 -3
- package/src/tools/messaging-user.js +23 -14
- package/src/tools/profile.js +43 -7
package/src/setup.js
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const readline = require('readline');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
13
16
|
const { findMcpConfig, writeNewConfig } = require('./config');
|
|
14
17
|
|
|
15
|
-
// Parse CLI args: --app-id, --app-secret, --cookie, --client
|
|
18
|
+
// Parse CLI args: --app-id, --app-secret, --cookie, --client, --force, --profile
|
|
16
19
|
function parseArgs() {
|
|
17
20
|
const args = {};
|
|
18
21
|
const argv = process.argv.slice(2);
|
|
@@ -21,6 +24,10 @@ function parseArgs() {
|
|
|
21
24
|
else if (argv[i] === '--app-secret' && argv[i + 1]) args.appSecret = argv[++i];
|
|
22
25
|
else if (argv[i] === '--cookie' && argv[i + 1]) args.cookie = argv[++i];
|
|
23
26
|
else if (argv[i] === '--client' && argv[i + 1]) args.client = argv[++i];
|
|
27
|
+
else if (argv[i] === '--pointer-only') args.pointerOnly = true; // kept for backward compat; now implicit default
|
|
28
|
+
else if (argv[i] === '--force') args.force = true;
|
|
29
|
+
else if (argv[i] === '--profile' && argv[i + 1]) args.profile = argv[++i];
|
|
30
|
+
else if (argv[i] === '--activate') args.activate = true;
|
|
24
31
|
}
|
|
25
32
|
return args;
|
|
26
33
|
}
|
|
@@ -150,20 +157,102 @@ async function main() {
|
|
|
150
157
|
}
|
|
151
158
|
if (!client) client = 'claude';
|
|
152
159
|
|
|
153
|
-
//
|
|
154
|
-
|
|
160
|
+
// --- 4-state SSOT matrix (v1.3.9 A.3) ---
|
|
161
|
+
// Determines how credentials.json is created/updated based on current state.
|
|
162
|
+
//
|
|
163
|
+
// State 1 (fresh): credentials.json absent, no harness LARK_* env → create fresh
|
|
164
|
+
// State 2 (auto-migrate): credentials.json absent, harness LARK_* env exists → migrate
|
|
165
|
+
// State 3 (preserve): credentials.json present, no --app-id → touch nothing in file
|
|
166
|
+
// State 4 (update): credentials.json present, --app-id given → update/add profile
|
|
167
|
+
const credentials = require('./auth/credentials');
|
|
168
|
+
const credsPath = path.join(os.homedir(), '.feishu-user-plugin', 'credentials.json');
|
|
169
|
+
const credsExist = !!credentials.readCanonical();
|
|
170
|
+
const targetProfile = cliArgs.profile || 'default';
|
|
171
|
+
const harnessHasLark = !!(existingEnv.LARK_APP_ID || existingEnv.LARK_COOKIE || existingEnv.LARK_USER_ACCESS_TOKEN);
|
|
172
|
+
|
|
173
|
+
let mode;
|
|
174
|
+
if (!credsExist && !harnessHasLark) mode = 'fresh';
|
|
175
|
+
else if (!credsExist && harnessHasLark) mode = 'auto-migrate';
|
|
176
|
+
else if (credsExist && !cliArgs.appId) mode = 'preserve';
|
|
177
|
+
else mode = 'update';
|
|
178
|
+
console.log(`\nSetup mode: ${mode}`);
|
|
155
179
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
180
|
+
if (mode === 'fresh' || mode === 'update') {
|
|
181
|
+
// Gather profile values — only include keys that have real values.
|
|
182
|
+
const profileValues = {};
|
|
183
|
+
if (appId) profileValues.LARK_APP_ID = appId;
|
|
184
|
+
if (appSecret) profileValues.LARK_APP_SECRET = appSecret;
|
|
185
|
+
if (cookie && cookie !== 'SETUP_NEEDED') profileValues.LARK_COOKIE = cookie;
|
|
186
|
+
else if (existingEnv.LARK_COOKIE && existingEnv.LARK_COOKIE !== 'SETUP_NEEDED') profileValues.LARK_COOKIE = existingEnv.LARK_COOKIE;
|
|
187
|
+
if (existingEnv.LARK_USER_ACCESS_TOKEN && existingEnv.LARK_USER_ACCESS_TOKEN !== 'SETUP_NEEDED') profileValues.LARK_USER_ACCESS_TOKEN = existingEnv.LARK_USER_ACCESS_TOKEN;
|
|
188
|
+
if (existingEnv.LARK_USER_REFRESH_TOKEN) profileValues.LARK_USER_REFRESH_TOKEN = existingEnv.LARK_USER_REFRESH_TOKEN;
|
|
163
189
|
|
|
164
|
-
|
|
190
|
+
if (mode === 'fresh') {
|
|
191
|
+
fs.mkdirSync(path.dirname(credsPath), { recursive: true, mode: 0o700 });
|
|
192
|
+
const data = { version: 1, active: targetProfile, profiles: { [targetProfile]: profileValues }, profileHints: {} };
|
|
193
|
+
fs.writeFileSync(credsPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
194
|
+
console.log(`Created ${credsPath} with profile "${targetProfile}"`);
|
|
195
|
+
} else {
|
|
196
|
+
// mode === 'update'
|
|
197
|
+
const canonical = credentials.readCanonical();
|
|
198
|
+
const profileExists = !!(canonical && canonical.profiles[targetProfile]);
|
|
199
|
+
if (profileExists && targetProfile === 'default' && !cliArgs.force && !cliArgs.profile) {
|
|
200
|
+
console.error(`Profile "default" already exists. Pass --force to overwrite, or --profile <name> to create a new profile.`);
|
|
201
|
+
rl.close();
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
if (profileExists && cliArgs.profile && !cliArgs.force) {
|
|
205
|
+
console.error(`Profile "${targetProfile}" already exists. Pass --force to overwrite, or pick a different --profile name.`);
|
|
206
|
+
rl.close();
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
canonical.profiles[targetProfile] = { ...(canonical.profiles[targetProfile] || {}), ...profileValues };
|
|
210
|
+
// v1.3.9 fix: only flip credentials.json::active when --activate is given.
|
|
211
|
+
// Without --activate, adding/updating a non-active profile leaves the
|
|
212
|
+
// current active alone (least-surprise: "I added work2, default is still
|
|
213
|
+
// active, I'll switch when I want via MCP switch_profile").
|
|
214
|
+
if (cliArgs.activate || (cliArgs.force && targetProfile === canonical.active)) {
|
|
215
|
+
canonical.active = targetProfile;
|
|
216
|
+
}
|
|
217
|
+
fs.writeFileSync(credsPath, JSON.stringify(canonical, null, 2) + '\n', { mode: 0o600 });
|
|
218
|
+
console.log(`Updated profile "${targetProfile}" in ${credsPath}`);
|
|
219
|
+
if (cliArgs.activate) console.log(` → active profile flipped to "${targetProfile}"`);
|
|
220
|
+
else if (canonical.active !== targetProfile) {
|
|
221
|
+
console.log(` → active profile unchanged ("${canonical.active}"). Pass --activate to flip, or use switch_profile MCP tool at runtime.`);
|
|
222
|
+
}
|
|
223
|
+
if (cliArgs.force) console.warn(` warning: overwrote existing profile credentials with --force`);
|
|
224
|
+
}
|
|
225
|
+
} else if (mode === 'auto-migrate') {
|
|
226
|
+
// Run migrate to consolidate harness env → credentials.json, then optionally
|
|
227
|
+
// override the default profile with any explicitly provided --app-id.
|
|
228
|
+
const result = credentials.migrate({ dryRun: false });
|
|
229
|
+
if (!result.ok) {
|
|
230
|
+
console.error('Auto-migrate failed; aborting setup.');
|
|
231
|
+
rl.close();
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
if (cliArgs.appId) {
|
|
235
|
+
credentials.persistProfileUpdate('default', { LARK_APP_ID: appId, LARK_APP_SECRET: appSecret });
|
|
236
|
+
console.log('Updated default profile with new app credentials.');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// mode === 'preserve': credentials.json is unchanged; we only update the harness pointer.
|
|
240
|
+
|
|
241
|
+
// --- Write harness config ---
|
|
242
|
+
// Always write pointer-only env to harness configs (v1.3.9 SSOT).
|
|
243
|
+
// The harness env block only needs FEISHU_PLUGIN_PROFILE; all real creds
|
|
244
|
+
// live in credentials.json.
|
|
245
|
+
console.log('\n--- Writing Config ---');
|
|
246
|
+
// v1.3.9 fix: harness env pointer should reflect what credentials.json::active
|
|
247
|
+
// will end up as, not blindly the targetProfile (which would mislead users
|
|
248
|
+
// who added a non-active profile via --profile alt without --activate).
|
|
249
|
+
const finalCanonical = credentials.readCanonical();
|
|
250
|
+
const harnessActive = finalCanonical?.active || targetProfile;
|
|
251
|
+
const pointerEnv = { FEISHU_PLUGIN_PROFILE: harnessActive };
|
|
252
|
+
const result = writeNewConfig(pointerEnv, undefined, undefined, client, { pointerOnly: true });
|
|
165
253
|
if (result.configPath) console.log(`Written to ${result.configPath} (Claude Code)`);
|
|
166
254
|
if (result.codexConfigPath) console.log(`Written to ${result.codexConfigPath} (Codex)`);
|
|
255
|
+
console.log(`Mode: pointer-only (env block contains only FEISHU_PLUGIN_PROFILE=${targetProfile})`);
|
|
167
256
|
|
|
168
257
|
// Summary
|
|
169
258
|
console.log('\n' + '='.repeat(60));
|
package/src/test-all.js
CHANGED
|
@@ -266,14 +266,6 @@ async function main() {
|
|
|
266
266
|
// 31. create_folder (skip)
|
|
267
267
|
log('create_folder', 'SKIP', 'skipped to avoid creating unnecessary folders');
|
|
268
268
|
|
|
269
|
-
// 32. find_user
|
|
270
|
-
try {
|
|
271
|
-
const res = await officialClient.findUserByIdentity({ emails: 'test@test.com' });
|
|
272
|
-
log('find_user', 'PASS', `returned ${(res.userList || []).length} users (expected 0 for test email)`);
|
|
273
|
-
} catch (e) {
|
|
274
|
-
log('find_user', 'FAIL', e.message);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
269
|
// ========== UAT Tests ==========
|
|
278
270
|
|
|
279
271
|
if (officialClient.hasUAT) {
|
|
@@ -321,4 +313,15 @@ async function main() {
|
|
|
321
313
|
}
|
|
322
314
|
}
|
|
323
315
|
|
|
324
|
-
main().catch(console.error)
|
|
316
|
+
main().catch(console.error).finally(() => {
|
|
317
|
+
// Fixture-based unit test — runs regardless of credential availability
|
|
318
|
+
require('./test-read-doc-markdown').run();
|
|
319
|
+
require('./test-switch-profile').run().catch(e => {
|
|
320
|
+
console.error('switch-profile-e2e: FAIL', e);
|
|
321
|
+
process.exitCode = 1;
|
|
322
|
+
});
|
|
323
|
+
require('./test-events-lockfile').run();
|
|
324
|
+
require('./test-events-log').run();
|
|
325
|
+
require('./test-events-cursor').run();
|
|
326
|
+
require('./test-events-owner').run();
|
|
327
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// src/test-events-cursor.js
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
const { ensureLog, appendEvent } = require('./events/event-log');
|
|
7
|
+
const { drain, readSnapshot, resetCursorTo } = require('./events/cursor');
|
|
8
|
+
|
|
9
|
+
function run() {
|
|
10
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fish-cur-'));
|
|
11
|
+
const logPath = path.join(dir, 'events.jsonl');
|
|
12
|
+
ensureLog(logPath);
|
|
13
|
+
|
|
14
|
+
// No events → empty drain.
|
|
15
|
+
let r = drain(dir);
|
|
16
|
+
assert.equal(r.events.length, 0);
|
|
17
|
+
|
|
18
|
+
// Append 3 events, drain reads them.
|
|
19
|
+
appendEvent(logPath, { event_id: 'a', ts: 1, profile: 'default', payload: {} });
|
|
20
|
+
appendEvent(logPath, { event_id: 'b', ts: 2, profile: 'default', payload: {} });
|
|
21
|
+
appendEvent(logPath, { event_id: 'c', ts: 3, profile: 'default', payload: {} });
|
|
22
|
+
|
|
23
|
+
r = drain(dir);
|
|
24
|
+
assert.equal(r.events.length, 3);
|
|
25
|
+
assert.equal(r.advanced, true);
|
|
26
|
+
|
|
27
|
+
// Second drain returns nothing (cursor advanced).
|
|
28
|
+
r = drain(dir);
|
|
29
|
+
assert.equal(r.events.length, 0);
|
|
30
|
+
|
|
31
|
+
// Peek doesn't advance.
|
|
32
|
+
appendEvent(logPath, { event_id: 'd', ts: 4, profile: 'default', payload: {} });
|
|
33
|
+
r = drain(dir, { peek: true });
|
|
34
|
+
assert.equal(r.events.length, 1);
|
|
35
|
+
assert.equal(r.advanced, false);
|
|
36
|
+
// Real drain afterward still returns the same event.
|
|
37
|
+
r = drain(dir);
|
|
38
|
+
assert.equal(r.events.length, 1);
|
|
39
|
+
assert.equal(r.events[0].event_id, 'd');
|
|
40
|
+
|
|
41
|
+
// Snapshot.
|
|
42
|
+
const snap = readSnapshot(dir);
|
|
43
|
+
assert.equal(snap.pending, 0);
|
|
44
|
+
assert.ok(snap.cursor.offset > 0);
|
|
45
|
+
|
|
46
|
+
// Reset cursor to 0 → next drain returns all 4.
|
|
47
|
+
resetCursorTo(dir, 0);
|
|
48
|
+
r = drain(dir);
|
|
49
|
+
assert.equal(r.events.length, 4);
|
|
50
|
+
|
|
51
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
console.log('cursor.js: PASS');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (require.main === module) run();
|
|
56
|
+
module.exports = { run };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/test-events-lockfile.js (new file; require it from src/test-all.js)
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
const { acquireLongLived, withMutex } = require('./events/lockfile');
|
|
7
|
+
|
|
8
|
+
function run() {
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fish-lock-'));
|
|
10
|
+
const lockPath = path.join(dir, 'test.lock');
|
|
11
|
+
|
|
12
|
+
// Long-lived: first acquire works, second returns null.
|
|
13
|
+
const handle1 = acquireLongLived(lockPath, { info: { test: 1 } });
|
|
14
|
+
assert.ok(handle1, 'first acquire should succeed');
|
|
15
|
+
const handle2 = acquireLongLived(lockPath, { info: { test: 2 } });
|
|
16
|
+
assert.equal(handle2, null, 'second acquire should fail (lock held)');
|
|
17
|
+
handle1.release();
|
|
18
|
+
|
|
19
|
+
// After release, can acquire again.
|
|
20
|
+
const handle3 = acquireLongLived(lockPath, { info: { test: 3 } });
|
|
21
|
+
assert.ok(handle3, 'third acquire after release should succeed');
|
|
22
|
+
handle3.release();
|
|
23
|
+
|
|
24
|
+
// withMutex serializes.
|
|
25
|
+
const mutexPath = path.join(dir, 'mutex.lock');
|
|
26
|
+
let counter = 0;
|
|
27
|
+
withMutex(mutexPath, () => { counter += 1; });
|
|
28
|
+
withMutex(mutexPath, () => { counter += 1; });
|
|
29
|
+
assert.equal(counter, 2);
|
|
30
|
+
|
|
31
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
32
|
+
console.log('lockfile.js: PASS');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (require.main === module) run();
|
|
36
|
+
module.exports = { run };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/test-events-log.js
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
const { ensureLog, appendEvent, readFrom, repairTail, maybeRotate, forceRotate } = require('./events/event-log');
|
|
7
|
+
|
|
8
|
+
function run() {
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fish-log-'));
|
|
10
|
+
const logPath = path.join(dir, 'events.jsonl');
|
|
11
|
+
|
|
12
|
+
// Append + read
|
|
13
|
+
ensureLog(logPath);
|
|
14
|
+
appendEvent(logPath, { event_id: 'a', ts: 1, profile: 'default', payload: {} });
|
|
15
|
+
appendEvent(logPath, { event_id: 'b', ts: 2, profile: 'default', payload: {} });
|
|
16
|
+
|
|
17
|
+
let r = readFrom(logPath, 0);
|
|
18
|
+
assert.equal(r.events.length, 2);
|
|
19
|
+
assert.equal(r.events[0].event_id, 'a');
|
|
20
|
+
assert.equal(r.events[1].event_id, 'b');
|
|
21
|
+
|
|
22
|
+
// Read with offset
|
|
23
|
+
r = readFrom(logPath, r.events.length === 2 ? r.nextOffset : 0);
|
|
24
|
+
assert.equal(r.events.length, 0);
|
|
25
|
+
|
|
26
|
+
// Partial-line tolerance: append broken line
|
|
27
|
+
fs.appendFileSync(logPath, '{"event_id":"c","ts":3,"profile":"default","payload":{}}');
|
|
28
|
+
r = readFrom(logPath, 0);
|
|
29
|
+
assert.equal(r.events.length, 2, 'partial last line should not be consumed');
|
|
30
|
+
|
|
31
|
+
// Repair tail
|
|
32
|
+
const beforeSize = fs.statSync(logPath).size;
|
|
33
|
+
const repair = repairTail(logPath);
|
|
34
|
+
assert.equal(repair.repaired, true, 'repair should run');
|
|
35
|
+
assert.ok(repair.sizeAfter < beforeSize, 'truncation occurred');
|
|
36
|
+
|
|
37
|
+
// After repair, can append normally
|
|
38
|
+
appendEvent(logPath, { event_id: 'd', ts: 4, profile: 'default', payload: {} });
|
|
39
|
+
r = readFrom(logPath, 0);
|
|
40
|
+
assert.equal(r.events.length, 3, 'after repair + append, 3 full events');
|
|
41
|
+
assert.equal(r.events[2].event_id, 'd');
|
|
42
|
+
|
|
43
|
+
// Defer-rotate: size below threshold → no rotation
|
|
44
|
+
let rot = maybeRotate(logPath, r.nextOffset, 1024 * 1024);
|
|
45
|
+
assert.equal(rot.rotated, false);
|
|
46
|
+
|
|
47
|
+
// Defer-rotate: size above threshold + cursor caught up → rotation
|
|
48
|
+
// (we'll use a tiny threshold)
|
|
49
|
+
rot = maybeRotate(logPath, r.nextOffset, 1);
|
|
50
|
+
assert.equal(rot.rotated, true);
|
|
51
|
+
assert.ok(fs.existsSync(rot.droppedPath));
|
|
52
|
+
assert.equal(fs.statSync(logPath).size, 0, 'new log empty after rotate');
|
|
53
|
+
|
|
54
|
+
// Force rotate: even with cursor 0 (behind), drops + writes _rotated event
|
|
55
|
+
appendEvent(logPath, { event_id: 'e', ts: 5, profile: 'default', payload: {} });
|
|
56
|
+
forceRotate(logPath, fs.statSync(logPath).size);
|
|
57
|
+
r = readFrom(logPath, 0);
|
|
58
|
+
assert.equal(r.events.length, 1);
|
|
59
|
+
assert.equal(r.events[0].event_id, '_rotated');
|
|
60
|
+
assert.equal(r.events[0].profile, '_system');
|
|
61
|
+
|
|
62
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
63
|
+
console.log('event-log.js: PASS');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (require.main === module) run();
|
|
67
|
+
module.exports = { run };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// src/test-events-owner.js
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
const { tryClaim, readOwnerInfo, STALE_MS } = require('./events/owner');
|
|
7
|
+
|
|
8
|
+
function run() {
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fish-own-'));
|
|
10
|
+
|
|
11
|
+
// Initial claim succeeds.
|
|
12
|
+
const h1 = tryClaim(dir, { info: { wsProfile: 'default' } });
|
|
13
|
+
assert.equal(h1.isOwner, true);
|
|
14
|
+
|
|
15
|
+
// Second claim fails (lock held).
|
|
16
|
+
const h2 = tryClaim(dir);
|
|
17
|
+
assert.equal(h2.isOwner, false);
|
|
18
|
+
assert.equal(h2.ownerInfo.pid, process.pid);
|
|
19
|
+
|
|
20
|
+
// readOwnerInfo reflects state.
|
|
21
|
+
const info = readOwnerInfo(dir);
|
|
22
|
+
assert.equal(info.exists, true);
|
|
23
|
+
assert.equal(info.alive, true);
|
|
24
|
+
assert.equal(info.pid, process.pid);
|
|
25
|
+
|
|
26
|
+
// Heartbeat updates mtime.
|
|
27
|
+
const beforeMtime = info.mtimeMs;
|
|
28
|
+
// Force a slight delay then heartbeat
|
|
29
|
+
const target = Date.now();
|
|
30
|
+
while (Date.now() - target < 50) {} // small busy wait
|
|
31
|
+
const ok = h1.heartbeat();
|
|
32
|
+
assert.equal(ok, true);
|
|
33
|
+
const afterMtime = readOwnerInfo(dir).mtimeMs;
|
|
34
|
+
assert.ok(afterMtime >= beforeMtime);
|
|
35
|
+
|
|
36
|
+
h1.release();
|
|
37
|
+
|
|
38
|
+
// After release, readOwnerInfo says no owner.
|
|
39
|
+
const info2 = readOwnerInfo(dir);
|
|
40
|
+
assert.equal(info2.exists, false);
|
|
41
|
+
|
|
42
|
+
// After release, can re-claim.
|
|
43
|
+
const h3 = tryClaim(dir);
|
|
44
|
+
assert.equal(h3.isOwner, true);
|
|
45
|
+
h3.release();
|
|
46
|
+
|
|
47
|
+
// Force claim over an existing lock.
|
|
48
|
+
const h4 = tryClaim(dir, { info: { wsProfile: 'A' } });
|
|
49
|
+
const h5 = tryClaim(dir, { info: { wsProfile: 'B' }, force: true });
|
|
50
|
+
assert.equal(h5.isOwner, true);
|
|
51
|
+
// h4's heartbeat should now fail because its lock file got stolen.
|
|
52
|
+
// (Best-effort — heartbeat returns false if file isn't there to utimes.)
|
|
53
|
+
// We don't assert this strictly because the file's still around (we renamed
|
|
54
|
+
// it, didn't delete). Instead just verify h5 holds the new lock.
|
|
55
|
+
const info3 = readOwnerInfo(dir);
|
|
56
|
+
assert.equal(info3.exists, true);
|
|
57
|
+
|
|
58
|
+
h5.release();
|
|
59
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
60
|
+
console.log('owner.js: PASS');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (require.main === module) run();
|
|
64
|
+
module.exports = { run };
|