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
329 lines
17 KiB
JavaScript
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');
|
|
});
|
|
});
|