Skip to content
Merged
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
107 changes: 107 additions & 0 deletions web/e2e/resume-import.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { expect, test, type Page } from "@playwright/test";

import { RESUME_IMPORT_MESSAGES } from "../src/constants/messages";
import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth";

/**
* 手持ち PDF 経歴書のフォーム流し込み(ADR-0024 / #528)E2E。
*
* シナリオ:
* 1. 空フォームの新規ユーザは「PDF から自動入力」パネルを見る
* 2. PDF をアップロード(POST /api/agent/resume-import/pdf をモック)
* 3. 抽出結果がフォーム(氏名・職務要約・自己PR)に反映される(DB 非更新)
* 4. 反映成功のトーストが出る
*/

/** 職務経歴書の最小 API モック。latest は 404 = 空フォーム(パネルが出る条件)。 */
async function setupEmptyResume(page: Page) {
await page.route("**/api/master-data/qualification", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
);
await page.route("**/api/master-data/technology-stack", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
);
await page.route("**/api/resumes/latest", (route) =>
route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ code: "NOT_FOUND", message: "not found" }),
}),
);
}

test.describe("PDF 経歴書インポート", () => {
test.beforeEach(async ({ page }) => {
await setupAuth(page);
await setupEmptyResume(page);
});

test("空フォームで PDF をアップロードすると抽出結果が反映される", async ({ page }) => {
await page.route("**/api/agent/resume-import/pdf", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
full_name: "山田 太郎",
career_summary: "バックエンドエンジニアとして 5 年の経験があります。",
self_pr: "品質と保守性を重視した設計が得意です。",
experiences: [
{
company: "株式会社サンプル",
business_description: "受託開発",
start_date: "2020-04",
end_date: "2023-03",
description: "API 開発を担当。",
},
],
}),
});
});

await page.goto("/career");
await waitForAuthenticatedLayout(page);

// 空フォームの新規ユーザにパネルが見える
await expect(page.getByText(RESUME_IMPORT_MESSAGES.HEADING)).toBeVisible();

// 隠しファイル input に PDF を投入する(マジックバイト付きのダミー)
await page
.getByLabel(RESUME_IMPORT_MESSAGES.UPLOAD_LABEL)
.setInputFiles({
name: "resume.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.7\n...dummy..."),
});

// 抽出結果がフォームへ反映される(空フォームなので確認ダイアログは出ない)
await expect(page.getByText(RESUME_IMPORT_MESSAGES.APPLIED_TOAST)).toBeVisible();
// 氏名入力に抽出値が入る
await expect(page.getByPlaceholder("例: 山田 太郎")).toHaveValue("山田 太郎");
});

test("非対応 PDF(スキャン)はエラーメッセージを表示する", async ({ page }) => {
await page.route("**/api/agent/resume-import/pdf", async (route) => {
await route.fulfill({
status: 422,
contentType: "application/json",
body: JSON.stringify({
code: "VALIDATION_ERROR",
message: "テキストを含む PDF のみ対応しています。スキャン画像の PDF は読み取れないため、お手数ですが手入力をお願いします。",
}),
});
});

await page.goto("/career");
await waitForAuthenticatedLayout(page);

await page
.getByLabel(RESUME_IMPORT_MESSAGES.UPLOAD_LABEL)
.setInputFiles({
name: "scan.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.7\n...scan..."),
});

await expect(page.getByText(/テキストを含む PDF のみ対応/)).toBeVisible();

Check failure on line 105 in web/e2e/resume-import.spec.ts

View workflow job for this annotation

GitHub Actions / tests / test-e2e

[chromium] › e2e/resume-import.spec.ts:82:3 › PDF 経歴書インポート › 非対応 PDF(スキャン)はエラーメッセージを表示する

1) [chromium] › e2e/resume-import.spec.ts:82:3 › PDF 経歴書インポート › 非対応 PDF(スキャン)はエラーメッセージを表示する ────── Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByText(/テキストを含む PDF のみ対応/) Expected: visible Error: strict mode violation: getByText(/テキストを含む PDF のみ対応/) resolved to 2 elements: 1) <p class="_hint_yqgz8_21">手持ちの職務経歴書 PDF を読み込むと、氏名・職務要約・自己PR・職歴をフォームに反映します。内…</p> aka getByText('手持ちの職務経歴書 PDF') 2) <p class="_message_1t2fe_47">テキストを含む PDF のみ対応しています。スキャン画像の PDF は読み取れないため、お手数です…</p> aka getByText('テキストを含む PDF のみ対応しています。スキャン画像の PDF は読み取れないため、お手数ですが手入力をお願いします。') Call log: - Expect "toBeVisible" with timeout 5000ms - waiting for getByText(/テキストを含む PDF のみ対応/) 103 | }); 104 | > 105 | await expect(page.getByText(/テキストを含む PDF のみ対応/)).toBeVisible(); | ^ 106 | }); 107 | }); 108 | at /home/runner/work/devforge/devforge/web/e2e/resume-import.spec.ts:105:54
});
});
25 changes: 4 additions & 21 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"ws": "^8.21.0",
"undici": "^7.28.0",
"fast-uri": "^3.1.4",
"sharp": "^0.35.3"
"sharp": "^0.35.3",
"brace-expansion": "^5.0.8"
}
}
9 changes: 9 additions & 0 deletions web/scripts/audit-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ const ALLOWLIST = {
"dev サーバーは Vite(Linux)、esbuild serve は未使用のため到達不能。dev 依存。",
reviewBy: "2026-09-30 (vite 8 / Rolldown 移行で esbuild 0.28.1 化を目指す)",
},
"GHSA-qwww-vcr4-c8h2": {
reason:
"React Router の RSC(framework)モードでの CSRF バイパス。DevForge は SPA 構成で " +
"main.tsx の BrowserRouter のみを使い、RSC / framework モード(@react-router/* の " +
"server 実行・action)を一切使わないため攻撃面に到達しない。react-router-dom 7.x に " +
"前進修正版が無く(最新 7.18.1 も脆弱範囲)、修正は v8 メジャー or 7.11.0 への " +
"ダウングレードのみで、いずれも本 CSRF に無関係な破壊的変更のため時限的に許容。",
reviewBy: "2026-10-31 (react-router v8 移行 or 7.x パッチ提供を評価する)",
},
};

const BLOCKING_SEVERITIES = new Set(["high", "critical"]);
Expand Down
15 changes: 15 additions & 0 deletions web/src/api/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { PATHS } from "./paths";
import type {
AgentChatRequest,
AgentChatResponse,
ResumeImportResponse,
TaskAcceptedResponse,
TaskStatusResponse,
} from "./types";
Expand Down Expand Up @@ -51,3 +52,17 @@ export function fetchResumeDraftPdfBlobUrl(): Promise<string> {
FALLBACK_MESSAGES.RESUME_DRAFT,
);
}

/**
* 手持ちの PDF 経歴書をアップロードして構造化抽出する(ADR-0024 / #527)。
* テキスト埋め込み PDF のみ対応。DB は更新されず、抽出結果(Resume 互換 payload)を返す。
* 失敗時は ApiError(422 = 非対応/破損 PDF・サイズ超過 / 502 = 抽出失敗 / 429 = レート上限)を送出する。
*/
export function importResumePdf(file: File): Promise<ResumeImportResponse> {
const formData = new FormData();
formData.append("file", file);
return request<ResumeImportResponse>(PATHS.agent.resumeImportPdf, {
method: "POST",
body: formData,
});
}
6 changes: 5 additions & 1 deletion web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,12 @@ export async function request<T>(
_isRetry = false,
): Promise<T> {
const method = (options.method ?? "GET").toUpperCase();
// FormData(multipart アップロード)のときは Content-Type を指定しない。
// ブラウザが boundary 付きの multipart/form-data を自動設定するため、明示すると壊れる。
const isFormData =
typeof FormData !== "undefined" && options.body instanceof FormData;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(isFormData ? {} : { "Content-Type": "application/json" }),
...((options.headers as Record<string, string>) ?? {}),
};

Expand Down
1 change: 1 addition & 0 deletions web/src/api/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const PATHS = {
resumeDraftRun: "/api/agent/resume-draft/run",
resumeDraftStatus: "/api/agent/resume-draft/status",
resumeDraftPdf: "/api/agent/resume-draft/pdf",
resumeImportPdf: "/api/agent/resume-import/pdf",
},
resumes: {
base: "/api/resumes",
Expand Down
3 changes: 3 additions & 0 deletions web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ export type AgentChatRequest = Schemas["AgentChatRequest"];
/** Agent チャットのレスポンス。backend `schemas/agent.py:AgentChatResponse`。 */
export type AgentChatResponse = Schemas["AgentChatResponse"];

/** PDF 経歴書の抽出結果。backend `schemas/agent.py:ResumeImportResponse`(ADR-0024)。 */
export type ResumeImportResponse = Schemas["ResumeImportResponse"];

/** resume state へ適用する差分。backend `schemas/agent.py:AgentOperation`。 */
export type AgentOperation = Schemas["AgentOperation"];

Expand Down
18 changes: 18 additions & 0 deletions web/src/components/forms/CareerResumeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import { useImportPanelLayout } from "../../hooks/career/useImportPanelLayout";
import { useResumeImportAssist } from "../../hooks/career/useResumeImportAssist";
import { useDocumentForm } from "../../hooks/useDocumentForm";
import { clearCareerDraft, loadCareerDraft, saveCareerDraft } from "../../utils/careerDraft";
import { hasCareerFormContent } from "../../utils/resumeImport";
import { buildCareerPayload } from "../../payloadBuilders";
import { useCareerFormValidationFocus } from "../../hooks/career/useCareerFormValidationFocus";
import { useQualifications, useTechnologyStacks } from "../../hooks/useMasterData";
import { useCareerExportActions } from "../../hooks/career/useCareerExportActions";
import { useMessageToast } from "../ui/toast";
import { AgentChatWidget } from "./AgentChatWidget";
import { ResumeImportPanel } from "./ResumeImportPanel";
import shared from "../../styles/shared.module.css";
import { ConfirmDialog } from "../ConfirmDialog";
import { useLoginPrompt } from "../auth/loginPromptContext";
Expand Down Expand Up @@ -125,6 +127,12 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean
/** 未保存マーク(🔴)の表示判定に使う dirty マップ */
const dirty = useCareerDirty(form, baseline);

// PDF 自動入力の導線は「保存済み経歴書が無く、ロード完了済みで、フォームが空」のときだけ出す
// (ADR-0024 / #528)。認証ユーザは loadLatest 完了前に空フォームが見えるため、!loading &&
// !resumeId でガードして既存経歴書との競合(インポートがロード結果で上書きされる/既存に
// 適用される)を防ぐ。入力・抽出後は内容が入るため自然に消える。
const showImportPanel = !loading && !resumeId && !hasCareerFormContent(form);

/** Skeleton 表示・入力ロックの統合フラグ */
const formLocked = loading;

Expand Down Expand Up @@ -235,6 +243,16 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean
<div className={`${shared.form} import-assign-form ${layout.formCol}`}>
{validationError && <p className={shared.error}>{validationError}</p>}

{/* 空フォームの新規ユーザ向け: 手持ち PDF から自動入力(ADR-0024 / #528) */}
{showImportPanel && (
<ResumeImportPanel
form={form}
onApply={setFormAndClearFocus}
isAuthenticated={isAuthenticated}
requestLogin={requestLogin}
/>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{/* 基本情報: 氏名・連絡先・職務要約 */}
<CareerBasicInfoSection
fullName={form.full_name}
Expand Down
74 changes: 74 additions & 0 deletions web/src/components/forms/ResumeImportPanel.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
.panel {
border: 1px solid var(--color-border, #d0d7de);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
background: var(--color-surface, #fff);
}

.header {
display: flex;
align-items: center;
justify-content: space-between;
}

.title {
font-size: 1rem;
font-weight: 600;
margin: 0;
}

.hint {
font-size: 0.85rem;
color: var(--color-text-muted, #57606a);
margin: 8px 0 12px;
line-height: 1.6;
}

.dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 24px 16px;
border: 2px dashed var(--color-border, #d0d7de);
border-radius: 8px;
cursor: pointer;
text-align: center;
transition: border-color 0.15s, background 0.15s;
}

.dropzone:hover,
.dragOver {
border-color: var(--color-accent, #0969da);
background: var(--color-accent-subtle, rgba(9, 105, 218, 0.06));
}

.dropzone[aria-disabled="true"] {
cursor: progress;
opacity: 0.7;
}

.dropHint {
font-size: 0.85rem;
color: var(--color-text-muted, #57606a);
}

.uploadLabel {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-accent, #0969da);
}

.importing {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: var(--color-text-muted, #57606a);
}

.fileInput {
display: none;
}
Loading
Loading