Author SHA1 Message Date
JMR-dev d885922ada project bootstrapped 2025-10-18 23:57:32 -05:00
47 changed files with 1129 additions and 4569 deletions
+1 -4
View File
@@ -16,10 +16,7 @@
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"project": ["./tsconfig.json", "./job-application-demo/tsconfig.json"],
"ecmaFeatures": {
"jsx": true
}
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint", "prettier"],
"rules": {
-89
View File
@@ -1,89 +0,0 @@
name: Status Checks
on:
pull_request:
branches: [main]
jobs:
extension-unit-tests:
name: Extension Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.18.1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run extension unit tests
run: pnpm test -- --run --reporter=verbose
demo-unit-tests:
name: Demo App Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.18.1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run demo unit tests
run: cd job-application-demo && pnpm test -- --run --reporter=verbose
demo-e2e-tests:
name: Demo App E2E Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.18.1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: cd job-application-demo && npx playwright install --with-deps chromium firefox
- name: Run E2E tests
run: cd job-application-demo && pnpm test:e2e
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: job-application-demo/playwright-report/
retention-days: 30
-1
View File
@@ -9,4 +9,3 @@ coverage
*.swo
*~
web-ext-artifacts
.claude/
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Jason Ross
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-22
View File
@@ -82,30 +82,8 @@ src/
└── services/
├── formDetector.ts # Form field detection logic
└── fieldMatcher.ts # Resume-to-field matching logic
job-application-demo/ # Sample job application form (separate app)
```
### Demo Application
The `job-application-demo` folder contains a standalone sample job application form built with Vite, React, Material UI, and TypeScript. This demo app:
- Showcases the structure and fields used by the JobSpeed extension
- Uses the same `ResumeData` interface as the extension
- Saves submitted applications as incremental JSON files
- Is excluded from the main extension build
- Has its own `package.json` and can be run independently
To run the demo:
```bash
cd job-application-demo
pnpm install
pnpm dev
```
See [job-application-demo/README.md](job-application-demo/README.md) for more details.
## Usage
1. **Upload Your Resume**:
-30
View File
@@ -1,30 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Test results
coverage
playwright-report
test-results
.playwright
-129
View File
@@ -1,129 +0,0 @@
# Job Application Demo
This is a sample job application form web app built with Vite, React, Material UI, and TypeScript. It demonstrates the structure and fields used by the JobSpeed extension.
## Features
- Material UI components for a polished, professional look
- Form validation matching the extension's `ResumeData` interface
- Required and optional fields as defined in the extension
- Incremental JSON file export with timestamps
- Sections for:
- Personal Information (required)
- Work Experience (required)
- Education (optional)
- Certifications (optional)
- Skills (required)
- Professional Summary (optional)
## Getting Started
### Install Dependencies
```bash
pnpm install
```
### Development Server
```bash
pnpm dev
```
The app will be available at `http://localhost:5173`
### Build for Production
```bash
pnpm build
```
### Preview Production Build
```bash
pnpm preview
```
## Usage
1. Fill in the form with your information
2. Required fields are marked with an asterisk (*)
3. Click "Submit Application" to download your data as a JSON file
4. The file will be saved as `job-application-{N}-{timestamp}.json`
- `{N}` is an incrementing number
- `{timestamp}` is the current date and time
## Field Requirements
The form matches the extension's `ResumeData` interface:
### Required Fields
- All personal information fields (except LinkedIn, GitHub, Website)
- At least one work experience entry
- At least one skill
### Optional Fields
- LinkedIn profile URL
- GitHub profile URL
- Personal website URL
- Education entries
- Certification entries
- Professional summary
## Testing
This project includes comprehensive test coverage with both unit tests and end-to-end tests.
### Unit Tests (Vitest)
Run unit tests for React components:
```bash
# Run tests in watch mode
pnpm test
# Run tests once
pnpm test -- --run
# Run tests with UI
pnpm test:ui
# Run tests with coverage
pnpm test:coverage
```
Unit tests cover:
- PersonalInfoForm component
- WorkExperienceForm component
- SkillsForm component
- App component integration
### End-to-End Tests (Playwright)
Run E2E tests that simulate real user interactions:
```bash
# Run E2E tests
pnpm test:e2e
# Run E2E tests with UI
pnpm test:e2e:ui
# View test report
pnpm test:e2e:report
```
E2E tests cover:
- Complete form submission flow
- Form validation (email, phone, zip code, GPA)
- Adding and removing dynamic entries (work experience, education, etc.)
- Skills management
- File download functionality
## Integration with Main Project
This demo app is:
- Excluded from the main extension build
- Bootstrapped separately with its own `package.json`
- Included in the project's ESLint and Prettier configurations
- Using the same strict TypeScript settings as the main project
@@ -1,251 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('Job Application Form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should display the form with all sections', async ({ page }) => {
await expect(page.getByText('Job Application Form')).toBeVisible();
await expect(
page.getByText('Fields marked with * are required')
).toBeVisible();
// Check all sections are present
await expect(page.getByText('Personal Information')).toBeVisible();
await expect(page.getByText('Work Experience *')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Education' })).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Certifications' })
).toBeVisible();
await expect(page.getByText('Skills *')).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Professional Summary' })
).toBeVisible();
});
test('should show validation errors for incomplete required fields', async ({
page,
}) => {
// Fill some fields but leave others empty to trigger custom validation
await page.getByLabel(/full name/i).fill('John Doe');
await page.getByLabel(/email/i).fill('john@example.com');
// Leave other required fields empty
await page.getByRole('button', { name: /submit application/i }).click();
await expect(
page.getByText(/please fill in all required personal information fields/i)
).toBeVisible();
});
test('should fill personal information', async ({ page }) => {
await page.getByLabel(/full name/i).fill('John Doe');
await page.getByLabel(/email/i).fill('john.doe@example.com');
await page.getByLabel(/phone/i).fill('+12345678901');
await page.getByLabel(/address/i).fill('123 Main Street');
await page.getByLabel(/city/i).fill('New York');
await page.getByLabel(/state/i).fill('NY');
await page.getByLabel(/zip code/i).fill('10001');
await page.getByLabel(/country/i).fill('USA');
// Verify values are entered
await expect(page.getByLabel(/full name/i)).toHaveValue('John Doe');
await expect(page.getByLabel(/email/i)).toHaveValue(
'john.doe@example.com'
);
});
test('should add and remove work experience', async ({ page }) => {
// Add work experience
await page.getByRole('button', { name: /add work experience/i }).click();
// Fill work experience form
await page.getByLabel(/company/i).fill('Acme Corporation');
await page.getByLabel(/position/i).fill('Software Engineer');
await page.getByLabel(/start date/i).fill('2020-01-01');
await page.getByLabel(/end date/i).fill('2023-12-31');
await page
.getByLabel(/description/i)
.fill('Developed and maintained web applications');
// Verify values
await expect(page.getByLabel(/company/i)).toHaveValue('Acme Corporation');
// Check for delete button (IconButton with DeleteIcon)
const deleteButton = page.getByRole('button', { name: '' }).first();
await expect(deleteButton).toBeVisible();
// Remove work experience
await deleteButton.click();
// Verify work experience is removed
await expect(page.getByLabel(/company/i)).not.toBeVisible();
});
test('should toggle current job checkbox', async ({ page }) => {
await page.getByRole('button', { name: /add work experience/i }).click();
const endDateInput = page.getByLabel(/end date/i);
await expect(endDateInput).toBeEnabled();
// Click "I currently work here" checkbox
await page.getByText(/i currently work here/i).click();
// End date should be disabled
await expect(endDateInput).toBeDisabled();
});
test('should add and remove skills', async ({ page }) => {
// Add a skill
await page.getByLabel(/add a skill/i).fill('JavaScript');
await page.getByRole('button', { name: /^add$/i }).click();
// Verify skill chip appears
await expect(page.getByText('JavaScript')).toBeVisible();
// Add another skill
await page.getByLabel(/add a skill/i).fill('TypeScript');
await page.getByRole('button', { name: /^add$/i }).click();
await expect(page.getByText('TypeScript')).toBeVisible();
// Remove a skill (click the X on the chip)
await page.locator('[data-testid="CancelIcon"]').first().click();
// Verify one skill remains
const skillChips = page.locator('span').filter({ hasText: /script/i });
await expect(skillChips).toHaveCount(1);
});
test('should add education entry', async ({ page }) => {
await page.getByRole('button', { name: /add education/i }).click();
await page.getByLabel(/institution/i).fill('MIT');
await page.getByLabel(/degree/i).fill('Bachelor of Science');
await page.getByLabel(/field of study/i).fill('Computer Science');
await page.getByLabel(/graduation date/i).fill('2019-05-15');
await page.getByLabel(/gpa/i).fill('3.85');
await expect(page.getByLabel(/institution/i)).toHaveValue('MIT');
});
test('should add certification entry', async ({ page }) => {
await page.getByRole('button', { name: /add certification/i }).click();
await page
.getByLabel(/certification name/i)
.fill('AWS Certified Solutions Architect');
await page.getByLabel(/acquired date/i).fill('2022-06-01');
await page.getByLabel(/area of expertise/i).fill('Cloud Computing');
await expect(page.getByLabel(/certification name/i)).toHaveValue(
'AWS Certified Solutions Architect'
);
});
test('should fill professional summary', async ({ page }) => {
const summary =
'Experienced software engineer with 5 years of expertise in web development.';
await page.getByLabel(/summary/i).fill(summary);
await expect(page.getByLabel(/summary/i)).toHaveValue(summary);
});
test('should complete full application and submit', async ({ page }) => {
// Personal Information
await page.getByLabel(/full name/i).fill('Jane Smith');
await page.getByLabel(/email/i).fill('jane.smith@example.com');
await page.getByLabel(/phone/i).fill('+19876543210');
await page.getByLabel(/address/i).fill('456 Oak Avenue');
await page.getByLabel(/city/i).fill('San Francisco');
await page.getByLabel(/state/i).fill('CA');
await page.getByLabel(/zip code/i).fill('94102');
await page.getByLabel(/country/i).fill('USA');
// Work Experience
await page.getByRole('button', { name: /add work experience/i }).click();
await page.getByLabel(/company/i).fill('Tech Innovations Inc');
await page.getByLabel(/position/i).fill('Senior Developer');
await page.getByLabel(/start date/i).fill('2018-03-01');
await page.getByLabel(/end date/i).fill('2024-01-01');
await page
.getByLabel(/description/i)
.fill('Led development of enterprise applications');
// Skills
await page.getByLabel(/add a skill/i).fill('React');
await page.getByRole('button', { name: /^add$/i }).click();
await page.getByLabel(/add a skill/i).fill('Node.js');
await page.getByRole('button', { name: /^add$/i }).click();
// Setup download listener before clicking submit
const downloadPromise = page.waitForEvent('download');
// Submit the form
await page.getByRole('button', { name: /submit application/i }).click();
// Wait for success message
await expect(
page.getByText(/application saved as job-application/i)
).toBeVisible({ timeout: 10000 });
// Verify download occurred
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/job-application-\d+-.*\.json/);
});
test('should validate email format', async ({ page }) => {
const emailInput = page.getByLabel(/email/i);
await emailInput.fill('invalid-email');
// The HTML5 validation will prevent form submission
await page.getByRole('button', { name: /submit application/i }).click();
// Check for browser validation message
const validationMessage = await emailInput.evaluate(
(el: HTMLInputElement) => el.validationMessage
);
expect(validationMessage).toBeTruthy();
});
test('should validate phone number format', async ({ page }) => {
const phoneInput = page.getByLabel(/phone/i);
await phoneInput.fill('abc123');
await page.getByRole('button', { name: /submit application/i }).click();
const validationMessage = await phoneInput.evaluate(
(el: HTMLInputElement) => el.validationMessage
);
expect(validationMessage).toBeTruthy();
});
test('should validate zip code format', async ({ page }) => {
const zipInput = page.getByLabel(/zip code/i);
await zipInput.fill('1234'); // Only 4 digits
await page.getByRole('button', { name: /submit application/i }).click();
const validationMessage = await zipInput.evaluate(
(el: HTMLInputElement) => el.validationMessage
);
expect(validationMessage).toBeTruthy();
});
test('should validate GPA format when education is added', async ({
page,
}) => {
await page.getByRole('button', { name: /add education/i }).click();
const gpaInput = page.getByLabel(/gpa/i);
await gpaInput.fill('5.00'); // Invalid - first digit must be 1-4
await page.getByRole('button', { name: /submit application/i }).click();
const validationMessage = await gpaInput.evaluate(
(el: HTMLInputElement) => el.validationMessage
);
expect(validationMessage).toBeTruthy();
});
});
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Job Application Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-40
View File
@@ -1,40 +0,0 @@
{
"name": "job-application-demo",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test --project=chromium --project=firefox --reporter=list",
"test:e2e:ui": "playwright test --project=chromium --project=firefox --ui",
"test:e2e:report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.4",
"@vitest/ui": "^3.2.4",
"jsdom": "^27.0.1",
"playwright": "^1.56.1",
"typescript": "~5.9.3",
"vite": "^7.1.10",
"vitest": "^3.2.4"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
}
-35
View File
@@ -1,35 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-199
View File
@@ -1,199 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value.toString();
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
// Mock URL.createObjectURL and URL.revokeObjectURL
global.URL.createObjectURL = vi.fn(() => 'mock-url');
global.URL.revokeObjectURL = vi.fn();
describe('App', () => {
beforeEach(() => {
localStorageMock.clear();
});
it('renders the job application form', () => {
render(<App />);
expect(screen.getByText(/job application form/i)).toBeInTheDocument();
expect(
screen.getByText(/fields marked with \* are required/i)
).toBeInTheDocument();
});
it('renders all form sections', () => {
render(<App />);
expect(screen.getByText(/personal information/i)).toBeInTheDocument();
expect(screen.getByText(/work experience \*/i)).toBeInTheDocument();
expect(screen.getAllByText(/education/i)[0]).toBeInTheDocument();
expect(screen.getAllByText(/certifications/i)[0]).toBeInTheDocument();
expect(screen.getByText(/skills \*/i)).toBeInTheDocument();
expect(screen.getAllByText(/professional summary/i)[0]).toBeInTheDocument();
});
it('has a submit button', () => {
render(<App />);
expect(
screen.getByRole('button', { name: /submit application/i })
).toBeInTheDocument();
});
it('shows error when required fields are missing', async () => {
const user = userEvent.setup();
render(<App />);
// Fill some but not all required fields to bypass HTML5 validation
await user.click(screen.getByLabelText(/full name/i));
await user.paste('John Doe');
await user.click(screen.getByLabelText(/email/i));
await user.paste('john@example.com');
// Leave other required fields empty
const submitButton = screen.getByRole('button', {
name: /submit application/i,
});
await user.click(submitButton);
await waitFor(() => {
expect(
screen.getByText(/please fill in all required personal information fields/i)
).toBeInTheDocument();
});
});
it('shows error when work experience is missing', async () => {
const user = userEvent.setup();
render(<App />);
// Fill in required personal info using paste for speed
await user.click(screen.getByLabelText(/full name/i));
await user.paste('John Doe');
await user.click(screen.getByLabelText(/email/i));
await user.paste('john@example.com');
await user.click(screen.getByLabelText(/phone/i));
await user.paste('+12345678901');
await user.click(screen.getByLabelText(/address/i));
await user.paste('123 Main St');
await user.click(screen.getByLabelText(/city/i));
await user.paste('New York');
await user.click(screen.getByLabelText(/state/i));
await user.paste('NY');
await user.click(screen.getByLabelText(/zip code/i));
await user.paste('12345');
await user.click(screen.getByLabelText(/country/i));
await user.paste('USA');
const submitButton = screen.getByRole('button', {
name: /submit application/i,
});
await user.click(submitButton);
await waitFor(() => {
expect(
screen.getByText(/please add at least one work experience entry/i)
).toBeInTheDocument();
});
});
it('shows error when skills are missing', async () => {
const user = userEvent.setup();
render(<App />);
// Fill in required personal info using paste for speed
await user.click(screen.getByLabelText(/full name/i));
await user.paste('John Doe');
await user.click(screen.getByLabelText(/email/i));
await user.paste('john@example.com');
await user.click(screen.getByLabelText(/phone/i));
await user.paste('+12345678901');
await user.click(screen.getByLabelText(/address/i));
await user.paste('123 Main St');
await user.click(screen.getByLabelText(/city/i));
await user.paste('New York');
await user.click(screen.getByLabelText(/state/i));
await user.paste('NY');
await user.click(screen.getByLabelText(/zip code/i));
await user.paste('12345');
await user.click(screen.getByLabelText(/country/i));
await user.paste('USA');
// Add work experience
await user.click(screen.getByText(/add work experience/i));
const submitButton = screen.getByRole('button', {
name: /submit application/i,
});
await user.click(submitButton);
await waitFor(() => {
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent(/please add at least one skill/i);
});
});
it('increments application count in localStorage', async () => {
const user = userEvent.setup();
render(<App />);
// Fill in all required fields using paste for speed
await user.click(screen.getByLabelText(/full name/i));
await user.paste('John Doe');
await user.click(screen.getByLabelText(/email/i));
await user.paste('john@example.com');
await user.click(screen.getByLabelText(/phone/i));
await user.paste('+12345678901');
await user.click(screen.getByLabelText(/address/i));
await user.paste('123 Main St');
await user.click(screen.getByLabelText(/city/i));
await user.paste('New York');
await user.click(screen.getByLabelText(/state/i));
await user.paste('NY');
await user.click(screen.getByLabelText(/zip code/i));
await user.paste('12345');
await user.click(screen.getByLabelText(/country/i));
await user.paste('USA');
// Add work experience
await user.click(screen.getByText(/add work experience/i));
const companyInputs = screen.getAllByLabelText(/company/i);
await user.click(companyInputs[0]!);
await user.paste('Acme Corp');
// Add skill
const skillInput = screen.getByLabelText(/add a skill/i);
await user.click(skillInput);
await user.paste('JavaScript');
await user.click(screen.getByRole('button', { name: /^add$/i }));
// Submit
const submitButton = screen.getByRole('button', {
name: /submit application/i,
});
await user.click(submitButton);
await waitFor(() => {
expect(localStorageMock.getItem('applicationCount')).toBe('1');
});
});
});
-210
View File
@@ -1,210 +0,0 @@
import { useState } from 'react';
import {
Container,
Paper,
Typography,
Box,
Button,
Snackbar,
Alert,
} from '@mui/material';
import { ResumeData } from './types';
import PersonalInfoForm from './components/PersonalInfoForm';
import WorkExperienceForm from './components/WorkExperienceForm';
import EducationForm from './components/EducationForm';
import CertificationsForm from './components/CertificationsForm';
import SkillsForm from './components/SkillsForm';
import SummaryForm from './components/SummaryForm';
function App() {
const [formData, setFormData] = useState<ResumeData>({
personalInfo: {
fullName: '',
email: '',
phone: '',
address: '',
city: '',
state: '',
zipCode: '',
country: '',
linkedIn: '',
github: '',
website: '',
},
workExperience: [],
education: [],
certifications: [],
skills: [],
summary: '',
});
const [snackbar, setSnackbar] = useState({
open: false,
message: '',
severity: 'success' as 'success' | 'error',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validate required fields
const { personalInfo, workExperience, skills } = formData;
if (
!personalInfo.fullName ||
!personalInfo.email ||
!personalInfo.phone ||
!personalInfo.address ||
!personalInfo.city ||
!personalInfo.state ||
!personalInfo.zipCode ||
!personalInfo.country
) {
setSnackbar({
open: true,
message: 'Please fill in all required personal information fields',
severity: 'error',
});
return;
}
if (workExperience.length === 0) {
setSnackbar({
open: true,
message: 'Please add at least one work experience entry',
severity: 'error',
});
return;
}
if (skills.length === 0) {
setSnackbar({
open: true,
message: 'Please add at least one skill',
severity: 'error',
});
return;
}
try {
// Get the current count from localStorage
const count =
parseInt(localStorage.getItem('applicationCount') || '0') + 1;
localStorage.setItem('applicationCount', count.toString());
// Create filename with timestamp
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, -5);
const filename = `job-application-${count}-${timestamp}.json`;
// Create and download the JSON file
const blob = new Blob([JSON.stringify(formData, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setSnackbar({
open: true,
message: `Application saved as ${filename}`,
severity: 'success',
});
} catch {
setSnackbar({
open: true,
message: 'Error saving application',
severity: 'error',
});
}
};
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Paper elevation={3} sx={{ p: 5 }}>
<Typography variant="h4" component="h1" gutterBottom align="center">
Job Application Form
</Typography>
<Typography
variant="body2"
color="text.secondary"
gutterBottom
align="center"
sx={{ mb: 3 }}
>
Fields marked with * are required
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate>
<PersonalInfoForm
data={formData.personalInfo}
onChange={(personalInfo) =>
setFormData({ ...formData, personalInfo })
}
/>
<WorkExperienceForm
data={formData.workExperience}
onChange={(workExperience) =>
setFormData({ ...formData, workExperience })
}
/>
<EducationForm
data={formData.education || []}
onChange={(education) => setFormData({ ...formData, education })}
/>
<CertificationsForm
data={formData.certifications || []}
onChange={(certifications) =>
setFormData({ ...formData, certifications })
}
/>
<SkillsForm
data={formData.skills}
onChange={(skills) => setFormData({ ...formData, skills })}
/>
<SummaryForm
data={formData.summary || ''}
onChange={(summary) => setFormData({ ...formData, summary })}
/>
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
<Button
type="submit"
variant="contained"
size="large"
sx={{ minWidth: 200 }}
>
Submit Application
</Button>
</Box>
</Box>
</Paper>
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={() => setSnackbar({ ...snackbar, open: false })}
severity={snackbar.severity}
variant="filled"
>
{snackbar.message}
</Alert>
</Snackbar>
</Container>
);
}
export default App;
@@ -1,115 +0,0 @@
import {
Box,
TextField,
Typography,
Divider,
Button,
Paper,
IconButton,
} from '@mui/material';
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { ResumeData } from '../types';
interface CertificationsFormProps {
data: NonNullable<ResumeData['certifications']>;
onChange: (data: NonNullable<ResumeData['certifications']>) => void;
}
export default function CertificationsForm({
data,
onChange,
}: CertificationsFormProps) {
const handleAdd = () => {
onChange([
...data,
{
certificationName: '',
acquiredDate: '',
areaOfExpertise: '',
},
]);
};
const handleRemove = (index: number) => {
onChange(data.filter((_, i) => i !== index));
};
const handleChange = (
index: number,
field: keyof NonNullable<ResumeData['certifications']>[0],
value: string
) => {
const newData = [...data];
newData[index] = { ...newData[index]!, [field]: value };
onChange(newData);
};
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
Certifications
</Typography>
<Divider sx={{ mb: 2 }} />
{data.map((certification, index) => (
<Paper
key={index}
elevation={1}
sx={{ p: 3, mb: 2, position: 'relative' }}
>
<IconButton
onClick={() => handleRemove(index)}
sx={{ position: 'absolute', top: 20, right: 4 }}
color="error"
size="small"
>
<DeleteIcon />
</IconButton>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Certification Name"
required
fullWidth
value={certification.certificationName}
onChange={(e) =>
handleChange(index, 'certificationName', e.target.value)
}
/>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="Acquired Date"
type="date"
required
fullWidth
InputLabelProps={{ shrink: true }}
value={certification.acquiredDate}
onChange={(e) =>
handleChange(index, 'acquiredDate', e.target.value)
}
/>
<TextField
label="Area of Expertise"
fullWidth
value={certification.areaOfExpertise || ''}
onChange={(e) =>
handleChange(index, 'areaOfExpertise', e.target.value)
}
/>
</Box>
</Box>
</Paper>
))}
<Button
startIcon={<AddIcon />}
onClick={handleAdd}
variant="outlined"
fullWidth
>
Add Certification
</Button>
</Box>
);
}
@@ -1,135 +0,0 @@
import {
Box,
TextField,
Typography,
Divider,
Button,
Paper,
IconButton,
} from '@mui/material';
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { ResumeData } from '../types';
interface EducationFormProps {
data: NonNullable<ResumeData['education']>;
onChange: (data: NonNullable<ResumeData['education']>) => void;
}
export default function EducationForm({ data, onChange }: EducationFormProps) {
const handleAdd = () => {
onChange([
...data,
{
institution: '',
degree: '',
field: '',
graduationDate: '',
gpa: '',
},
]);
};
const handleRemove = (index: number) => {
onChange(data.filter((_, i) => i !== index));
};
const handleChange = (
index: number,
field: keyof NonNullable<ResumeData['education']>[0],
value: string
) => {
const newData = [...data];
newData[index] = { ...newData[index]!, [field]: value };
onChange(newData);
};
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
Education
</Typography>
<Divider sx={{ mb: 2 }} />
{data.map((education, index) => (
<Paper
key={index}
elevation={1}
sx={{ p: 3, mb: 2, position: 'relative' }}
>
<IconButton
onClick={() => handleRemove(index)}
sx={{ position: 'absolute', top: 20, right: 4 }}
color="error"
size="small"
>
<DeleteIcon />
</IconButton>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Institution"
required
fullWidth
value={education.institution}
onChange={(e) =>
handleChange(index, 'institution', e.target.value)
}
/>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="Degree"
required
fullWidth
value={education.degree}
onChange={(e) => handleChange(index, 'degree', e.target.value)}
/>
<TextField
label="Field of Study"
fullWidth
value={education.field || ''}
onChange={(e) => handleChange(index, 'field', e.target.value)}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="Graduation Date"
type="date"
required
fullWidth
InputLabelProps={{ shrink: true }}
value={education.graduationDate}
onChange={(e) =>
handleChange(index, 'graduationDate', e.target.value)
}
/>
<TextField
label="GPA"
fullWidth
value={education.gpa || ''}
onChange={(e) => handleChange(index, 'gpa', e.target.value)}
placeholder="e.g., 3.50"
slotProps={{
htmlInput: {
pattern: '^[1-4]\\.[0-9]{2}$',
},
}}
helperText="Format: X.XX (1.00-4.99)"
/>
</Box>
</Box>
</Paper>
))}
<Button
startIcon={<AddIcon />}
onClick={handleAdd}
variant="outlined"
fullWidth
>
Add Education
</Button>
</Box>
);
}
@@ -1,81 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import PersonalInfoForm from './PersonalInfoForm';
import { ResumeData } from '../types';
describe('PersonalInfoForm', () => {
const mockData: ResumeData['personalInfo'] = {
fullName: '',
email: '',
phone: '',
address: '',
city: '',
state: '',
zipCode: '',
country: '',
linkedIn: '',
github: '',
website: '',
};
const mockOnChange = vi.fn();
it('renders all required fields', () => {
render(<PersonalInfoForm data={mockData} onChange={mockOnChange} />);
expect(screen.getByLabelText(/full name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/phone/i)).toBeInTheDocument();
expect(screen.getByLabelText(/address/i)).toBeInTheDocument();
expect(screen.getByLabelText(/city/i)).toBeInTheDocument();
expect(screen.getByLabelText(/state/i)).toBeInTheDocument();
expect(screen.getByLabelText(/zip code/i)).toBeInTheDocument();
expect(screen.getByLabelText(/country/i)).toBeInTheDocument();
});
it('renders optional fields', () => {
render(<PersonalInfoForm data={mockData} onChange={mockOnChange} />);
expect(screen.getByLabelText(/linkedin profile/i)).toBeInTheDocument();
expect(screen.getByLabelText(/github profile/i)).toBeInTheDocument();
expect(screen.getByLabelText(/personal website/i)).toBeInTheDocument();
});
it('calls onChange when full name is entered', async () => {
const user = userEvent.setup();
render(<PersonalInfoForm data={mockData} onChange={mockOnChange} />);
const fullNameInput = screen.getByLabelText(/full name/i);
await user.type(fullNameInput, 'John Doe');
expect(mockOnChange).toHaveBeenCalled();
});
it('displays validation helper text', () => {
render(<PersonalInfoForm data={mockData} onChange={mockOnChange} />);
expect(
screen.getByText(/enter a valid email address/i)
).toBeInTheDocument();
expect(
screen.getByText(/include country code \(e\.g\., \+1 for us\)/i)
).toBeInTheDocument();
expect(screen.getByText(/enter 5-digit zip code/i)).toBeInTheDocument();
});
it('populates fields with existing data', () => {
const existingData: ResumeData['personalInfo'] = {
...mockData,
fullName: 'Jane Smith',
email: 'jane@example.com',
phone: '+12345678901',
};
render(<PersonalInfoForm data={existingData} onChange={mockOnChange} />);
expect(screen.getByDisplayValue('Jane Smith')).toBeInTheDocument();
expect(screen.getByDisplayValue('jane@example.com')).toBeInTheDocument();
expect(screen.getByDisplayValue('+12345678901')).toBeInTheDocument();
});
});
@@ -1,140 +0,0 @@
import { Box, TextField, Typography, Divider } from '@mui/material';
import { ResumeData } from '../types';
interface PersonalInfoFormProps {
data: ResumeData['personalInfo'];
onChange: (data: ResumeData['personalInfo']) => void;
}
export default function PersonalInfoForm({
data,
onChange,
}: PersonalInfoFormProps) {
const handleChange =
(field: keyof ResumeData['personalInfo']) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange({ ...data, [field]: e.target.value });
};
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
Personal Information
</Typography>
<Divider sx={{ mb: 2 }} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Full Name"
required
fullWidth
value={data.fullName}
onChange={handleChange('fullName')}
/>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="Email"
type="email"
required
fullWidth
value={data.email}
onChange={handleChange('email')}
slotProps={{
htmlInput: {
pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$',
},
}}
helperText="Enter a valid email address"
/>
<TextField
label="Phone"
type="tel"
required
fullWidth
value={data.phone}
onChange={handleChange('phone')}
slotProps={{
htmlInput: {
pattern: '^\\+?[1-9]\\d{1,14}$',
},
}}
helperText="Include country code (e.g., +1 for US)"
/>
</Box>
<TextField
label="Address"
required
fullWidth
value={data.address}
onChange={handleChange('address')}
/>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="City"
required
fullWidth
value={data.city}
onChange={handleChange('city')}
/>
<TextField
label="State"
required
fullWidth
value={data.state}
onChange={handleChange('state')}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="Zip Code"
required
fullWidth
value={data.zipCode}
onChange={handleChange('zipCode')}
slotProps={{
htmlInput: {
pattern: '^\\d{5}$',
},
}}
helperText="Enter 5-digit zip code"
/>
<TextField
label="Country"
required
fullWidth
value={data.country}
onChange={handleChange('country')}
/>
</Box>
<TextField
label="LinkedIn Profile"
fullWidth
value={data.linkedIn || ''}
onChange={handleChange('linkedIn')}
placeholder="https://linkedin.com/in/username"
/>
<TextField
label="GitHub Profile"
fullWidth
value={data.github || ''}
onChange={handleChange('github')}
placeholder="https://github.com/username"
/>
<TextField
label="Personal Website"
fullWidth
value={data.website || ''}
onChange={handleChange('website')}
placeholder="https://yourwebsite.com"
/>
</Box>
</Box>
);
}
@@ -1,104 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SkillsForm from './SkillsForm';
describe('SkillsForm', () => {
const mockOnChange = vi.fn();
beforeEach(() => {
mockOnChange.mockClear();
});
it('renders with no skills', () => {
render(<SkillsForm data={[]} onChange={mockOnChange} />);
expect(screen.getByText(/skills \*/i)).toBeInTheDocument();
expect(
screen.getByText(/no skills added yet\. please add at least one skill\./i)
).toBeInTheDocument();
});
it('renders existing skills as chips', () => {
const skills = ['JavaScript', 'React', 'TypeScript'];
render(<SkillsForm data={skills} onChange={mockOnChange} />);
expect(screen.getByText('JavaScript')).toBeInTheDocument();
expect(screen.getByText('React')).toBeInTheDocument();
expect(screen.getByText('TypeScript')).toBeInTheDocument();
});
it('adds a skill when Add button is clicked', async () => {
const user = userEvent.setup();
render(<SkillsForm data={[]} onChange={mockOnChange} />);
const input = screen.getByLabelText(/add a skill/i);
const addButton = screen.getByRole('button', { name: /add/i });
await user.type(input, 'Python');
await user.click(addButton);
expect(mockOnChange).toHaveBeenCalledWith(['Python']);
});
it('adds a skill when Enter key is pressed', async () => {
const user = userEvent.setup();
render(<SkillsForm data={[]} onChange={mockOnChange} />);
const input = screen.getByLabelText(/add a skill/i);
await user.type(input, 'Java{Enter}');
expect(mockOnChange).toHaveBeenCalledWith(['Java']);
});
it('does not add empty skills', async () => {
const user = userEvent.setup();
render(<SkillsForm data={[]} onChange={mockOnChange} />);
const addButton = screen.getByRole('button', { name: /add/i });
await user.click(addButton);
expect(mockOnChange).not.toHaveBeenCalled();
});
it('does not add duplicate skills', async () => {
const user = userEvent.setup();
const existingSkills = ['JavaScript'];
render(<SkillsForm data={existingSkills} onChange={mockOnChange} />);
const input = screen.getByLabelText(/add a skill/i);
const addButton = screen.getByRole('button', { name: /add/i });
await user.type(input, 'JavaScript');
await user.click(addButton);
expect(mockOnChange).not.toHaveBeenCalled();
});
it('removes a skill when delete icon is clicked', async () => {
const user = userEvent.setup();
const skills = ['JavaScript', 'Python'];
render(<SkillsForm data={skills} onChange={mockOnChange} />);
const deleteButtons = screen.getAllByTestId('CancelIcon');
await user.click(deleteButtons[0]!);
expect(mockOnChange).toHaveBeenCalledWith(['Python']);
});
it('clears input after adding a skill', async () => {
const user = userEvent.setup();
render(<SkillsForm data={[]} onChange={mockOnChange} />);
const input = screen.getByLabelText(/add a skill/i) as HTMLInputElement;
const addButton = screen.getByRole('button', { name: /add/i });
await user.type(input, 'C++');
await user.click(addButton);
// The component should clear the input after adding
// We can't directly check the state, but we can verify onChange was called
expect(mockOnChange).toHaveBeenCalledWith(['C++']);
});
});
@@ -1,85 +0,0 @@
import { useState } from 'react';
import {
Box,
TextField,
Typography,
Divider,
Button,
Chip,
Stack,
} from '@mui/material';
import { Add as AddIcon } from '@mui/icons-material';
import { ResumeData } from '../types';
interface SkillsFormProps {
data: ResumeData['skills'];
onChange: (data: ResumeData['skills']) => void;
}
export default function SkillsForm({ data, onChange }: SkillsFormProps) {
const [skillInput, setSkillInput] = useState('');
const handleAdd = () => {
if (skillInput.trim() && !data.includes(skillInput.trim())) {
onChange([...data, skillInput.trim()]);
setSkillInput('');
}
};
const handleRemove = (skillToRemove: string) => {
onChange(data.filter((skill) => skill !== skillToRemove));
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAdd();
}
};
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
Skills *
</Typography>
<Divider sx={{ mb: 2 }} />
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<TextField
label="Add a skill"
fullWidth
value={skillInput}
onChange={(e) => setSkillInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="e.g., JavaScript, Python, Project Management"
/>
<Button
variant="contained"
onClick={handleAdd}
startIcon={<AddIcon />}
sx={{ minWidth: 100 }}
>
Add
</Button>
</Box>
{data.length > 0 ? (
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{data.map((skill, index) => (
<Chip
key={index}
label={skill}
onDelete={() => handleRemove(skill)}
color="primary"
variant="outlined"
/>
))}
</Stack>
) : (
<Typography variant="body2" color="text.secondary">
No skills added yet. Please add at least one skill.
</Typography>
)}
</Box>
);
}
@@ -1,27 +0,0 @@
import { Box, TextField, Typography, Divider } from '@mui/material';
interface SummaryFormProps {
data: string;
onChange: (data: string) => void;
}
export default function SummaryForm({ data, onChange }: SummaryFormProps) {
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
Professional Summary
</Typography>
<Divider sx={{ mb: 2 }} />
<TextField
label="Summary"
fullWidth
multiline
rows={4}
value={data}
onChange={(e) => onChange(e.target.value)}
placeholder="Provide a brief professional summary highlighting your key qualifications and career objectives..."
/>
</Box>
);
}
@@ -1,109 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import WorkExperienceForm from './WorkExperienceForm';
import { ResumeData } from '../types';
describe('WorkExperienceForm', () => {
const mockData: ResumeData['workExperience'] = [];
const mockOnChange = vi.fn();
it('renders with no work experience entries', () => {
render(<WorkExperienceForm data={mockData} onChange={mockOnChange} />);
expect(screen.getByText(/work experience \*/i)).toBeInTheDocument();
expect(screen.getByText(/add work experience/i)).toBeInTheDocument();
});
it('adds a work experience entry when button is clicked', async () => {
const user = userEvent.setup();
render(<WorkExperienceForm data={mockData} onChange={mockOnChange} />);
const addButton = screen.getByText(/add work experience/i);
await user.click(addButton);
expect(mockOnChange).toHaveBeenCalledWith([
{
company: '',
position: '',
startDate: '',
endDate: '',
description: '',
current: false,
},
]);
});
it('renders existing work experience entries', () => {
const existingData: ResumeData['workExperience'] = [
{
company: 'Acme Corp',
position: 'Software Engineer',
startDate: '2020-01-01',
endDate: '2023-12-31',
description: 'Developed features',
current: false,
},
];
render(<WorkExperienceForm data={existingData} onChange={mockOnChange} />);
expect(screen.getByDisplayValue('Acme Corp')).toBeInTheDocument();
expect(screen.getByDisplayValue('Software Engineer')).toBeInTheDocument();
expect(screen.getByDisplayValue('2020-01-01')).toBeInTheDocument();
expect(screen.getByDisplayValue('Developed features')).toBeInTheDocument();
});
it('shows delete button for each entry', () => {
const existingData: ResumeData['workExperience'] = [
{
company: 'Test Company',
position: 'Developer',
startDate: '2020-01-01',
endDate: '2023-12-31',
description: 'Test description',
current: false,
},
];
render(<WorkExperienceForm data={existingData} onChange={mockOnChange} />);
const deleteButtons = screen.getAllByRole('button', { name: '' });
expect(deleteButtons.length).toBeGreaterThan(0);
});
it('disables end date when current is checked', () => {
const existingData: ResumeData['workExperience'] = [
{
company: 'Current Co',
position: 'Engineer',
startDate: '2023-01-01',
endDate: '',
description: 'Current job',
current: true,
},
];
render(<WorkExperienceForm data={existingData} onChange={mockOnChange} />);
const endDateInput = screen.getByLabelText(/end date/i);
expect(endDateInput).toBeDisabled();
});
it('displays the current job checkbox', () => {
const existingData: ResumeData['workExperience'] = [
{
company: 'Company',
position: 'Position',
startDate: '2023-01-01',
endDate: '',
description: 'Description',
current: false,
},
];
render(<WorkExperienceForm data={existingData} onChange={mockOnChange} />);
expect(screen.getByText(/i currently work here/i)).toBeInTheDocument();
});
});
@@ -1,153 +0,0 @@
import {
Box,
TextField,
Typography,
Divider,
Button,
Paper,
IconButton,
Checkbox,
FormControlLabel,
} from '@mui/material';
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { ResumeData } from '../types';
interface WorkExperienceFormProps {
data: ResumeData['workExperience'];
onChange: (data: ResumeData['workExperience']) => void;
}
export default function WorkExperienceForm({
data,
onChange,
}: WorkExperienceFormProps) {
const handleAdd = () => {
onChange([
...data,
{
company: '',
position: '',
startDate: '',
endDate: '',
description: '',
current: false,
},
]);
};
const handleRemove = (index: number) => {
onChange(data.filter((_, i) => i !== index));
};
const handleChange = (
index: number,
field: keyof ResumeData['workExperience'][0],
value: string | boolean
) => {
const newData = [...data];
newData[index] = { ...newData[index]!, [field]: value };
onChange(newData);
};
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
Work Experience *
</Typography>
<Divider sx={{ mb: 2 }} />
{data.map((experience, index) => (
<Paper
key={index}
elevation={1}
sx={{ p: 3, mb: 2, position: 'relative' }}
>
<IconButton
onClick={() => handleRemove(index)}
sx={{ position: 'absolute', top: 20, right: 4 }}
color="error"
size="small"
>
<DeleteIcon />
</IconButton>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Company"
required
fullWidth
value={experience.company}
onChange={(e) => handleChange(index, 'company', e.target.value)}
/>
<TextField
label="Position"
required
fullWidth
value={experience.position}
onChange={(e) => handleChange(index, 'position', e.target.value)}
/>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="Start Date"
type="date"
required
fullWidth
InputLabelProps={{ shrink: true }}
value={experience.startDate}
onChange={(e) =>
handleChange(index, 'startDate', e.target.value)
}
/>
<TextField
label="End Date"
type="date"
required={!experience.current}
fullWidth
InputLabelProps={{ shrink: true }}
value={experience.endDate}
onChange={(e) => handleChange(index, 'endDate', e.target.value)}
disabled={experience.current}
/>
</Box>
<FormControlLabel
control={
<Checkbox
checked={experience.current}
onChange={(e) =>
handleChange(index, 'current', e.target.checked)
}
/>
}
label="I currently work here"
/>
<TextField
label="Description"
required
fullWidth
multiline
rows={3}
value={experience.description}
onChange={(e) =>
handleChange(index, 'description', e.target.value)
}
placeholder="Describe your responsibilities and achievements..."
/>
</Box>
</Paper>
))}
<Button
startIcon={<AddIcon />}
onClick={handleAdd}
variant="outlined"
fullWidth
>
Add Work Experience
</Button>
</Box>
);
}
-18
View File
@@ -1,18 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
#root {
min-height: 100vh;
}
-10
View File
@@ -1,10 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
-1
View File
@@ -1 +0,0 @@
import '@testing-library/jest-dom';
-37
View File
@@ -1,37 +0,0 @@
export interface ResumeData {
personalInfo: {
fullName: string;
email: string;
phone: string;
address?: string;
city: string;
state: string;
zipCode?: string;
country?: string;
linkedIn?: string;
github?: string;
website?: string;
};
workExperience: Array<{
company: string;
position: string;
startDate: string;
endDate: string;
description: string;
current: boolean;
}>;
education?: Array<{
institution: string;
degree: string;
field?: string;
graduationDate: string;
gpa?: string;
}>;
certifications?: Array<{
certificationName: string;
acquiredDate: string;
areaOfExpertise?: string;
}>;
skills: string[];
summary?: string;
}
-28
View File
@@ -1,28 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
"jsx": "react-jsx",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
/* Linting - Strict Mode */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
-14
View File
@@ -1,14 +0,0 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**'],
testTimeout: 10000,
},
});
+14 -26
View File
@@ -9,10 +9,10 @@
"copy:icons": "mkdir -p dist/icons && cp src/icons/*.svg dist/icons/",
"test": "vitest",
"test:ui": "vitest --ui",
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint . --ext .ts,.tsx",
"lint:fix": "ESLINT_USE_FLAT_CONFIG=false eslint . --ext .ts,.tsx --fix",
"format": "prettier --write \"{src,job-application-demo/src}/**/*.{ts,tsx,json,css}\"",
"format:check": "prettier --check \"{src,job-application-demo/src}/**/*.{ts,tsx,json,css}\""
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,json,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,json,css}\""
},
"keywords": [
"firefox",
@@ -21,34 +21,22 @@
"autofill"
],
"author": "",
"license": "MIT",
"license": "ISC",
"packageManager": "pnpm@10.18.1",
"devDependencies": {
"@types/firefox-webext-browser": "^143.0.0",
"@types/node": "^24.8.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1",
"@vitejs/plugin-react": "^5.0.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"@types/firefox-webext-browser": "^120.0.4",
"@types/node": "^20.14.9",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"@vitest/ui": "^1.6.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"jsdom": "^27.0.1",
"prettier": "^3.3.2",
"typescript": "^5.5.3",
"vite": "^7.1.10",
"vite": "^5.3.2",
"vite-plugin-web-extension": "^4.1.6",
"vitest": "^3.2.4"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"vitest": "^1.6.0"
}
}
+793 -1910
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
packages:
- 'job-application-demo'
onlyBuiltDependencies:
- esbuild
+44
View File
@@ -0,0 +1,44 @@
John Smith
john.smith@email.com
(555) 123-4567
San Francisco, CA 94102
PROFESSIONAL SUMMARY
Experienced software engineer with 5+ years of expertise in full-stack web development.
Proficient in modern JavaScript frameworks and cloud technologies.
WORK EXPERIENCE
Senior Software Engineer
Tech Solutions Inc. | San Francisco, CA
January 2020 - Present
- Lead development of customer-facing web applications
- Mentor junior developers and conduct code reviews
- Improved application performance by 40%
Software Developer
Digital Innovations | San Jose, CA
June 2018 - December 2019
- Built RESTful APIs using Node.js and Express
- Implemented responsive UI components with React
- Collaborated with cross-functional teams
EDUCATION
Bachelor of Science in Computer Science
Stanford University | Stanford, CA
Graduated: May 2018
GPA: 3.8/4.0
SKILLS
JavaScript, TypeScript, React, Node.js, Python, Java, SQL, MongoDB, PostgreSQL,
AWS, Docker, Kubernetes, Git, CI/CD, Agile, REST APIs, GraphQL
CERTIFICATIONS
- AWS Certified Solutions Architect
- Google Cloud Professional Developer
PROJECTS
- Open source contributor to popular JavaScript libraries
- Personal portfolio website: https://johnsmith.dev
- LinkedIn: https://linkedin.com/in/johnsmith
Binary file not shown.
Binary file not shown.
-52
View File
@@ -1,52 +0,0 @@
John Smith
john.smith@email.com
+1-(555)-123-4567
123 Market Street
San Francisco, CA 94102
United States
LinkedIn: https://linkedin.com/in/johnsmith
GitHub: https://github.com/johnsmith
Website: https://johnsmith.dev
PROFESSIONAL SUMMARY
Experienced software engineer with 5+ years of expertise in full-stack web development.
Proficient in modern JavaScript frameworks and cloud technologies. Passionate about building
scalable applications and mentoring junior developers.
WORK EXPERIENCE
Senior Software Engineer
Tech Solutions Inc. | San Francisco, CA
January 2020 - Present
Lead development of customer-facing web applications using React and Node.js. Mentor junior
developers and conduct code reviews. Improved application performance by 40% through optimization
and refactoring. Implemented CI/CD pipelines and cloud infrastructure on AWS.
Software Developer
Digital Innovations | San Jose, CA
June 2018 - December 2019
Built RESTful APIs using Node.js and Express. Implemented responsive UI components with React.
Collaborated with cross-functional teams to deliver high-quality software solutions. Participated
in agile development processes and sprint planning.
EDUCATION
Bachelor of Science in Computer Science
Stanford University
Field of Study: Computer Science
Graduated: May 2018
GPA: 3.8
CERTIFICATIONS
AWS Certified Solutions Architect
Acquired: March 2021
Area of Expertise: Cloud Architecture and Infrastructure
Google Cloud Professional Developer
Acquired: September 2020
Area of Expertise: Cloud Development and Deployment
SKILLS
JavaScript, TypeScript, React, Node.js, Python, Java, SQL, MongoDB, PostgreSQL,
AWS, Docker, Kubernetes, Git, CI/CD, Agile, REST APIs, GraphQL
Binary file not shown.
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
John Smith
john.smith@email.com
+1-(555)-123-4567
San Francisco, CA
WORK EXPERIENCE
Senior Software Engineer
Tech Solutions Inc.
January 2020 - Present
Lead development of customer-facing web applications using React and Node.js. Mentor junior
developers and conduct code reviews. Improved application performance by 40% through optimization
and refactoring. Implemented CI/CD pipelines and cloud infrastructure on AWS.
Software Developer
Digital Innovations
June 2018 - December 2019
Built RESTful APIs using Node.js and Express. Implemented responsive UI components with React.
Collaborated with cross-functional teams to deliver high-quality software solutions. Participated
in agile development processes and sprint planning.
SKILLS
JavaScript, TypeScript, React, Node.js, Python, Java, SQL, MongoDB, PostgreSQL,
AWS, Docker, Kubernetes, Git, CI/CD, Agile, REST APIs, GraphQL
+52 -2
View File
@@ -4,9 +4,59 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JobSpeed</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div id="root"></div>
<script src="popup.tsx" type="module"></script>
<div class="popup-container">
<header>
<h1>JobSpeed</h1>
<p class="subtitle">Auto-fill job applications</p>
</header>
<main>
<section class="resume-section">
<h2>Resume</h2>
<div id="resume-status" class="status-container">
<p class="status-text">No resume uploaded</p>
</div>
<div class="upload-container">
<input
type="file"
id="resume-file-input"
accept=".pdf,.txt,.doc,.docx"
style="display: none"
/>
<button id="upload-button" class="primary-button">
Upload Resume
</button>
</div>
<div id="resume-preview" class="resume-preview hidden">
<h3>Resume Data</h3>
<div id="resume-data"></div>
<button id="clear-resume-button" class="secondary-button">
Clear Resume
</button>
</div>
</section>
<section class="actions-section">
<h2>Actions</h2>
<button id="scan-page-button" class="primary-button">
Scan Current Page
</button>
<p class="help-text">
Click to detect form fields on the current page and preview
auto-fill suggestions
</p>
</section>
</main>
<footer>
<p class="version">v1.0.0</p>
</footer>
</div>
<script src="popup.ts" type="module"></script>
</body>
</html>
+201
View File
@@ -0,0 +1,201 @@
import type { ResumeData, MessageResponse } from './types';
document.addEventListener('DOMContentLoaded', () => {
const uploadButton = document.getElementById(
'upload-button'
) as HTMLButtonElement;
const resumeFileInput = document.getElementById(
'resume-file-input'
) as HTMLInputElement;
const resumeStatus = document.getElementById('resume-status');
const resumePreview = document.getElementById('resume-preview');
const resumeDataDiv = document.getElementById('resume-data');
const clearResumeButton = document.getElementById(
'clear-resume-button'
) as HTMLButtonElement;
const scanPageButton = document.getElementById(
'scan-page-button'
) as HTMLButtonElement;
// Load and display existing resume data
void loadResumeStatus();
uploadButton.addEventListener('click', () => {
resumeFileInput.click();
});
resumeFileInput.addEventListener('change', (event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
void (async () => {
try {
const text = await file.text();
const resumeData = parseResume(text);
await browser.runtime.sendMessage({
type: 'SAVE_RESUME_DATA',
data: resumeData,
});
await loadResumeStatus();
alert('Resume uploaded successfully!');
} catch (error) {
console.error('Error uploading resume:', error);
alert('Error uploading resume. Please try again.');
}
})();
});
clearResumeButton.addEventListener('click', () => {
if (!confirm('Are you sure you want to clear your resume data?')) {
return;
}
void (async () => {
try {
await browser.runtime.sendMessage({
type: 'SAVE_RESUME_DATA',
data: null,
});
await loadResumeStatus();
alert('Resume cleared successfully!');
} catch (error) {
console.error('Error clearing resume:', error);
alert('Error clearing resume. Please try again.');
}
})();
});
scanPageButton.addEventListener('click', () => {
void (async () => {
try {
const tabs = await browser.tabs.query({
active: true,
currentWindow: true,
});
const activeTab = tabs[0];
if (!activeTab?.id) {
alert('No active tab found');
return;
}
await browser.tabs.sendMessage(activeTab.id, {
type: 'SCAN_PAGE',
});
window.close();
} catch (error) {
console.error('Error scanning page:', error);
alert('Error scanning page. Please try again.');
}
})();
});
async function loadResumeStatus() {
try {
const response = (await browser.runtime.sendMessage({
type: 'GET_RESUME_DATA',
})) as MessageResponse;
if (response.success && response.data) {
const resumeData = response.data as ResumeData;
displayResumeData(resumeData);
resumeStatus?.classList.add('has-resume');
if (resumeStatus) {
const statusText = resumeStatus.querySelector('.status-text');
if (statusText) {
statusText.textContent = 'Resume uploaded ✓';
}
}
resumePreview?.classList.remove('hidden');
if (scanPageButton) {
scanPageButton.disabled = false;
}
} else {
resumeStatus?.classList.remove('has-resume');
if (resumeStatus) {
const statusText = resumeStatus.querySelector('.status-text');
if (statusText) {
statusText.textContent = 'No resume uploaded';
}
}
resumePreview?.classList.add('hidden');
if (scanPageButton) {
scanPageButton.disabled = true;
}
}
} catch (error) {
console.error('Error loading resume status:', error);
}
}
function displayResumeData(data: ResumeData) {
if (!resumeDataDiv) return;
const html = `
<p><strong>Name:</strong> ${data.personalInfo.fullName}</p>
<p><strong>Email:</strong> ${data.personalInfo.email}</p>
<p><strong>Phone:</strong> ${data.personalInfo.phone}</p>
<p><strong>Location:</strong> ${data.personalInfo.city}, ${data.personalInfo.state}</p>
<p><strong>Skills:</strong> ${data.skills.slice(0, 5).join(', ')}${data.skills.length > 5 ? '...' : ''}</p>
`;
resumeDataDiv.innerHTML = html;
}
function parseResume(text: string): ResumeData {
// Basic resume parser - extracts email, phone, and text content
const emailRegex = /[\w.-]+@[\w.-]+\.\w+/g;
const phoneRegex =
/(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g;
const email = text.match(emailRegex)?.[0] || '';
const phone = text.match(phoneRegex)?.[0] || '';
// Extract name (assume first line is name)
const lines = text.split('\n').filter((line) => line.trim());
const fullName = lines[0] || '';
// Basic skill extraction (look for common keywords)
const skillKeywords = [
'JavaScript',
'TypeScript',
'Python',
'Java',
'React',
'Node.js',
'SQL',
'HTML',
'CSS',
'Git',
'AWS',
'Azure',
'Docker',
'Kubernetes',
];
const skills = skillKeywords.filter((skill) =>
text.toLowerCase().includes(skill.toLowerCase())
);
return {
personalInfo: {
fullName,
email,
phone,
address: '',
city: '',
state: '',
zipCode: '',
country: '',
},
workExperience: [],
education: [],
skills,
summary: text.substring(0, 500),
};
}
});
-353
View File
@@ -1,353 +0,0 @@
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import {
ThemeProvider,
createTheme,
CssBaseline,
Box,
AppBar,
Toolbar,
Typography,
Paper,
Button,
Alert,
Chip,
Stack,
Divider,
} from '@mui/material';
import {
Upload as UploadIcon,
Delete as DeleteIcon,
Search as SearchIcon,
Description as DescriptionIcon,
} from '@mui/icons-material';
import type { ResumeData } from './types';
// Create a custom theme for the extension
const theme = createTheme({
palette: {
primary: {
main: '#667eea',
},
secondary: {
main: '#764ba2',
},
background: {
default: '#f5f5f5',
},
},
typography: {
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif',
},
});
const PopupApp: React.FC = () => {
const [resumeData, setResumeData] = useState<ResumeData | null>(null);
useEffect(() => {
void loadResumeStatus();
}, []);
const loadResumeStatus = async () => {
try {
const response = (await browser.runtime.sendMessage({
type: 'GET_RESUME_DATA',
})) as { success: boolean; data?: ResumeData | null };
if (response.success && response.data) {
setResumeData(response.data);
}
} catch (error) {
console.error('Error loading resume:', error);
}
};
const handleUploadResume = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.pdf,.txt,.doc,.docx';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const parsedData = parseResume(text);
await browser.runtime.sendMessage({
type: 'SAVE_RESUME_DATA',
data: parsedData,
});
await loadResumeStatus();
alert('Resume uploaded successfully!');
} catch (error) {
console.error('Error uploading resume:', error);
alert('Error uploading resume. Please try again.');
}
};
input.click();
};
const handleClearResume = async () => {
if (!confirm('Are you sure you want to clear your resume data?')) {
return;
}
try {
await browser.runtime.sendMessage({
type: 'SAVE_RESUME_DATA',
data: null,
});
await loadResumeStatus();
alert('Resume cleared successfully!');
} catch (error) {
console.error('Error clearing resume:', error);
alert('Error clearing resume. Please try again.');
}
};
const handleScanPage = async () => {
try {
const tabs = await browser.tabs.query({
active: true,
currentWindow: true,
});
const activeTab = tabs[0];
if (!activeTab?.id) {
alert('No active tab found');
return;
}
await browser.tabs.sendMessage(activeTab.id, {
type: 'SCAN_PAGE',
});
window.close();
} catch (error) {
console.error('Error scanning page:', error);
alert('Error scanning page. Please try again.');
}
};
const parseResume = (text: string): ResumeData => {
const lines = text.split('\n').filter((line) => line.trim());
return {
personalInfo: {
fullName:
lines
.find((line) => line.match(/^[A-Z][a-z]+ [A-Z][a-z]+/))
?.trim() || '',
email:
lines
.find((line) =>
line.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/)
)
?.trim() || '',
phone:
lines
.find((line) =>
line.match(/(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/)
)
?.trim() || '',
address:
lines
.find((line) => line.match(/\d+\s+\w+\s+(St|Ave|Rd|Blvd|Ln|Dr)/i))
?.trim() || '',
city: '',
state: '',
zipCode: '',
country: '',
},
workExperience: [],
education: [],
skills: [],
summary: '',
};
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ width: 400, bgcolor: 'background.default' }}>
{/* Header */}
<AppBar
position="static"
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}
>
<Toolbar>
<Box sx={{ flexGrow: 1, textAlign: 'center' }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 600 }}>
JobSpeed
</Typography>
<Typography variant="caption" sx={{ opacity: 0.9 }}>
Auto-fill job applications
</Typography>
</Box>
</Toolbar>
</AppBar>
{/* Main Content */}
<Box sx={{ p: 2.5 }}>
{/* Resume Section */}
<Paper elevation={0} sx={{ p: 2, mb: 3, bgcolor: 'white' }}>
<Stack spacing={2}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Resume
</Typography>
{resumeData && (
<Chip
icon={<DescriptionIcon />}
label="Uploaded"
color="success"
size="small"
/>
)}
</Box>
{!resumeData ? (
<Alert severity="info" sx={{ mb: 1 }}>
No resume uploaded
</Alert>
) : (
<Alert severity="success" sx={{ mb: 1 }}>
Resume data loaded
</Alert>
)}
<Button
variant="contained"
startIcon={<UploadIcon />}
onClick={handleUploadResume}
fullWidth
sx={{
background:
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
textTransform: 'none',
py: 1.5,
}}
>
Upload Resume
</Button>
{resumeData && (
<>
<Divider />
<Box sx={{ bgcolor: '#f8f9fa', p: 2, borderRadius: 1 }}>
<Typography
variant="subtitle2"
sx={{ mb: 1, fontWeight: 600 }}
>
Resume Data
</Typography>
<Stack
spacing={0.5}
sx={{ fontSize: '0.875rem', color: '#666' }}
>
{resumeData.personalInfo.fullName && (
<Typography variant="body2">
<strong>Name:</strong>{' '}
{resumeData.personalInfo.fullName}
</Typography>
)}
{resumeData.personalInfo.email && (
<Typography variant="body2">
<strong>Email:</strong>{' '}
{resumeData.personalInfo.email}
</Typography>
)}
{resumeData.personalInfo.phone && (
<Typography variant="body2">
<strong>Phone:</strong>{' '}
{resumeData.personalInfo.phone}
</Typography>
)}
{resumeData.personalInfo.address && (
<Typography variant="body2">
<strong>Address:</strong>{' '}
{resumeData.personalInfo.address}
</Typography>
)}
</Stack>
</Box>
<Button
variant="outlined"
startIcon={<DeleteIcon />}
onClick={() => void handleClearResume()}
fullWidth
color="error"
sx={{ textTransform: 'none' }}
>
Clear Resume
</Button>
</>
)}
</Stack>
</Paper>
{/* Actions Section */}
<Paper elevation={0} sx={{ p: 2, bgcolor: 'white' }}>
<Stack spacing={2}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Actions
</Typography>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={() => void handleScanPage()}
fullWidth
sx={{
background:
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
textTransform: 'none',
py: 1.5,
}}
>
Scan Current Page
</Button>
<Typography variant="caption" color="text.secondary">
Click to detect form fields on the current page and preview
auto-fill suggestions
</Typography>
</Stack>
</Paper>
</Box>
{/* Footer */}
<Box
sx={{
p: 1.5,
textAlign: 'center',
borderTop: '1px solid',
borderColor: 'divider',
bgcolor: '#f8f9fa',
}}
>
<Typography variant="caption" color="text.secondary">
v1.0.0
</Typography>
</Box>
</Box>
</ThemeProvider>
);
};
// Mount the React app
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(<PopupApp />);
}
+18 -75
View File
@@ -28,7 +28,7 @@ export class FieldMatcher {
// Personal Info mappings - check specific names first before generic "name"
if (this.matchesAny(fieldText, ['first name', 'firstname', 'given name'])) {
const firstName =
this.resumeData.personalInfo.fullName.split(' ')[0] ?? '';
this.resumeData.personalInfo.fullName.split(' ')[0] || '';
return this.createMapping(field, 'personalInfo.firstName', firstName);
}
@@ -41,7 +41,7 @@ export class FieldMatcher {
])
) {
const parts = this.resumeData.personalInfo.fullName.split(' ');
const lastName = parts[parts.length - 1] ?? '';
const lastName = parts[parts.length - 1] || '';
return this.createMapping(field, 'personalInfo.lastName', lastName);
}
@@ -73,7 +73,7 @@ export class FieldMatcher {
return this.createMapping(
field,
'personalInfo.address',
this.resumeData.personalInfo.address ?? ''
this.resumeData.personalInfo.address
);
}
@@ -105,7 +105,7 @@ export class FieldMatcher {
return this.createMapping(
field,
'personalInfo.zipCode',
this.resumeData.personalInfo.zipCode ?? ""
this.resumeData.personalInfo.zipCode
);
}
@@ -113,7 +113,7 @@ export class FieldMatcher {
return this.createMapping(
field,
'personalInfo.country',
this.resumeData.personalInfo.country ?? ''
this.resumeData.personalInfo.country
);
}
@@ -121,15 +121,7 @@ export class FieldMatcher {
return this.createMapping(
field,
'personalInfo.linkedIn',
this.resumeData.personalInfo.linkedIn ?? ''
);
}
if (this.matchesAny(fieldText, ['github', 'git hub', 'github url'])) {
return this.createMapping(
field,
'personalInfo.github',
this.resumeData.personalInfo.github ?? ''
this.resumeData.personalInfo.linkedIn || ''
);
}
@@ -137,7 +129,7 @@ export class FieldMatcher {
return this.createMapping(
field,
'personalInfo.website',
this.resumeData.personalInfo.website ?? ''
this.resumeData.personalInfo.website || ''
);
}
@@ -154,7 +146,7 @@ export class FieldMatcher {
return this.createMapping(
field,
'workExperience.company',
latestJob?.company ?? ''
latestJob?.company || ''
);
}
@@ -171,7 +163,7 @@ export class FieldMatcher {
return this.createMapping(
field,
'workExperience.position',
latestJob?.position ?? ''
latestJob?.position || ''
);
}
@@ -184,20 +176,20 @@ export class FieldMatcher {
'institution',
])
) {
const latestEd = this.resumeData.education?.[0];
const latestEd = this.resumeData.education[0];
return this.createMapping(
field,
'education.institution',
latestEd?.institution ?? ''
latestEd?.institution || ''
);
}
if (this.matchesAny(fieldText, ['degree', 'qualification'])) {
const latestEd = this.resumeData.education?.[0];
const latestEd = this.resumeData.education[0];
return this.createMapping(
field,
'education.degree',
latestEd?.degree ?? ''
latestEd?.degree || ''
);
}
@@ -209,66 +201,17 @@ export class FieldMatcher {
'specialization',
])
) {
const latestEd = this.resumeData.education?.[0];
const latestEd = this.resumeData.education[0];
return this.createMapping(
field,
'education.field',
latestEd?.field ?? ''
latestEd?.field || ''
);
}
if (this.matchesAny(fieldText, ['gpa'])) {
const latestEd = this.resumeData.education?.[0];
return this.createMapping(field, 'education.gpa', latestEd?.gpa ?? '');
}
// Certifications
if (
this.matchesAny(fieldText, [
'certification',
'certificate',
'certification name',
'cert name',
])
) {
const latestCert = this.resumeData.certifications?.[0];
return this.createMapping(
field,
'certifications.certificationName',
latestCert?.certificationName ?? ''
);
}
if (
this.matchesAny(fieldText, [
'certification date',
'cert date',
'acquired',
'acquired date',
])
) {
const latestCert = this.resumeData.certifications?.[0];
return this.createMapping(
field,
'certifications.acquiredDate',
latestCert?.acquiredDate ?? ''
);
}
if (
this.matchesAny(fieldText, [
'area of expertise',
'expertise',
'certification area',
'cert area',
])
) {
const latestCert = this.resumeData.certifications?.[0];
return this.createMapping(
field,
'certifications.areaOfExpertise',
latestCert?.areaOfExpertise ?? ''
);
const latestEd = this.resumeData.education[0];
return this.createMapping(field, 'education.gpa', latestEd?.gpa || '');
}
// Skills
@@ -294,7 +237,7 @@ export class FieldMatcher {
return this.createMapping(
field,
'summary',
this.resumeData.summary ?? ''
this.resumeData.summary || ''
);
}
+5 -11
View File
@@ -3,13 +3,12 @@ export interface ResumeData {
fullName: string;
email: string;
phone: string;
address?: string;
address: string;
city: string;
state: string;
zipCode?: string;
country?: string;
zipCode: string;
country: string;
linkedIn?: string;
github?: string;
website?: string;
};
workExperience: Array<{
@@ -20,18 +19,13 @@ export interface ResumeData {
description: string;
current: boolean;
}>;
education?: Array<{
education: Array<{
institution: string;
degree: string;
field?: string;
field: string;
graduationDate: string;
gpa?: string;
}>;
certifications?: Array<{
certificationName: string;
acquiredDate: string;
areaOfExpertise?: string;
}>;
skills: string[];
summary?: string;
}
+1 -2
View File
@@ -5,7 +5,6 @@
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"jsx": "react-jsx",
/* Bundler mode */
"moduleResolution": "bundler",
@@ -31,5 +30,5 @@
}
},
"include": ["src/**/*", "vite.config.ts"],
"exclude": ["node_modules", "dist", "job-application-demo"]
"exclude": ["node_modules", "dist"]
}
-2
View File
@@ -1,6 +1,5 @@
import { defineConfig } from 'vite';
import webExtension from 'vite-plugin-web-extension';
import react from '@vitejs/plugin-react';
import path from 'path';
import { fileURLToPath } from 'url';
@@ -9,7 +8,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
root: 'src',
plugins: [
react(),
webExtension({
manifest: 'manifest.json',
watchFilePaths: ['**/*'],
-5
View File
@@ -8,11 +8,6 @@ export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/job-application-demo/**',
],
},
resolve: {
alias: {