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(`

Jane Doe

`); 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(`

Jane Doe

`); 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'); }); });