Skip to content

Commit e826d33

Browse files
committed
feat: complete linkedin groups implementation with full MCP/CLI/E2E coverage
1 parent caf3a3a commit e826d33

File tree

6 files changed

+292
-5
lines changed

6 files changed

+292
-5
lines changed

packages/cli/src/bin/linkedin.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8741,6 +8741,41 @@ async function runNotificationsPreferencesPrepareUpdate(
87418741
}
87428742
}
87438743

8744+
async function runGroupsList(
8745+
input: {
8746+
profileName: string;
8747+
limit: number;
8748+
},
8749+
cdpUrl?: string,
8750+
): Promise<void> {
8751+
const runtime = createRuntime(cdpUrl);
8752+
8753+
try {
8754+
runtime.logger.log("info", "cli.groups.list.start", {
8755+
profileName: input.profileName,
8756+
limit: input.limit,
8757+
});
8758+
8759+
const result = await runtime.groups.listGroups({
8760+
profileName: input.profileName,
8761+
limit: input.limit,
8762+
});
8763+
8764+
runtime.logger.log("info", "cli.groups.list.done", {
8765+
profileName: input.profileName,
8766+
count: result.count,
8767+
});
8768+
8769+
printJson({
8770+
run_id: runtime.runId,
8771+
profile_name: input.profileName,
8772+
...result,
8773+
});
8774+
} finally {
8775+
runtime.close();
8776+
}
8777+
}
8778+
87448779
async function runGroupsSearch(
87458780
input: {
87468781
profileName: string;
@@ -12341,6 +12376,23 @@ export function createCliProgram(): Command {
1234112376
"Search, view, join, leave, and post in LinkedIn groups",
1234212377
);
1234312378

12379+
groupsCommand
12380+
.command("list")
12381+
.description("List LinkedIn groups you are a member of")
12382+
.option("-l, --limit <limit>", "Maximum number of groups to return", "10")
12383+
.option("-p, --profile <profile>", "Profile name", "default")
12384+
.action(
12385+
async (options: { profile: string; limit: string }) => {
12386+
await runGroupsList(
12387+
{
12388+
profileName: options.profile,
12389+
limit: parseInt(options.limit, 10)
12390+
},
12391+
readCdpUrl()
12392+
);
12393+
}
12394+
);
12395+
1234412396
groupsCommand
1234512397
.command("search")
1234612398
.description("Search LinkedIn groups by keyword")
@@ -12387,11 +12439,13 @@ export function createCliProgram(): Command {
1238712439
.option("-i, --industry <industry>", "Group industry")
1238812440
.option("-l, --location <location>", "Group location")
1238912441
.option("--unlisted", "Make the group unlisted")
12442+
.option("--logo-path <path>", "Path to logo image")
12443+
.option("--cover-image-path <path>", "Path to cover image")
1239012444
.option("-p, --profile <profile>", "Profile name", "default")
1239112445
.option("-o, --operator-note <note>", "Optional operator note")
1239212446
.action(
1239312447
async (
12394-
options: { profile: string; name: string; description: string; rules?: string; industry?: string; location?: string; unlisted?: boolean; operatorNote?: string },
12448+
options: { profile: string; name: string; description: string; rules?: string; industry?: string; location?: string; unlisted?: boolean; operatorNote?: string; logoPath?: string; coverImagePath?: string },
1239512449
) => {
1239612450
await runGroupsPrepareCreate(
1239712451
{
@@ -12402,6 +12456,8 @@ export function createCliProgram(): Command {
1240212456
...(options.industry ? { industry: options.industry } : {}),
1240312457
...(options.location ? { location: options.location } : {}),
1240412458
...(options.unlisted ? { isUnlisted: options.unlisted } : {}),
12459+
...(options.logoPath ? { logoPath: options.logoPath } : {}),
12460+
...(options.coverImagePath ? { coverImagePath: options.coverImagePath } : {}),
1240512461
...(options.operatorNote
1240612462
? { operatorNote: options.operatorNote }
1240712463
: {}),

packages/core/src/__tests__/e2e/groups.e2e.test.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,30 @@ import {
1313
describe("Groups E2E", () => {
1414
const e2e = setupE2ESuite();
1515

16-
it("searchGroups returns results with populated fields", async (context) => {
16+
it("listGroups returns current groups", async (context) => {
17+
skipIfE2EUnavailable(e2e, context);
18+
const runtime = e2e.runtime();
19+
const result = await runtime.groups.listGroups({
20+
limit: 5,
21+
});
22+
23+
expect(result.count).toBeGreaterThanOrEqual(0);
24+
if (result.count > 0) {
25+
expect(result.results.length).toBeGreaterThan(0);
26+
const first = result.results[0]!;
27+
expect(first.name.length).toBeGreaterThan(0);
28+
expect(first.group_url).toContain("/groups/");
29+
expect(first.group_id.length).toBeGreaterThan(0);
30+
expect(typeof first.member_count).toBe("string");
31+
expect(typeof first.visibility).toBe("string");
32+
expect(typeof first.description).toBe("string");
33+
expect(["member", "joinable", "pending", "unknown"]).toContain(
34+
first.membership_state,
35+
);
36+
}
37+
});
38+
39+
it("searchGroups returns results with populated fields", async (context) => {
1740
skipIfE2EUnavailable(e2e, context);
1841
const runtime = e2e.runtime();
1942
const result = await runtime.groups.searchGroups({
@@ -124,7 +147,25 @@ describe("Groups E2E", () => {
124147
.toThrow(/text is required/i);
125148
});
126149

127-
it("MCP groups.search returns results", async (context) => {
150+
it("MCP groups.list returns results", async (context) => {
151+
skipIfE2EUnavailable(e2e, context);
152+
const result = await callMcpTool(MCP_TOOL_NAMES.groupsList, {
153+
limit: 5,
154+
});
155+
156+
expect(result.isError).toBe(false);
157+
expect(result.payload).toHaveProperty("count");
158+
expect(Number(result.payload.count)).toBeGreaterThanOrEqual(0);
159+
const results = result.payload.results;
160+
expect(Array.isArray(results)).toBe(true);
161+
if (Array.isArray(results) && results.length > 0) {
162+
const firstResult = (results as Record<string, unknown>[])[0]!;
163+
expect(typeof firstResult.name).toBe("string");
164+
expect(typeof firstResult.group_url).toBe("string");
165+
}
166+
});
167+
168+
it("MCP groups.search returns results", async (context) => {
128169
skipIfE2EUnavailable(e2e, context);
129170
const result = await callMcpTool(MCP_TOOL_NAMES.groupsSearch, {
130171
query: "software engineering",
@@ -191,7 +232,20 @@ describe("Groups E2E", () => {
191232
expect(typeof result.payload.confirmToken).toBe("string");
192233
});
193234

194-
it("CLI groups search returns results", async (context) => {
235+
it("CLI groups list returns results", async (context) => {
236+
skipIfE2EUnavailable(e2e, context);
237+
const result = await runCliCommand(
238+
["groups", "list", "--limit", "5"],
239+
{ timeoutMs: 30_000 },
240+
);
241+
242+
expect(result.exitCode).toBe(0);
243+
const output = getLastJsonObject(result.stdout);
244+
expect(Number(output.count)).toBeGreaterThanOrEqual(0);
245+
expect(Array.isArray(output.results)).toBe(true);
246+
});
247+
248+
it("CLI groups search returns results", async (context) => {
195249
skipIfE2EUnavailable(e2e, context);
196250
const result = await runCliCommand(
197251
["groups", "search", "--query", "software engineering", "--limit", "5"],

packages/core/src/__tests__/e2e/helpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {
7070
LINKEDIN_GROUPS_PREPARE_JOIN_TOOL,
7171
LINKEDIN_GROUPS_PREPARE_LEAVE_TOOL,
7272
LINKEDIN_GROUPS_PREPARE_POST_TOOL,
73+
LINKEDIN_GROUPS_LIST_TOOL,
7374
LINKEDIN_GROUPS_SEARCH_TOOL,
7475
LINKEDIN_GROUPS_VIEW_TOOL,
7576
LINKEDIN_JOBS_ALERTS_CREATE_TOOL,
@@ -1026,6 +1027,7 @@ export const MCP_TOOL_NAMES = {
10261027
groupsPrepareJoin: LINKEDIN_GROUPS_PREPARE_JOIN_TOOL,
10271028
groupsPrepareLeave: LINKEDIN_GROUPS_PREPARE_LEAVE_TOOL,
10281029
groupsPreparePost: LINKEDIN_GROUPS_PREPARE_POST_TOOL,
1030+
groupsList: LINKEDIN_GROUPS_LIST_TOOL,
10291031
groupsSearch: LINKEDIN_GROUPS_SEARCH_TOOL,
10301032
groupsView: LINKEDIN_GROUPS_VIEW_TOOL,
10311033
submitFeedback: SUBMIT_FEEDBACK_TOOL,

packages/core/src/linkedinGroups.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,21 @@ export interface CreateGroupInput {
9797
industry?: string;
9898
location?: string;
9999
isUnlisted?: boolean;
100+
logoPath?: string;
101+
coverImagePath?: string;
100102
operatorNote?: string;
101103
}
102104

105+
export interface ListGroupsInput {
106+
profileName?: string;
107+
limit?: number;
108+
}
109+
110+
export interface ListGroupsOutput {
111+
results: LinkedInGroupsSearchResult[];
112+
count: number;
113+
}
114+
103115
export interface SearchGroupsInput {
104116
profileName?: string;
105117
query: string;
@@ -919,7 +931,7 @@ export class CreateGroupActionExecutor
919931

920932
const group = await runtime.profileManager.runWithPersistentContext(
921933
data.profileName ?? "default",
922-
{ headless: false },
934+
{ headless: true },
923935
async (context) => {
924936
const page = await context.newPage();
925937
try {
@@ -943,6 +955,47 @@ export class CreateGroupActionExecutor
943955
await page.locator("label[for='unlisted-group']").click();
944956
}
945957

958+
if (data.industry) {
959+
const industryLocator = page.locator("input[placeholder*='Industry']").or(page.getByLabel(/Industry/i)).first();
960+
await industryLocator.fill(data.industry);
961+
await page.waitForTimeout(1000);
962+
await page.keyboard.press("ArrowDown");
963+
await page.keyboard.press("Enter");
964+
}
965+
966+
if (data.location) {
967+
const locationLocator = page.locator("input[placeholder*='Location']").or(page.getByLabel(/Location/i)).first();
968+
await locationLocator.fill(data.location);
969+
await page.waitForTimeout(1000);
970+
await page.keyboard.press("ArrowDown");
971+
await page.keyboard.press("Enter");
972+
}
973+
974+
if (data.logoPath) {
975+
const fileChooserPromise = page.waitForEvent('filechooser');
976+
const logoButton = page.locator("button[aria-label*='logo']").or(page.getByRole("button", { name: /logo/i })).first();
977+
if (await logoButton.isVisible().catch(() => false)) {
978+
await logoButton.click();
979+
const fileChooser = await fileChooserPromise;
980+
await fileChooser.setFiles(data.logoPath);
981+
await page.waitForTimeout(1000);
982+
await page.getByRole("button", { name: /Apply|Save/i }).first().click().catch(() => {});
983+
}
984+
}
985+
986+
if (data.coverImagePath) {
987+
const fileChooserPromise = page.waitForEvent('filechooser');
988+
const coverButton = page.locator("button[aria-label*='cover image']").or(page.getByRole("button", { name: /cover image/i })).first();
989+
if (await coverButton.isVisible().catch(() => false)) {
990+
await coverButton.click();
991+
const fileChooser = await fileChooserPromise;
992+
await fileChooser.setFiles(data.coverImagePath);
993+
await page.waitForTimeout(1000);
994+
await page.getByRole("button", { name: /Apply|Save/i }).first().click().catch(() => {});
995+
}
996+
}
997+
998+
946999
await page.locator("button[type='submit']").click();
9471000

9481001
// Wait for redirect to new group page
@@ -1071,6 +1124,8 @@ export class LinkedInGroupsService {
10711124
industry: input.industry,
10721125
location: input.location,
10731126
isUnlisted: input.isUnlisted,
1127+
logoPath: input.logoPath,
1128+
coverImagePath: input.coverImagePath,
10741129
},
10751130
preview: {
10761131
summary: `Create LinkedIn group "${input.name}"`,
@@ -1087,6 +1142,73 @@ export class LinkedInGroupsService {
10871142
});
10881143
}
10891144

1145+
1146+
async listGroups(input: ListGroupsInput): Promise<ListGroupsOutput> {
1147+
const profileName = input.profileName ?? "default";
1148+
const limit = readSearchLimit(input.limit);
1149+
1150+
await this.runtime.auth.ensureAuthenticated({
1151+
profileName
1152+
});
1153+
1154+
try {
1155+
const snapshots = await this.runtime.profileManager.runWithPersistentContext(
1156+
profileName,
1157+
{ headless: true },
1158+
async (context) => {
1159+
const page = await getOrCreatePage(context);
1160+
await page.goto("https://www.linkedin.com/groups/", {
1161+
waitUntil: "domcontentloaded"
1162+
});
1163+
await waitForNetworkIdleBestEffort(page);
1164+
1165+
const selectors = [
1166+
".groups-list",
1167+
"ul.groups-list",
1168+
".scaffold-layout__main",
1169+
"main"
1170+
];
1171+
for (const selector of selectors) {
1172+
try {
1173+
await page.locator(selector).first().waitFor({
1174+
state: "visible",
1175+
timeout: 5_000
1176+
});
1177+
break;
1178+
} catch {
1179+
// Try next
1180+
}
1181+
}
1182+
return scrollSearchResultsIfNeeded(page, extractGroupSearchResults, limit);
1183+
}
1184+
);
1185+
1186+
const results = snapshots
1187+
.map((snapshot) => ({
1188+
group_id: normalizeText(snapshot.group_id),
1189+
name: normalizeText(snapshot.name),
1190+
group_url: normalizeText(snapshot.group_url),
1191+
visibility: normalizeText(snapshot.visibility),
1192+
member_count: normalizeText(snapshot.member_count),
1193+
description: normalizeText(snapshot.description),
1194+
membership_state: snapshot.membership_state
1195+
}))
1196+
.filter((result) => result.name.length > 0 || result.group_url.length > 0)
1197+
.slice(0, limit);
1198+
1199+
return {
1200+
results,
1201+
count: results.length
1202+
};
1203+
} catch (error) {
1204+
throw asLinkedInBuddyError(
1205+
error,
1206+
"UNKNOWN",
1207+
"Failed to list LinkedIn groups."
1208+
);
1209+
}
1210+
}
1211+
10901212
async searchGroups(input: SearchGroupsInput): Promise<SearchGroupsOutput> {
10911213
const profileName = input.profileName ?? "default";
10921214
const query = normalizeText(input.query);

0 commit comments

Comments
 (0)