Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
466 changes: 277 additions & 189 deletions apps/extension/src/background/index.ts

Large diffs are not rendered by default.

642 changes: 642 additions & 0 deletions apps/extension/src/content/form-mapping.ts

Large diffs are not rendered by default.

882 changes: 882 additions & 0 deletions apps/extension/src/content/greenhouse.ts

Large diffs are not rendered by default.

843 changes: 505 additions & 338 deletions apps/extension/src/content/linkedin.ts

Large diffs are not rendered by default.

655 changes: 387 additions & 268 deletions apps/extension/src/content/mycareersfuture.ts

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions apps/extension/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export default defineManifest({
host_permissions: [
'https://www.linkedin.com/*',
'https://www.mycareersfuture.gov.sg/*',
'https://boards.greenhouse.io/*',
'https://job-boards.greenhouse.io/*',
'http://localhost:3000/*',
],
background: {
Expand All @@ -30,5 +32,13 @@ export default defineManifest({
js: ['src/content/mycareersfuture.ts'],
run_at: 'document_idle',
},
{
matches: [
'https://boards.greenhouse.io/*',
'https://job-boards.greenhouse.io/*',
],
js: ['src/content/greenhouse.ts'],
run_at: 'document_idle',
},
],
});
2 changes: 1 addition & 1 deletion apps/web/src/app/api/runs/start/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { startRun } from '@/server/services/app-service';
export async function POST(request: Request) {
return withAuthenticatedRoute(request, async ({ candidateId }) => {
const body = (await request.json()) as {
source?: 'linkedin' | 'mycareersfuture';
source?: 'linkedin' | 'mycareersfuture' | 'greenhouse';
targetCount?: number;
jobs?: Array<Partial<JobPosting>>;
};
Expand Down
32 changes: 26 additions & 6 deletions apps/web/src/components/manual-job-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ export function ManualJobForm() {
});

if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
const payload = (await response.json().catch(() => null)) as {
error?: string;
} | null;
setStatus('');
setError(payload?.error ?? 'Could not save this job.');
return;
Expand All @@ -65,25 +67,40 @@ export function ManualJobForm() {
<div className="field-grid">
<label className="field">
<span>Source</span>
<select onChange={(event) => update('source', event.target.value)} value={formState.source}>
<select
onChange={(event) => update('source', event.target.value)}
value={formState.source}
>
<option value="linkedin">LinkedIn</option>
<option value="mycareersfuture">MyCareersFuture</option>
<option value="greenhouse">Greenhouse</option>
</select>
</label>
<label className="field">
<span>Location</span>
<input onChange={(event) => update('location', event.target.value)} value={formState.location} />
<input
onChange={(event) => update('location', event.target.value)}
value={formState.location}
/>
</label>
</div>

<div className="field-grid">
<label className="field">
<span>Job title</span>
<input onChange={(event) => update('title', event.target.value)} required value={formState.title} />
<input
onChange={(event) => update('title', event.target.value)}
required
value={formState.title}
/>
</label>
<label className="field">
<span>Company</span>
<input onChange={(event) => update('company', event.target.value)} required value={formState.company} />
<input
onChange={(event) => update('company', event.target.value)}
required
value={formState.company}
/>
</label>
</div>

Expand All @@ -101,7 +118,10 @@ export function ManualJobForm() {
<div className="field-grid">
<label className="field">
<span>Salary</span>
<input onChange={(event) => update('salaryText', event.target.value)} value={formState.salaryText} />
<input
onChange={(event) => update('salaryText', event.target.value)}
value={formState.salaryText}
/>
</label>
<label className="field">
<span>Employment type</span>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/server/services/app-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,7 @@ export const saveManualJob = async ({

const externalIdFromUrl =
url.match(/\/jobs\/view\/(\d+)/i)?.[1] ??
url.match(/\/jobs\/([A-Za-z0-9-]+)/i)?.[1] ??
url.match(/job\/([A-Za-z0-9-]+)/i)?.[1] ??
slugify(nonEmptyString(input.title, 'manual-role'));

Expand Down
6 changes: 5 additions & 1 deletion packages/domain/src/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { z } from 'zod';

export const sourcePlatformSchema = z.enum(['linkedin', 'mycareersfuture']);
export const sourcePlatformSchema = z.enum([
'linkedin',
'mycareersfuture',
'greenhouse',
]);
export type SourcePlatform = z.infer<typeof sourcePlatformSchema>;

export const applicationStatusSchema = z.enum([
Expand Down
157 changes: 157 additions & 0 deletions tests/e2e/greenhouse-fixture.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import fs from 'node:fs/promises';
import path from 'node:path';

import { expect, test } from '@playwright/test';

import {
resolveChoiceQuestionAnswer,
resolveTextQuestionAnswer,
type FormMappingPreference,
} from '../../apps/extension/src/content/form-mapping';

type FixtureQuestion = {
label: string;
choices: string[];
};

const preference: FormMappingPreference = {
minSalary: 144000,
salaryCurrency: 'SGD',
applicationSalaryAmount: 123000,
yearsExperienceOverride: 8,
noticePeriodWeeks: 2,
workAuthorization: 'yes',
requiresVisaSponsorship: 'no',
willingToRelocate: 'yes',
};

test('mocked Greenhouse fixture maps standard application questions from preferences', async ({
page,
}) => {
const html = await fs.readFile(
path.join(process.cwd(), 'tests/fixtures/greenhouse-application.html'),
'utf8',
);

await page.setContent(html);

await expect(page.locator('h1')).toHaveText('Senior Product Manager');
await expect(page.locator('form#application-form')).toBeVisible();

const questions = await page
.locator('#application-form .field')
.evaluateAll((fields) =>
fields.map((field) => {
const label =
field
.querySelector('legend, label')
?.textContent?.replace(/\s+/g, ' ')
.trim() ?? '';
const selectOptions = Array.from(
field.querySelectorAll('select option'),
).map(
(option) => option.textContent?.replace(/\s+/g, ' ').trim() ?? '',
);
const radioOptions = Array.from(
field.querySelectorAll('input[type="radio"]'),
).map(
(radio) =>
radio.closest('label')?.textContent?.replace(/\s+/g, ' ').trim() ??
radio.getAttribute('value') ??
'',
);

return {
label,
choices: selectOptions.length > 0 ? selectOptions : radioOptions,
};
}),
);

const findQuestion = (pattern: RegExp): FixtureQuestion => {
const question = questions.find((candidate) =>
pattern.test(candidate.label),
);
expect(question, `Missing fixture question for ${pattern}`).toBeTruthy();
return question!;
};

const workAuth = findQuestion(/authorized to work/i);
expect(
resolveChoiceQuestionAnswer({
questionText: workAuth.label,
choiceLabels: workAuth.choices,
preference,
}),
).toMatchObject({
outcome: 'answer',
optionIndex: 0,
source: 'workAuthorization',
});

const sponsorship = findQuestion(/visa sponsorship/i);
expect(
resolveChoiceQuestionAnswer({
questionText: sponsorship.label,
choiceLabels: sponsorship.choices,
preference,
}),
).toMatchObject({
outcome: 'answer',
optionIndex: 1,
source: 'requiresVisaSponsorship',
});

const relocation = findQuestion(/relocation/i);
expect(
resolveChoiceQuestionAnswer({
questionText: relocation.label,
choiceLabels: relocation.choices,
preference,
}),
).toMatchObject({
outcome: 'answer',
optionIndex: 1,
source: 'willingToRelocate',
});

const years = findQuestion(/years of product management/i);
expect(
resolveChoiceQuestionAnswer({
questionText: years.label,
choiceLabels: years.choices,
preference,
}),
).toMatchObject({
outcome: 'answer',
optionIndex: 3,
source: 'yearsExperienceOverride',
});

expect(
resolveTextQuestionAnswer(findQuestion(/notice period/i).label, preference),
).toMatchObject({
outcome: 'answer',
source: 'noticePeriodWeeks',
value: '2',
});
expect(
resolveTextQuestionAnswer(
findQuestion(/annual compensation/i).label,
preference,
),
).toMatchObject({
outcome: 'answer',
source: 'applicationSalaryAmount',
value: '123000',
});
expect(
resolveTextQuestionAnswer(
findQuestion(/current salary/i).label,
preference,
),
).toMatchObject({
outcome: 'review',
category: 'salaryAmount',
});
});
Loading
Loading