linkedin-automation-cli 1.0.0
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/.env.example +12 -0
- package/.github/workflows/ci.yml +66 -0
- package/.github/workflows/publish.yml +48 -0
- package/.husky/pre-commit +6 -0
- package/.prettierignore +4 -0
- package/.prettierrc +10 -0
- package/AGENTS.md +294 -0
- package/CHANGELOG.md +40 -0
- package/GIT_RELEASE.md +167 -0
- package/LICENSE +21 -0
- package/Makefile +30 -0
- package/NPM_PUBLISHING.md +230 -0
- package/PYEOF +0 -0
- package/README.md +295 -0
- package/TESTING-GUIDE.md +151 -0
- package/cmd/linkedin/main.go +9 -0
- package/dist/agent/action-executor.d.ts +81 -0
- package/dist/agent/action-executor.d.ts.map +1 -0
- package/dist/agent/action-executor.js +170 -0
- package/dist/agent/action-executor.js.map +1 -0
- package/dist/agent/action-executor.test.d.ts +2 -0
- package/dist/agent/action-executor.test.d.ts.map +1 -0
- package/dist/agent/action-executor.test.js +366 -0
- package/dist/agent/action-executor.test.js.map +1 -0
- package/dist/agent/claude-client.d.ts +74 -0
- package/dist/agent/claude-client.d.ts.map +1 -0
- package/dist/agent/claude-client.js +314 -0
- package/dist/agent/claude-client.js.map +1 -0
- package/dist/agent/claude-client.test.d.ts +2 -0
- package/dist/agent/claude-client.test.d.ts.map +1 -0
- package/dist/agent/claude-client.test.js +590 -0
- package/dist/agent/claude-client.test.js.map +1 -0
- package/dist/agent/dom-extractor.d.ts +50 -0
- package/dist/agent/dom-extractor.d.ts.map +1 -0
- package/dist/agent/dom-extractor.js +374 -0
- package/dist/agent/dom-extractor.js.map +1 -0
- package/dist/agent/dom-extractor.test.d.ts +7 -0
- package/dist/agent/dom-extractor.test.d.ts.map +1 -0
- package/dist/agent/dom-extractor.test.js +504 -0
- package/dist/agent/dom-extractor.test.js.map +1 -0
- package/dist/agent/extension-client.d.ts +75 -0
- package/dist/agent/extension-client.d.ts.map +1 -0
- package/dist/agent/extension-client.js +245 -0
- package/dist/agent/extension-client.js.map +1 -0
- package/dist/agent/index.d.ts +8 -0
- package/dist/agent/index.d.ts.map +1 -0
- package/dist/agent/index.js +16 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/agent/page-agent.d.ts +76 -0
- package/dist/agent/page-agent.d.ts.map +1 -0
- package/dist/agent/page-agent.js +236 -0
- package/dist/agent/page-agent.js.map +1 -0
- package/dist/agent/types.d.ts +236 -0
- package/dist/agent/types.d.ts.map +1 -0
- package/dist/agent/types.js +37 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/cli/agent-commands.d.ts +3 -0
- package/dist/cli/agent-commands.d.ts.map +1 -0
- package/dist/cli/agent-commands.js +250 -0
- package/dist/cli/agent-commands.js.map +1 -0
- package/dist/cli/auth.d.ts +3 -0
- package/dist/cli/auth.d.ts.map +1 -0
- package/dist/cli/auth.js +288 -0
- package/dist/cli/auth.js.map +1 -0
- package/dist/cli/company.d.ts +3 -0
- package/dist/cli/company.d.ts.map +1 -0
- package/dist/cli/company.js +55 -0
- package/dist/cli/company.js.map +1 -0
- package/dist/cli/connection.d.ts +3 -0
- package/dist/cli/connection.d.ts.map +1 -0
- package/dist/cli/connection.js +79 -0
- package/dist/cli/connection.js.map +1 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +17 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/messages.d.ts +3 -0
- package/dist/cli/messages.d.ts.map +1 -0
- package/dist/cli/messages.js +268 -0
- package/dist/cli/messages.js.map +1 -0
- package/dist/cli/profile.d.ts +3 -0
- package/dist/cli/profile.d.ts.map +1 -0
- package/dist/cli/profile.js +81 -0
- package/dist/cli/profile.js.map +1 -0
- package/dist/cli/profile.test.d.ts +2 -0
- package/dist/cli/profile.test.d.ts.map +1 -0
- package/dist/cli/profile.test.js +15 -0
- package/dist/cli/profile.test.js.map +1 -0
- package/dist/cli/reply.d.ts +3 -0
- package/dist/cli/reply.d.ts.map +1 -0
- package/dist/cli/reply.js +129 -0
- package/dist/cli/reply.js.map +1 -0
- package/dist/core/audit.d.ts +17 -0
- package/dist/core/audit.d.ts.map +1 -0
- package/dist/core/audit.js +121 -0
- package/dist/core/audit.js.map +1 -0
- package/dist/core/audit.test.d.ts +2 -0
- package/dist/core/audit.test.d.ts.map +1 -0
- package/dist/core/audit.test.js +142 -0
- package/dist/core/audit.test.js.map +1 -0
- package/dist/core/browser-cookies.d.ts +19 -0
- package/dist/core/browser-cookies.d.ts.map +1 -0
- package/dist/core/browser-cookies.js +181 -0
- package/dist/core/browser-cookies.js.map +1 -0
- package/dist/core/browser.d.ts +50 -0
- package/dist/core/browser.d.ts.map +1 -0
- package/dist/core/browser.js +318 -0
- package/dist/core/browser.js.map +1 -0
- package/dist/core/config.d.ts +20 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +103 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/config.test.d.ts +2 -0
- package/dist/core/config.test.d.ts.map +1 -0
- package/dist/core/config.test.js +111 -0
- package/dist/core/config.test.js.map +1 -0
- package/dist/core/storage.d.ts +19 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/storage.js +124 -0
- package/dist/core/storage.js.map +1 -0
- package/dist/core/storage.test.d.ts +2 -0
- package/dist/core/storage.test.d.ts.map +1 -0
- package/dist/core/storage.test.js +142 -0
- package/dist/core/storage.test.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/linkedin/auth.d.ts +22 -0
- package/dist/linkedin/auth.d.ts.map +1 -0
- package/dist/linkedin/auth.js +167 -0
- package/dist/linkedin/auth.js.map +1 -0
- package/dist/linkedin/company-extractor.d.ts +36 -0
- package/dist/linkedin/company-extractor.d.ts.map +1 -0
- package/dist/linkedin/company-extractor.js +211 -0
- package/dist/linkedin/company-extractor.js.map +1 -0
- package/dist/linkedin/company-extractor.test.d.ts +2 -0
- package/dist/linkedin/company-extractor.test.d.ts.map +1 -0
- package/dist/linkedin/company-extractor.test.js +52 -0
- package/dist/linkedin/company-extractor.test.js.map +1 -0
- package/dist/linkedin/connector.d.ts +45 -0
- package/dist/linkedin/connector.d.ts.map +1 -0
- package/dist/linkedin/connector.js +245 -0
- package/dist/linkedin/connector.js.map +1 -0
- package/dist/linkedin/message-sender.d.ts +32 -0
- package/dist/linkedin/message-sender.d.ts.map +1 -0
- package/dist/linkedin/message-sender.js +112 -0
- package/dist/linkedin/message-sender.js.map +1 -0
- package/dist/linkedin/messages.d.ts +78 -0
- package/dist/linkedin/messages.d.ts.map +1 -0
- package/dist/linkedin/messages.js +745 -0
- package/dist/linkedin/messages.js.map +1 -0
- package/dist/linkedin/profile.d.ts +37 -0
- package/dist/linkedin/profile.d.ts.map +1 -0
- package/dist/linkedin/profile.js +268 -0
- package/dist/linkedin/profile.js.map +1 -0
- package/dist/linkedin/profile.test.d.ts +2 -0
- package/dist/linkedin/profile.test.d.ts.map +1 -0
- package/dist/linkedin/profile.test.js +68 -0
- package/dist/linkedin/profile.test.js.map +1 -0
- package/dist/linkedin/reply.d.ts +21 -0
- package/dist/linkedin/reply.d.ts.map +1 -0
- package/dist/linkedin/reply.js +76 -0
- package/dist/linkedin/reply.js.map +1 -0
- package/dist/linkedin/selector-engine.d.ts +69 -0
- package/dist/linkedin/selector-engine.d.ts.map +1 -0
- package/dist/linkedin/selector-engine.js +339 -0
- package/dist/linkedin/selector-engine.js.map +1 -0
- package/dist/linkedin/selector-engine.test.d.ts +2 -0
- package/dist/linkedin/selector-engine.test.d.ts.map +1 -0
- package/dist/linkedin/selector-engine.test.js +135 -0
- package/dist/linkedin/selector-engine.test.js.map +1 -0
- package/dist/linkedin/selectors.d.ts +65 -0
- package/dist/linkedin/selectors.d.ts.map +1 -0
- package/dist/linkedin/selectors.js +261 -0
- package/dist/linkedin/selectors.js.map +1 -0
- package/dist/templates/engine.d.ts +37 -0
- package/dist/templates/engine.d.ts.map +1 -0
- package/dist/templates/engine.js +215 -0
- package/dist/templates/engine.js.map +1 -0
- package/dist/templates/engine.test.d.ts +2 -0
- package/dist/templates/engine.test.d.ts.map +1 -0
- package/dist/templates/engine.test.js +212 -0
- package/dist/templates/engine.test.js.map +1 -0
- package/dist/templates/index.d.ts +2 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +7 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/types/index.d.ts +113 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/index.test.d.ts +2 -0
- package/dist/types/index.test.d.ts.map +1 -0
- package/dist/types/index.test.js +90 -0
- package/dist/types/index.test.js.map +1 -0
- package/dist/utils/paths.d.ts +8 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +68 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +22 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +57 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- package/dist/utils/retry.d.ts +18 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/retry.js +49 -0
- package/dist/utils/retry.js.map +1 -0
- package/docs/connection-command.md +52 -0
- package/docs/plans/2025-03-03-linkedin-cli-design.md +280 -0
- package/docs/plans/2025-03-03-linkedin-cli-implementation-plan.md +2087 -0
- package/docs/plans/2025-03-03-linkedin-cli-implementation.md +2420 -0
- package/docs/plans/2026-02-19-linkedin-connection-feature.md +596 -0
- package/docs/plans/2026-02-28-messages-send-feature.md +480 -0
- package/docs/plans/2026-02-28-messages-show-design.md +243 -0
- package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-design.md +394 -0
- package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-plan.md +1592 -0
- package/docs/superpowers/plans/2026-03-13-linkedin-automation-resilience-migration.md +425 -0
- package/docs/superpowers/plans/2026-03-13-playwright-fara-migration.md +1112 -0
- package/docs/superpowers/plans/2026-03-14-page-agent-plan.md +1598 -0
- package/docs/superpowers/plans/2026-03-15-company-profile-extraction.md +591 -0
- package/docs/superpowers/plans/2026-03-15-profile-extraction-plan.md +943 -0
- package/docs/superpowers/specs/2026-03-14-company-profile-extraction-design.md +371 -0
- package/docs/superpowers/specs/2026-03-14-page-agent-design.md +385 -0
- package/docs/superpowers/specs/2026-03-15-profile-extraction-design.md +409 -0
- package/eslint.config.mjs +58 -0
- package/go.mod +9 -0
- package/go.sum +10 -0
- package/import-cookies.js +376 -0
- package/internal/cmd/actions.go +123 -0
- package/internal/cmd/auth.go +108 -0
- package/internal/cmd/connect.go +42 -0
- package/internal/cmd/message.go +44 -0
- package/internal/cmd/people.go +454 -0
- package/internal/cmd/profiles.go +121 -0
- package/internal/cmd/root.go +89 -0
- package/internal/cmd/sequence.go +192 -0
- package/internal/config/config.go +187 -0
- package/internal/config/config_test.go +121 -0
- package/internal/config/profile.go +65 -0
- package/internal/linkedin/navigator.go +195 -0
- package/internal/linkedin/selectors.go +39 -0
- package/internal/linkedin/validator.go +69 -0
- package/internal/pinchtab/client.go +183 -0
- package/internal/pinchtab/client_test.go +67 -0
- package/internal/pinchtab/types.go +50 -0
- package/internal/ratelimit/limiter.go +115 -0
- package/internal/ratelimit/limits.go +32 -0
- package/package.json +67 -0
- package/release.sh +66 -0
- package/scripts/debug-linkedin.js +156 -0
- package/scripts/debug-login.js +193 -0
- package/scripts/extract-from-edge.js +96 -0
- package/scripts/import-cookies.js +101 -0
- package/scripts/poc-show-data.js +205 -0
- package/scripts/proof-of-access.js +87 -0
- package/scripts/prove-connection.js +110 -0
- package/scripts/show-linkedin-data.js +173 -0
- package/src/agent/action-executor.test.ts +464 -0
- package/src/agent/action-executor.ts +203 -0
- package/src/agent/claude-client.test.ts +707 -0
- package/src/agent/claude-client.ts +422 -0
- package/src/agent/dom-extractor.test.ts +574 -0
- package/src/agent/dom-extractor.ts +437 -0
- package/src/agent/extension-client.ts +306 -0
- package/src/agent/index.ts +28 -0
- package/src/agent/page-agent.ts +292 -0
- package/src/agent/types.ts +288 -0
- package/src/cli/agent-commands.ts +274 -0
- package/src/cli/auth.ts +343 -0
- package/src/cli/company.ts +66 -0
- package/src/cli/connection.ts +89 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/messages.ts +338 -0
- package/src/cli/profile.test.ts +14 -0
- package/src/cli/profile.ts +95 -0
- package/src/cli/reply.ts +110 -0
- package/src/core/audit.test.ts +134 -0
- package/src/core/audit.ts +98 -0
- package/src/core/browser-cookies.ts +203 -0
- package/src/core/browser.ts +304 -0
- package/src/core/config.test.ts +90 -0
- package/src/core/config.ts +81 -0
- package/src/core/storage.test.ts +129 -0
- package/src/core/storage.ts +100 -0
- package/src/index.ts +70 -0
- package/src/linkedin/auth.ts +218 -0
- package/src/linkedin/company-extractor.test.ts +58 -0
- package/src/linkedin/company-extractor.ts +222 -0
- package/src/linkedin/connector.ts +336 -0
- package/src/linkedin/message-sender.ts +141 -0
- package/src/linkedin/messages.ts +894 -0
- package/src/linkedin/profile.test.ts +79 -0
- package/src/linkedin/profile.ts +314 -0
- package/src/linkedin/reply.ts +96 -0
- package/src/linkedin/selector-engine.test.ts +167 -0
- package/src/linkedin/selector-engine.ts +393 -0
- package/src/linkedin/selectors.ts +268 -0
- package/src/templates/defaults/followup.txt +14 -0
- package/src/templates/defaults/meeting.txt +16 -0
- package/src/templates/defaults/welcome.txt +14 -0
- package/src/templates/engine.test.ts +228 -0
- package/src/templates/engine.ts +208 -0
- package/src/templates/index.ts +1 -0
- package/src/types/index.test.ts +94 -0
- package/src/types/index.ts +143 -0
- package/src/types/sql.js.d.ts +23 -0
- package/src/utils/paths.ts +33 -0
- package/src/utils/rate-limiter.ts +75 -0
- package/src/utils/retry.ts +78 -0
- package/test-cli.sh +85 -0
- package/test-real-data.sh +97 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +35 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/linkedin/profile.test.ts
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
LinkedInProfile,
|
|
5
|
+
validateProfileUrl,
|
|
6
|
+
parseDurationString,
|
|
7
|
+
calculateTotalExperience,
|
|
8
|
+
} from './profile';
|
|
9
|
+
|
|
10
|
+
describe('LinkedInProfile', () => {
|
|
11
|
+
it('should be defined', () => {
|
|
12
|
+
expect(LinkedInProfile).toBeDefined();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('validateProfileUrl', () => {
|
|
17
|
+
it('should accept valid LinkedIn profile URLs', () => {
|
|
18
|
+
const validUrls = [
|
|
19
|
+
'https://www.linkedin.com/in/johndoe/',
|
|
20
|
+
'https://linkedin.com/in/jane-doe-123/',
|
|
21
|
+
'http://www.linkedin.com/in/user',
|
|
22
|
+
];
|
|
23
|
+
validUrls.forEach((url) => {
|
|
24
|
+
expect(validateProfileUrl(url)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should reject invalid URLs', () => {
|
|
29
|
+
const invalidUrls = [
|
|
30
|
+
'https://www.google.com/',
|
|
31
|
+
'https://linkedin.com/company/test',
|
|
32
|
+
'not-a-url',
|
|
33
|
+
'',
|
|
34
|
+
];
|
|
35
|
+
invalidUrls.forEach((url) => {
|
|
36
|
+
expect(validateProfileUrl(url)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('parseDurationString', () => {
|
|
42
|
+
it('should parse "Present" duration', () => {
|
|
43
|
+
const result = parseDurationString('Jan 2020 - Present (5 yrs 3 mos)');
|
|
44
|
+
expect(result).not.toBeNull();
|
|
45
|
+
expect(result!.years).toBe(5);
|
|
46
|
+
expect(result!.endDate).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should parse range duration', () => {
|
|
50
|
+
const result = parseDurationString('Mar 2019 - Dec 2021 (2 yr 10 mos)');
|
|
51
|
+
expect(result).not.toBeNull();
|
|
52
|
+
expect(result!.years).toBe(2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should parse year-only duration', () => {
|
|
56
|
+
const result = parseDurationString('2018 - 2022 (4 years)');
|
|
57
|
+
expect(result).not.toBeNull();
|
|
58
|
+
expect(result!.years).toBe(4);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should return null for invalid duration', () => {
|
|
62
|
+
expect(parseDurationString('invalid')).toBeNull();
|
|
63
|
+
expect(parseDurationString('')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('calculateTotalExperience', () => {
|
|
68
|
+
it('should sum years from durations', () => {
|
|
69
|
+
const durations = [
|
|
70
|
+
{ startDate: new Date(), endDate: null, years: 5 },
|
|
71
|
+
{ startDate: new Date(), endDate: new Date(), years: 3 },
|
|
72
|
+
];
|
|
73
|
+
expect(calculateTotalExperience(durations)).toBe(8);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return 0 for empty array', () => {
|
|
77
|
+
expect(calculateTotalExperience([])).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// src/linkedin/profile.ts
|
|
2
|
+
import type { Page } from 'playwright';
|
|
3
|
+
import { SelectorEngine } from './selector-engine';
|
|
4
|
+
import { SELECTORS } from './selectors';
|
|
5
|
+
import type { ProfileData, ProfileExtractionOptions, ProfileExtractionResult } from '../types';
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// URL Validation
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
const LINKEDIN_PROFILE_URL_REGEX = /^https?:\/\/(?:www\.)?linkedin\.com\/in\/[^\/]+\/?$/;
|
|
12
|
+
|
|
13
|
+
export function validateProfileUrl(url: string): boolean {
|
|
14
|
+
return LINKEDIN_PROFILE_URL_REGEX.test(url);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Duration Parsing
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
interface ParsedDuration {
|
|
22
|
+
startDate: Date;
|
|
23
|
+
endDate: Date | null;
|
|
24
|
+
years: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
28
|
+
|
|
29
|
+
function parseMonthYear(text: string): Date {
|
|
30
|
+
const parts = text.trim().split(/\s+/);
|
|
31
|
+
const monthName = parts[0];
|
|
32
|
+
const year = parseInt(parts[1], 10);
|
|
33
|
+
const month = MONTHS.indexOf(monthName);
|
|
34
|
+
return new Date(year, month, 1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseDurationString(durationText: string): ParsedDuration | null {
|
|
38
|
+
if (!durationText || durationText.trim().length === 0) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const text = durationText.trim();
|
|
43
|
+
|
|
44
|
+
// Pattern: "Jan 2020 - Present · 5 yrs 3 mos" or "Jan 2020 - Present (5 yrs 3 mos)"
|
|
45
|
+
const presentPattern =
|
|
46
|
+
/^(\w+\s+\d{4})\s*-\s*Present\s*[·(]\s*(\d+)\s*yrs?\s*(?:\d+\s*mos?)?\s*[·)]?$/i;
|
|
47
|
+
// Pattern: "Mar 2019 - Dec 2021 · 2 yr 10 mos" or with parentheses
|
|
48
|
+
const rangePattern =
|
|
49
|
+
/^(\w+\s+\d{4})\s*-\s*(\w+\s+\d{4})\s*[·(]\s*(\d+)\s*(?:yrs?|years?)\s*(?:\d+\s*mos?)?\s*[·)]?$/i;
|
|
50
|
+
// Pattern: "2018 - 2022 · 4 years" or with parentheses
|
|
51
|
+
const yearOnlyPattern = /^(\d{4})\s*-\s*(\d{4})\s*[·(]\s*(\d+)\s*years?\s*[·)]?$/i;
|
|
52
|
+
|
|
53
|
+
let match = text.match(presentPattern);
|
|
54
|
+
if (match) {
|
|
55
|
+
const startDate = parseMonthYear(match[1]);
|
|
56
|
+
const years = parseInt(match[2], 10);
|
|
57
|
+
return { startDate, endDate: null, years };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
match = text.match(rangePattern);
|
|
61
|
+
if (match) {
|
|
62
|
+
const startDate = parseMonthYear(match[1]);
|
|
63
|
+
const endDate = parseMonthYear(match[2]);
|
|
64
|
+
const years = parseInt(match[3], 10);
|
|
65
|
+
return { startDate, endDate, years };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
match = text.match(yearOnlyPattern);
|
|
69
|
+
if (match) {
|
|
70
|
+
const startDate = new Date(parseInt(match[1], 10), 0, 1);
|
|
71
|
+
const endDate = new Date(parseInt(match[2], 10), 11, 31);
|
|
72
|
+
const years = parseInt(match[3], 10);
|
|
73
|
+
return { startDate, endDate, years };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function calculateTotalExperience(durations: ParsedDuration[]): number {
|
|
80
|
+
return durations.reduce((total, d) => total + d.years, 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// LinkedInProfile Class
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* LinkedInProfile class for extracting structured data from LinkedIn profiles.
|
|
89
|
+
* Uses SelectorEngine with multi-layer fallbacks for resilience.
|
|
90
|
+
*/
|
|
91
|
+
export class LinkedInProfile {
|
|
92
|
+
private page: Page;
|
|
93
|
+
private selectorEngine: SelectorEngine;
|
|
94
|
+
|
|
95
|
+
constructor(page: Page) {
|
|
96
|
+
this.page = page;
|
|
97
|
+
this.selectorEngine = new SelectorEngine(page);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Extract profile data from a LinkedIn profile URL.
|
|
102
|
+
*/
|
|
103
|
+
async extract(options: ProfileExtractionOptions): Promise<ProfileExtractionResult> {
|
|
104
|
+
const timeout = options.timeout ?? 30000;
|
|
105
|
+
|
|
106
|
+
// Validate URL
|
|
107
|
+
if (!validateProfileUrl(options.profileUrl)) {
|
|
108
|
+
return {
|
|
109
|
+
success: false,
|
|
110
|
+
message: 'Invalid LinkedIn profile URL',
|
|
111
|
+
error: 'URL must match pattern: https://www.linkedin.com/in/{username}/',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Navigate to profile
|
|
117
|
+
await this.page.goto(options.profileUrl, {
|
|
118
|
+
waitUntil: 'domcontentloaded',
|
|
119
|
+
timeout,
|
|
120
|
+
});
|
|
121
|
+
await this.page.waitForTimeout(3000);
|
|
122
|
+
|
|
123
|
+
// Extract top card data
|
|
124
|
+
const fullName = await this.extractText('profile', 'name');
|
|
125
|
+
if (!fullName) {
|
|
126
|
+
return {
|
|
127
|
+
success: false,
|
|
128
|
+
message: 'Could not extract profile name',
|
|
129
|
+
error: 'Profile name not found on page',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const headline = await this.extractText('profile', 'headline');
|
|
134
|
+
const location = await this.extractText('profile', 'location');
|
|
135
|
+
|
|
136
|
+
// Extract experience data
|
|
137
|
+
const experienceData = await this.extractExperienceData();
|
|
138
|
+
|
|
139
|
+
// Extract contact info if requested
|
|
140
|
+
let email: string | null = null;
|
|
141
|
+
let phone: string | null = null;
|
|
142
|
+
if (options.includeContact) {
|
|
143
|
+
const contactInfo = await this.extractContactInfo();
|
|
144
|
+
email = contactInfo.email;
|
|
145
|
+
phone = contactInfo.phone;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const profileData: ProfileData = {
|
|
149
|
+
full_name: fullName,
|
|
150
|
+
headline: headline ?? '',
|
|
151
|
+
location: location ?? '',
|
|
152
|
+
current_company: experienceData.currentCompany,
|
|
153
|
+
current_title: experienceData.currentTitle,
|
|
154
|
+
company_linkedin_url: experienceData.companyUrl,
|
|
155
|
+
years_experience: experienceData.yearsExperience,
|
|
156
|
+
email,
|
|
157
|
+
phone,
|
|
158
|
+
profile_url: this.page.url(),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
success: true,
|
|
163
|
+
message: 'Profile extracted successfully',
|
|
164
|
+
data: profileData,
|
|
165
|
+
};
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
message: errorMessage,
|
|
171
|
+
error: errorMessage,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Extract text using selector engine with fallbacks
|
|
178
|
+
*/
|
|
179
|
+
private async extractText(
|
|
180
|
+
category: 'profile',
|
|
181
|
+
key: keyof typeof SELECTORS.profile
|
|
182
|
+
): Promise<string | null> {
|
|
183
|
+
const result = await this.selectorEngine.findElement(category, key, { timeout: 5000 });
|
|
184
|
+
if (!result.element) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
const text = await result.element.textContent();
|
|
188
|
+
return text?.trim() || null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Extract experience data from the profile
|
|
193
|
+
*/
|
|
194
|
+
private async extractExperienceData(): Promise<{
|
|
195
|
+
currentCompany: string | null;
|
|
196
|
+
currentTitle: string | null;
|
|
197
|
+
companyUrl: string | null;
|
|
198
|
+
yearsExperience: number | null;
|
|
199
|
+
}> {
|
|
200
|
+
// Find experience section by looking for section with "Experience" heading
|
|
201
|
+
const expSection = await this.page.locator('section:has(h2:has-text("Experience"))').first();
|
|
202
|
+
const sectionCount = await expSection.count();
|
|
203
|
+
if (sectionCount === 0) {
|
|
204
|
+
return { currentCompany: null, currentTitle: null, companyUrl: null, yearsExperience: null };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Get first experience item (li within the section)
|
|
208
|
+
const firstItem = expSection.locator('li').first();
|
|
209
|
+
const itemCount = await firstItem.count();
|
|
210
|
+
if (itemCount === 0) {
|
|
211
|
+
return { currentCompany: null, currentTitle: null, companyUrl: null, yearsExperience: null };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Extract title from .t-bold span
|
|
215
|
+
let currentTitle: string | null = null;
|
|
216
|
+
try {
|
|
217
|
+
const titleEl = firstItem.locator('.t-bold span[aria-hidden="true"]').first();
|
|
218
|
+
const titleText = await titleEl.textContent({ timeout: 2000 });
|
|
219
|
+
if (titleText?.trim()) {
|
|
220
|
+
currentTitle = titleText.trim();
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// Title not found
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Extract company from .t-14.t-normal span
|
|
227
|
+
// Format is typically "Company Name · Full-time" or just "Company Name"
|
|
228
|
+
let currentCompany: string | null = null;
|
|
229
|
+
try {
|
|
230
|
+
const companyEls = firstItem.locator('.t-14.t-normal span[aria-hidden="true"]');
|
|
231
|
+
const count = await companyEls.count();
|
|
232
|
+
for (let i = 0; i < count; i++) {
|
|
233
|
+
const text = await companyEls.nth(i).textContent();
|
|
234
|
+
if (text?.trim()) {
|
|
235
|
+
// Skip duration patterns (contain month abbreviations or years)
|
|
236
|
+
if (text.includes('Present') || /\d{4}/.test(text)) continue;
|
|
237
|
+
// Extract company name from "Company · Employment Type" format
|
|
238
|
+
const companyText = text.split('·')[0].trim();
|
|
239
|
+
if (companyText && !companyText.includes('yrs') && !companyText.includes('mos')) {
|
|
240
|
+
currentCompany = companyText;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// Company not found
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Extract company URL
|
|
250
|
+
let companyUrl: string | null = null;
|
|
251
|
+
try {
|
|
252
|
+
const linkEl = firstItem.locator('a[data-field="experience_company_logo"]').first();
|
|
253
|
+
companyUrl = await linkEl.getAttribute('href', { timeout: 2000 });
|
|
254
|
+
} catch {
|
|
255
|
+
// Company link not found
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Extract duration for years calculation
|
|
259
|
+
// Duration format: "Dec 2025 - Present · 4 mos" or "Jan 2020 - Present · 5 yrs 3 mos"
|
|
260
|
+
let yearsExperience: number | null = null;
|
|
261
|
+
try {
|
|
262
|
+
const durationEl = firstItem.locator('.pvs-entity__caption-wrapper').first();
|
|
263
|
+
const durationText = await durationEl.textContent({ timeout: 2000 });
|
|
264
|
+
if (durationText) {
|
|
265
|
+
// Parse "X yrs Y mos" or just "X mos" format
|
|
266
|
+
const yrsMatch = durationText.match(/(\d+)\s*yrs?/i);
|
|
267
|
+
const mosMatch = durationText.match(/(\d+)\s*mos?/i);
|
|
268
|
+
if (yrsMatch) {
|
|
269
|
+
yearsExperience = parseInt(yrsMatch[1], 10);
|
|
270
|
+
} else if (mosMatch) {
|
|
271
|
+
// Convert months to fractional years
|
|
272
|
+
yearsExperience = Math.round((parseInt(mosMatch[1], 10) / 12) * 10) / 10;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
// Duration not found
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { currentCompany, currentTitle, companyUrl, yearsExperience };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Extract contact info by clicking the contact info button
|
|
284
|
+
*/
|
|
285
|
+
private async extractContactInfo(): Promise<{ email: string | null; phone: string | null }> {
|
|
286
|
+
// Click contact info button
|
|
287
|
+
const buttonResult = await this.selectorEngine.findElement('profile', 'contactInfoButton', {
|
|
288
|
+
timeout: 5000,
|
|
289
|
+
});
|
|
290
|
+
if (!buttonResult.element) {
|
|
291
|
+
return { email: null, phone: null };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await buttonResult.element.click();
|
|
295
|
+
await this.page.waitForTimeout(2000);
|
|
296
|
+
|
|
297
|
+
// Extract email and phone
|
|
298
|
+
const email = await this.extractText('profile', 'email');
|
|
299
|
+
const phone = await this.extractText('profile', 'phone');
|
|
300
|
+
|
|
301
|
+
// Close modal
|
|
302
|
+
const closeResult = await this.selectorEngine.findElement('profile', 'contactInfoCloseButton', {
|
|
303
|
+
timeout: 2000,
|
|
304
|
+
});
|
|
305
|
+
if (closeResult.element) {
|
|
306
|
+
await closeResult.element.click();
|
|
307
|
+
} else {
|
|
308
|
+
await this.page.keyboard.press('Escape');
|
|
309
|
+
}
|
|
310
|
+
await this.page.waitForTimeout(1000);
|
|
311
|
+
|
|
312
|
+
return { email, phone };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import { SelectorEngine } from './selector-engine';
|
|
3
|
+
|
|
4
|
+
export interface SendMessageOptions {
|
|
5
|
+
threadId: string;
|
|
6
|
+
text: string;
|
|
7
|
+
dryRun?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SendMessageResult {
|
|
11
|
+
success: boolean;
|
|
12
|
+
messageId?: string;
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class LinkedInReply {
|
|
17
|
+
private page: Page;
|
|
18
|
+
private selectorEngine: SelectorEngine;
|
|
19
|
+
|
|
20
|
+
constructor(page: Page) {
|
|
21
|
+
this.page = page;
|
|
22
|
+
this.selectorEngine = new SelectorEngine(page);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Send a message to a thread
|
|
27
|
+
*/
|
|
28
|
+
async sendMessage(options: SendMessageOptions): Promise<SendMessageResult> {
|
|
29
|
+
try {
|
|
30
|
+
// Navigate to thread
|
|
31
|
+
await this.page.goto(`https://www.linkedin.com/messaging/thread/${options.threadId}/`, {
|
|
32
|
+
waitUntil: 'networkidle',
|
|
33
|
+
timeout: 30000,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Wait for message input
|
|
37
|
+
const inputResult = await this.selectorEngine.findElement('messages', 'messageInput', {
|
|
38
|
+
timeout: 10000,
|
|
39
|
+
visible: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!inputResult.element) {
|
|
43
|
+
return { success: false, error: 'Could not find message input field' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Type message
|
|
47
|
+
await inputResult.element.fill(options.text);
|
|
48
|
+
|
|
49
|
+
// Check if dry run
|
|
50
|
+
if (options.dryRun) {
|
|
51
|
+
return {
|
|
52
|
+
success: true,
|
|
53
|
+
messageId: `dry-run-${Date.now()}`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Click send button
|
|
58
|
+
const sendResult = await this.selectorEngine.findElement('messages', 'sendButton', {
|
|
59
|
+
timeout: 5000,
|
|
60
|
+
visible: true,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!sendResult.element) {
|
|
64
|
+
return { success: false, error: 'Could not find send button' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await sendResult.element.click();
|
|
68
|
+
|
|
69
|
+
// Wait for message to be sent (look for confirmation)
|
|
70
|
+
await this.page.waitForTimeout(1000);
|
|
71
|
+
|
|
72
|
+
// Try to get message ID from the DOM
|
|
73
|
+
const messageId = await this.page.evaluate(() => {
|
|
74
|
+
const lastMessage = document.querySelector('[data-urn*="urn:li:fsd_message:"]');
|
|
75
|
+
if (lastMessage) {
|
|
76
|
+
const urn = lastMessage.getAttribute('data-urn');
|
|
77
|
+
if (urn) {
|
|
78
|
+
const match = urn.match(/urn:li:fsd_message:(\d+)/);
|
|
79
|
+
return match ? match[1] : urn;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return `msg-${Date.now()}`;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
success: true,
|
|
87
|
+
messageId,
|
|
88
|
+
};
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
error: error instanceof Error ? error.message : 'Unknown error sending message',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock Page object for testing - updated for current implementation
|
|
4
|
+
const createMockPage = () => {
|
|
5
|
+
const mockElementHandle = { dispose: vi.fn() };
|
|
6
|
+
const getByRoleMock = vi.fn().mockReturnValue({
|
|
7
|
+
elementHandle: vi.fn().mockResolvedValue(mockElementHandle),
|
|
8
|
+
});
|
|
9
|
+
const getByLabelMock = vi.fn().mockReturnValue({
|
|
10
|
+
elementHandle: vi.fn().mockResolvedValue(mockElementHandle),
|
|
11
|
+
});
|
|
12
|
+
const getByTestIdMock = vi.fn().mockReturnValue({
|
|
13
|
+
elementHandle: vi.fn().mockResolvedValue(mockElementHandle),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
waitForSelector: vi.fn(),
|
|
18
|
+
$: vi.fn(),
|
|
19
|
+
$$: vi.fn(),
|
|
20
|
+
getByRole: getByRoleMock,
|
|
21
|
+
getByLabel: getByLabelMock,
|
|
22
|
+
getByTestId: getByTestIdMock,
|
|
23
|
+
_mockElementHandle: mockElementHandle,
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type MockPage = ReturnType<typeof createMockPage>;
|
|
28
|
+
|
|
29
|
+
// Import after creating mocks
|
|
30
|
+
import { SelectorEngine } from './selector-engine';
|
|
31
|
+
|
|
32
|
+
describe('SelectorEngine', () => {
|
|
33
|
+
let page: MockPage;
|
|
34
|
+
let engine: SelectorEngine;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
page = createMockPage();
|
|
38
|
+
engine = new SelectorEngine(page as any);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('findElement', () => {
|
|
42
|
+
it('should return element when accessibility query succeeds', async () => {
|
|
43
|
+
const mockElement = { dispose: vi.fn() };
|
|
44
|
+
(page.getByRole as Mock).mockReturnValue({
|
|
45
|
+
elementHandle: vi.fn().mockResolvedValue(mockElement),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = await engine.findElement('messages', 'conversationList', { timeout: 1000 });
|
|
49
|
+
|
|
50
|
+
expect(result.element).toBe(mockElement);
|
|
51
|
+
expect(result.selector).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should return null when no selectors match', async () => {
|
|
55
|
+
// Mock all accessibility methods to return null
|
|
56
|
+
(page.getByRole as Mock).mockReturnValue({
|
|
57
|
+
elementHandle: vi.fn().mockRejectedValue(new Error('Not found')),
|
|
58
|
+
});
|
|
59
|
+
(page.getByLabel as Mock).mockReturnValue({
|
|
60
|
+
elementHandle: vi.fn().mockRejectedValue(new Error('Not found')),
|
|
61
|
+
});
|
|
62
|
+
(page.getByTestId as Mock).mockReturnValue({
|
|
63
|
+
elementHandle: vi.fn().mockRejectedValue(new Error('Not found')),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const result = await engine.findElement('messages', 'conversationList', { timeout: 1000 });
|
|
67
|
+
|
|
68
|
+
expect(result.element).toBeNull();
|
|
69
|
+
expect(result.selector).toBeNull();
|
|
70
|
+
expect(result.attempts.length).toBeGreaterThan(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should use visible state when requested', async () => {
|
|
74
|
+
const mockElement = { dispose: vi.fn() };
|
|
75
|
+
(page.getByRole as Mock).mockReturnValue({
|
|
76
|
+
elementHandle: vi.fn().mockResolvedValue(mockElement),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await engine.findElement('messages', 'conversationList', {
|
|
80
|
+
timeout: 1000,
|
|
81
|
+
visible: true,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Verify getByRole was called (accessibility-first approach)
|
|
85
|
+
expect(page.getByRole).toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('hasElement', () => {
|
|
90
|
+
it('should return true when element exists', async () => {
|
|
91
|
+
const mockElement = { dispose: vi.fn() };
|
|
92
|
+
(page.getByRole as Mock).mockReturnValue({
|
|
93
|
+
elementHandle: vi.fn().mockResolvedValue(mockElement),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = await engine.hasElement('messages', 'conversationList');
|
|
97
|
+
|
|
98
|
+
expect(result).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should return false when element does not exist', async () => {
|
|
102
|
+
(page.getByRole as Mock).mockReturnValue({
|
|
103
|
+
elementHandle: vi.fn().mockRejectedValue(new Error('Not found')),
|
|
104
|
+
});
|
|
105
|
+
(page.getByLabel as Mock).mockReturnValue({
|
|
106
|
+
elementHandle: vi.fn().mockRejectedValue(new Error('Not found')),
|
|
107
|
+
});
|
|
108
|
+
(page.getByTestId as Mock).mockReturnValue({
|
|
109
|
+
elementHandle: vi.fn().mockRejectedValue(new Error('Not found')),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const result = await engine.hasElement('messages', 'conversationList');
|
|
113
|
+
|
|
114
|
+
expect(result).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should try multiple accessibility queries', async () => {
|
|
118
|
+
const mockElement = { dispose: vi.fn() };
|
|
119
|
+
|
|
120
|
+
// Mock getByRole to succeed
|
|
121
|
+
(page.getByRole as Mock).mockReturnValue({
|
|
122
|
+
elementHandle: vi.fn().mockResolvedValue(mockElement),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = await engine.hasElement('messages', 'conversationList');
|
|
126
|
+
|
|
127
|
+
expect(result).toBe(true);
|
|
128
|
+
expect(page.getByRole).toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('findAccessible', () => {
|
|
133
|
+
it('should find element by role and name', async () => {
|
|
134
|
+
const mockElement = { dispose: vi.fn() };
|
|
135
|
+
(page.waitForSelector as Mock).mockResolvedValue(mockElement);
|
|
136
|
+
|
|
137
|
+
const result = await engine.findAccessible({
|
|
138
|
+
role: 'button',
|
|
139
|
+
name: 'Submit',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(result).toBe(mockElement);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should find element by label', async () => {
|
|
146
|
+
const mockElement = { dispose: vi.fn() };
|
|
147
|
+
(page.waitForSelector as Mock).mockResolvedValue(mockElement);
|
|
148
|
+
|
|
149
|
+
const result = await engine.findAccessible({
|
|
150
|
+
label: 'Email address',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result).toBe(mockElement);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should return null when no accessible element found', async () => {
|
|
157
|
+
(page.waitForSelector as Mock).mockRejectedValue(new Error('Not found'));
|
|
158
|
+
|
|
159
|
+
const result = await engine.findAccessible({
|
|
160
|
+
role: 'button',
|
|
161
|
+
name: 'NonExistent',
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(result).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|