Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

329 lines
17 KiB
JavaScript

import { describe, expect, it, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import './connect.js';
const {
normalizeName,
matchInvitationName,
canonicalizeLinkedInProfileUrl,
canonicalizeLinkedInInviteUrl,
unwrapEvaluateResult,
clampNote,
assessProfileSafety,
buildProfileProbeScript,
} = await import('./connect.js').then((m) => m.__test__);
function makeFakePage(probe, sendResult = { ok: true, status: 'sent', reason: 'connection_request_sent' }) {
return {
goto: vi.fn(async () => undefined),
wait: vi.fn(async () => undefined),
evaluate: vi.fn(async (script) => {
const text = String(script);
if (text.includes('custom-message') || text.includes('invite_dialog_not_found')) return sendResult;
return probe;
}),
};
}
function makeSequentialFakePage(values) {
let index = 0;
return {
goto: vi.fn(async () => undefined),
wait: vi.fn(async () => undefined),
evaluate: vi.fn(async (script) => {
const text = String(script);
if (text.includes('custom-message') || text.includes('invite_dialog_not_found')) return { ok: true, status: 'sent', reason: 'connection_request_sent' };
const value = values[Math.min(index, values.length - 1)];
index += 1;
return value;
}),
};
}
// Like makeSequentialFakePage, but also answers the in-page "open connect dialog" script
// (button/More-menu path) with a fixed result, without consuming a sequence slot. The
// sequence therefore only feeds the profile probe and the sent-invitations probe(s).
function makeMoreMenuFakePage(values, openResult = { ok: true, opened: 'dialog' }) {
let index = 0;
return {
goto: vi.fn(async () => undefined),
wait: vi.fn(async () => undefined),
evaluate: vi.fn(async (script) => {
const text = String(script);
if (text.includes('opened:')) return openResult;
if (text.includes('custom-message') || text.includes('invite_dialog_not_found')) return { ok: true, status: 'sent', reason: 'invitation_sent_without_note' };
const value = values[Math.min(index, values.length - 1)];
index += 1;
return value;
}),
};
}
function evaluateProfileProbe(html, expectedName = 'Jane Doe') {
const dom = new JSDOM(html, {
url: 'https://www.linkedin.com/in/jane/',
runScripts: 'outside-only',
});
Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetParent', {
configurable: true,
get() {
return this.dataset.hidden === 'true' ? null : dom.window.document.body;
},
});
return dom.window.eval(buildProfileProbeScript(expectedName));
}
describe('linkedin connect helpers', () => {
it('normalizes names and profile URLs', () => {
expect(normalizeName('Jane Doe • 2nd degree connection')).toBe('jane doe');
expect(matchInvitationName('Jane Doe, P.Eng.', ' jane doe ')).toBe(true);
expect(matchInvitationName('Jane Q. Doe', 'Jane Doe')).toBe(true);
expect(matchInvitationName('Janet Doe', 'Jane Doe')).toBe(false);
expect(canonicalizeLinkedInProfileUrl('https://www.linkedin.com/in/jane/?mini=true#x'))
.toBe('https://www.linkedin.com/in/jane/');
expect(canonicalizeLinkedInProfileUrl('https://ca.linkedin.com/in/jane/?mini=true#x'))
.toBe('https://www.linkedin.com/in/jane/');
expect(canonicalizeLinkedInProfileUrl('https://www.linkedin.com/company/opencli/')).toBe('');
expect(canonicalizeLinkedInProfileUrl('https://evil-linkedin.com/in/jane/')).toBe('');
expect(canonicalizeLinkedInProfileUrl('http://www.linkedin.com/in/jane/')).toBe('');
});
it('only accepts LinkedIn invitation route hrefs for sending', () => {
expect(canonicalizeLinkedInInviteUrl('/preload/custom-invite/?vanityName=jane'))
.toBe('https://www.linkedin.com/preload/custom-invite/?vanityName=jane');
expect(canonicalizeLinkedInInviteUrl('https://www.linkedin.com/feed/')).toBe('');
expect(canonicalizeLinkedInInviteUrl('https://evil-linkedin.com/preload/custom-invite/?vanityName=jane')).toBe('');
});
it('unwraps browser bridge evaluate envelopes', () => {
expect(unwrapEvaluateResult({ session: 'site:linkedin:1', data: { ok: true } })).toEqual({ ok: true });
const raw = { ok: true };
expect(unwrapEvaluateResult(raw)).toBe(raw);
});
it('enforces LinkedIn note length', () => {
expect(clampNote(' hello\nthere ')).toBe('hello there');
expect(() => clampNote('x'.repeat(301))).toThrow('--note must be 300 characters or fewer');
});
it('fails closed on wrong profile name, pending state, or missing connect button', () => {
expect(assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: true }, 'Janet Doe', 'https://www.linkedin.com/in/jane/').blockReason)
.toBe('profile_name_mismatch');
expect(assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', pending: true, connectAvailable: true }, 'Jane Doe', 'https://www.linkedin.com/in/jane/').blockReason)
.toBe('connection_pending');
expect(assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/' }, 'Jane Doe', 'https://www.linkedin.com/in/jane/').blockReason)
.toBe('connect_button_not_found');
});
it('classifies routine non-connectable profiles separately from unsafe blocks', () => {
expect(assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', alreadyConnected: true }, 'Jane Doe', 'https://www.linkedin.com/in/jane/'))
.toMatchObject({ ok: false, safety: 'routine_non_connectable', connectable: false, blockReason: 'already_connected' });
expect(assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', pending: true }, 'Jane Doe', 'https://www.linkedin.com/in/jane/'))
.toMatchObject({ ok: false, safety: 'routine_non_connectable', connectable: false, blockReason: 'connection_pending' });
expect(assessProfileSafety({ name: 'Wrong Person', url: 'https://www.linkedin.com/in/wrong/', connectAvailable: true }, 'Jane Doe', 'https://www.linkedin.com/in/jane/'))
.toMatchObject({ ok: false, safety: 'unsafe_block', connectable: null, blockReason: 'profile_name_mismatch' });
});
it('passes only when profile url, name, and connect affordance all match', () => {
const result = assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/?mini=true', connectAvailable: true }, 'Jane Doe', 'https://www.linkedin.com/in/jane/');
expect(result).toMatchObject({ ok: true, blockReason: 'verified', actualValue: 'Jane Doe', connectable: true });
});
it('treats a present "More" menu as connectable when there is no top-level Connect button', () => {
// Follow-primary profiles hide Connect inside the "More" actions menu. The probe
// reports moreAvailable; the send step opens the menu and confirms (or fails closed).
const result = assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: false, moreAvailable: true }, 'Jane Doe', 'https://www.linkedin.com/in/jane/');
expect(result).toMatchObject({ ok: true, blockReason: 'verified_via_more', connectable: true });
});
it('still blocks when neither a Connect button nor a More menu is present', () => {
const result = assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: false, moreAvailable: false }, 'Jane Doe', 'https://www.linkedin.com/in/jane/');
expect(result.blockReason).toBe('connect_button_not_found');
});
it('scopes profile action probing to the profile owner instead of sidebar cards', () => {
const probe = evaluateProfileProbe(`
<main>
<section class="pv-top-card">
<h1>Jane Doe</h1>
<div class="pvs-profile-actions">
<button aria-label="Follow Jane Doe">Follow</button>
<button aria-label="More actions">More</button>
</div>
</section>
<aside>
<a href="/preload/custom-invite/?vanityName=other">Connect</a>
<button>More</button>
</aside>
</main>
`);
expect(probe.name).toBe('Jane Doe');
expect(probe.moreAvailable).toBe(true);
expect(probe.connectAvailable).toBe(false);
expect(probe.connectHref).toBe('');
});
it('does not treat sidebar Connect as profile-owner connectable when the owner has no action menu', () => {
const probe = evaluateProfileProbe(`
<main>
<section class="pv-top-card">
<h1>Jane Doe</h1>
</section>
<aside>
<a href="/preload/custom-invite/?vanityName=other">Connect</a>
<button>More</button>
</aside>
</main>
`);
expect(probe.connectAvailable).toBe(false);
expect(probe.moreAvailable).toBe(false);
expect(probe.connectHref).toBe('');
});
});
describe('linkedin connect command', () => {
it('registers as a write command and dry-runs by default', async () => {
const command = getRegistry().get('linkedin/connect');
expect(command).toBeDefined();
expect(command.access).toBe('write');
const page = makeFakePage({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: true, connectHref: '/preload/custom-invite/?vanityName=jane', buttonLabels: ['Connect'] });
const rows = await command.func(page, {
'profile-url': 'https://www.linkedin.com/in/jane/',
'expected-name': 'Jane Doe',
note: 'quick note',
});
expect(rows[0]).toMatchObject({ status: 'connectable_dry_run', recipient: 'Jane Doe', reason: 'verified', connectable: true });
expect(page.evaluate).toHaveBeenCalledTimes(1);
});
it('returns a clean not_connectable dry-run row for routine blocked states', async () => {
const command = getRegistry().get('linkedin/connect');
const page = makeFakePage({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', alreadyConnected: true, buttonLabels: ['Message'] });
const rows = await command.func(page, {
'profile-url': 'https://www.linkedin.com/in/jane/',
'expected-name': 'Jane Doe',
note: 'quick note',
});
expect(rows[0]).toMatchObject({ status: 'not_connectable', recipient: 'Jane Doe', reason: 'already_connected', connectable: false });
});
it('does not send when recipient verification fails', async () => {
const command = getRegistry().get('linkedin/connect');
const page = makeFakePage({ name: 'Wrong Person', url: 'https://www.linkedin.com/in/wrong/', connectAvailable: true, buttonLabels: ['Connect'] });
await expect(command.func(page, {
'profile-url': 'https://www.linkedin.com/in/jane/',
'expected-name': 'Jane Doe',
note: 'quick note',
send: true,
})).rejects.toBeInstanceOf(CommandExecutionError);
expect(page.evaluate).toHaveBeenCalledTimes(1);
});
it('rejects non-profile URLs before navigating', async () => {
const command = getRegistry().get('linkedin/connect');
const page = makeFakePage({});
await expect(command.func(page, {
'profile-url': 'https://www.linkedin.com/company/opencli/',
'expected-name': 'Jane Doe',
send: true,
})).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('blocks send when the connect link is not LinkedIn invitation route', async () => {
const command = getRegistry().get('linkedin/connect');
const page = makeFakePage({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: true, connectHref: 'https://www.linkedin.com/feed/', buttonLabels: ['Connect'] });
await expect(command.func(page, {
'profile-url': 'https://www.linkedin.com/in/jane/',
'expected-name': 'Jane Doe',
send: true,
})).rejects.toThrow('invalid_connect_link');
});
it('sends only when --send is true after verification and sent-invitations confirms delivery', async () => {
const command = getRegistry().get('linkedin/connect');
const page = makeSequentialFakePage([
{ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: true, connectHref: '/preload/custom-invite/?vanityName=jane', buttonLabels: ['Connect'] },
{ found: true, matchedName: 'Jane Doe', matchedUrl: 'https://www.linkedin.com/in/jane/' },
]);
const rows = await command.func(page, {
'profile-url': 'https://www.linkedin.com/in/jane/',
'expected-name': 'Jane Doe',
note: 'quick note',
send: true,
});
expect(rows[0]).toMatchObject({ status: 'sent_verified', recipient: 'Jane Doe', reason: 'sent_invitation_verified', delivery_verified: true });
expect(page.goto).toHaveBeenCalledWith('https://www.linkedin.com/mynetwork/invitation-manager/sent/');
});
it('retries sent-invitations verification before reporting unverified', async () => {
const command = getRegistry().get('linkedin/connect');
const page = makeSequentialFakePage([
{ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: true, connectHref: '/preload/custom-invite/?vanityName=jane', buttonLabels: ['Connect'] },
{ found: false, matchedName: '', matchedUrl: '', visibleNames: ['Other Person'] },
{ found: false, matchedName: '', matchedUrl: '', visibleNames: ['Other Person'] },
{ found: true, matchedName: 'Jane Doe, P.Eng.', matchedUrl: '' },
]);
const rows = await command.func(page, {
'profile-url': 'https://www.linkedin.com/in/jane/',
'expected-name': 'Jane Doe',
note: 'quick note',
send: true,
});
expect(rows[0]).toMatchObject({ status: 'sent_verified', recipient: 'Jane Doe', reason: 'sent_invitation_verified', delivery_verified: true, matched_invitation_name: 'Jane Doe, P.Eng.' });
expect(page.goto).toHaveBeenCalledWith('https://www.linkedin.com/mynetwork/invitation-manager/sent/');
expect(page.evaluate).toHaveBeenCalledTimes(5);
});
it('does not report sent when sent-invitations verification fails after retries', async () => {
const command = getRegistry().get('linkedin/connect');
const page = makeSequentialFakePage([
{ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: true, connectHref: '/preload/custom-invite/?vanityName=jane', buttonLabels: ['Connect'] },
{ found: false, matchedName: '', matchedUrl: '' },
{ found: false, matchedName: '', matchedUrl: '' },
{ found: false, matchedName: '', matchedUrl: '' },
]);
const rows = await command.func(page, {
'profile-url': 'https://www.linkedin.com/in/jane/',
'expected-name': 'Jane Doe',
note: 'quick note',
send: true,
});
expect(rows[0]).toMatchObject({ status: 'send_unverified', recipient: 'Jane Doe', reason: 'sent_invitation_not_found_after_retries', delivery_verified: false });
expect(page.evaluate).toHaveBeenCalledTimes(5);
});
it('opens the invite dialog in-page when Connect has no anchor (button/More-menu path) and verifies delivery', async () => {
const command = getRegistry().get('linkedin/connect');
// Follow-primary profile: no connectHref anchor, but a "More" menu is present.
const page = makeMoreMenuFakePage([
{ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: false, moreAvailable: true, connectHref: '', buttonLabels: ['Follow', 'Message', 'More'] },
{ found: true, matchedName: 'Jane Doe', matchedUrl: 'https://www.linkedin.com/in/jane/' },
]);
const rows = await command.func(page, {
'profile-url': 'https://www.linkedin.com/in/jane/',
'expected-name': 'Jane Doe',
send: true,
});
expect(rows[0]).toMatchObject({ status: 'sent_verified', recipient: 'Jane Doe', reason: 'sent_invitation_verified', delivery_verified: true });
expect(page.goto).toHaveBeenCalledWith('https://www.linkedin.com/mynetwork/invitation-manager/sent/');
});
it('fails closed when the owner Connect control cannot be opened from the More menu', async () => {
const command = getRegistry().get('linkedin/connect');
const page = makeMoreMenuFakePage(
[{ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: false, moreAvailable: true, connectHref: '', buttonLabels: ['Follow', 'Message', 'More'] }],
{ ok: false, reason: 'owner_action_bar_not_found' },
);
await expect(command.func(page, {
'profile-url': 'https://www.linkedin.com/in/jane/',
'expected-name': 'Jane Doe',
send: true,
})).rejects.toThrow('owner_action_bar_not_found');
});
});