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
82 changes: 81 additions & 1 deletion PROJECT_STATUS.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,89 @@
# resume-tailor — PROJECT STATUS

Last updated: 2026-09-07
Last updated: 2026-09-09

## Import repair validation — 9 September

The file-import action now uses literal dynamic imports instead of a module-level
`new Function()` loader. Six focused tests cover module loading with string code
generation blocked, real synthetic PDF/DOCX extraction, guest isolation, signed-in
ownership, failed generation and empty input. AI calls are mocked in these tests.
All 479 tests passed, and the full Cloudflare build completed. Its bundle metadata
includes Mammoth and pdf-parse; tracing exclusions alone did not establish absence
from the final bundle. No dependency or production configuration changed.
Full quality is not green: the dependency gate reports 15 unexpected critical/high
advisories (3 critical findings overall). Later quality stages did not run.

The subsequent targeted Next.js 16.3.4 and Mammoth 1.12.2 updates reduce that gate
to 5 unexpected findings and 1 critical finding. All 479 tests and the full
Cloudflare build pass after the updates. Remaining findings concern Astro,
extract-zip, sharp, js-yaml and SVGO; the gate remains failed and no release ran.

Narrow same-major js-yaml 4.3.2 and SVGO 4.1.0 overrides subsequently reduced the
unexpected findings to 3 (Astro, extract-zip and sharp), with 1 critical remaining.
All 479 tests and the complete Cloudflare build still pass. No acceptance baseline
was relaxed. This supersedes the five-finding checkpoint above.

Astro 7.2.8 removes the remaining critical finding. The dependency gate now has
2 unexpected high findings (extract-zip and sharp); it is still failed. The full
Cloudflare build passed. Chromium comparisons of all four static pages at 390px
and 1440px found two lost spaces before inline code on the docs page; explicit
spaces restore exact rendering. All eight final screenshots are pixel-identical
to Astro 5 output, with matching rendered text and links and no horizontal
overflow. These local checks disabled JavaScript and external requests; they do
not qualify hosted interactions. Receipts: `docs/operations/evidence/astro-7-2026-09-09/`.

This repair is not yet released or qualified on Workers. A local built-Worker
dashboard probe stopped at a BetterAuth default-secret configuration error, before
file import could be exercised; the local server was stopped. Existing issue68
retains hosted import and account acceptance. The fixtures contain synthetic data.

## Current qualification

The browser-manager override to @puppeteer/browsers 3.2.2 removes extract-zip
without adding an audit exception. Both Puppeteer imports, a real local Chrome
launch/PDF render and a Cloudflare-fork connection to that browser pass. Full
quality (479 tests) and the complete Cloudflare build pass. The security gate has
0 critical and 0 unexpected findings; 7 previously accepted high advisory IDs
across 8 paths remain. No claim of a vulnerability-free dependency tree is made.

Built-Worker import probes used only synthetic documents, an ephemeral local auth
value and an intentionally unreachable synthetic AI endpoint. The dashboard
returned HTTP200. PDF import fails in pdfjs with `DOMMatrix is not defined`.
DOCX reaches the expected AI-service failure, proving extraction got past the
parser, but the browser sees minified React error441 instead of the safe message.
The local browser and Worker are stopped. Hosted import acceptance remains open;
no release has run. This supersedes the earlier local auth-only blocker.

Expected import errors now return structured action results instead of throwing
through React's production transport. Built-Worker PDF and DOCX probes both
returned HTTP 200 with readable error text, retained the dashboard and re-enabled
retry. PDF still fails extraction; DOCX reaches the deliberately unavailable AI
endpoint. This verifies recovery, not successful import. Eight focused tests pass,
including empty model output and failed-save handling; full quality passed 481
tests and the full Cloudflare build completed. Evidence:
`docs/operations/evidence/resume-import-errors-2026-09-09.json`.

PDF extraction now runs in the browser using the existing pdf-parse browser
build and a same-origin, content-hashed worker asset. The full Cloudflare build
passes with the worker URL excluded from the server bundle. A 390px Chromium
probe against the built local Worker uploaded the real synthetic PDF, preserved
its facts in the AI request, opened the editor and retained the generated resume
after reload. That successful probe used a local AI stub; it does not establish
hosted AI quality or account persistence. The unavailable-provider probe also
retains readable retry behavior. Both local servers and the browser were stopped.
Evidence: `docs/operations/evidence/resume-browser-pdf-2026-09-09.json`.
This supersedes the PDF runtime failure above for the browser import flow;
direct server-side PDF parsing still requires browser APIs and is not qualified.
Full quality passes after the browser PDF change (481 tests, no unexpected security findings).
The import picker no longer advertises legacy .doc support; direct legacy uploads
receive a DOCX/PDF conversion message. Empty or generic browser MIME types fall
back to supported filename extensions. Ten focused import tests pass.

Both workspaces use Wrangler 4.114.0 and Miniflare resolves sharp 0.35.4 through a
patch-only override. Native PNG encoding/decoding passed. The browser-manager
change above subsequently removed the final unexpected extract-zip finding.

Production source `18c041f4c795af5281d6c82ef96e75c775acac3b` is deployed at
100% traffic (Worker `fe163417-64ed-462a-80c5-a4b8d7cf99f5`,
[run 34143940332](https://github.com/Significant-Hobbies/rolepatch/actions/runs/34143940332)).
Expand Down
Binary file added __tests__/fixtures/resume-import/synthetic.docx
Binary file not shown.
Binary file added __tests__/fixtures/resume-import/synthetic.pdf
Binary file not shown.
148 changes: 148 additions & 0 deletions __tests__/resume-import-action.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// @vitest-environment node
import { readFileSync } from 'node:fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
generateText: vi.fn(),
getCurrentUserId: vi.fn(),
execute: vi.fn(),
revalidatePath: vi.fn(),
}));
vi.mock('ai', () => ({ generateText: mocks.generateText }));
vi.mock('@/lib/ai', () => ({
getAIModel: () => 'synthetic-model',
toUserFacingAIError: () => new Error('Generation unavailable'),
}));
vi.mock('@/lib/auth-utils', () => ({ getCurrentUserId: mocks.getCurrentUserId }));
vi.mock('@/lib/db', () => ({ db: { execute: mocks.execute } }));
vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath }));

const source =
'# Synthetic Candidate\n\n## Experience\nEngineer at Example, 2022–2025. Reduced latency from 240ms to 160ms.';
const config = { endpointUrl: '', apiKey: '', model: '' };

function input(text = source) {
const form = new FormData();
form.set('file', new File([text], 'synthetic.md', { type: 'text/markdown' }));
form.set('name', 'Synthetic import');
return form;
}

beforeEach(() => {
vi.resetAllMocks();
vi.resetModules();
mocks.getCurrentUserId.mockResolvedValue(null);
mocks.generateText.mockResolvedValue({ text: source });
});
afterEach(() => vi.unstubAllGlobals());

describe('resume file import action', () => {
it('imports Markdown when the browser supplies no MIME type', async () => {
const form = input();
form.set('file', new File([source], 'synthetic.md'));
const { importResumeFromFile } = await import('@/lib/actions/import-action');
expect(await importResumeFromFile(form, config)).toEqual({ success: true, id: '', source });
});

it('explains legacy Word conversion before calling AI', async () => {
const form = input();
form.set('file', new File([source], 'legacy.doc', { type: 'application/msword' }));
const { importResumeFromFile } = await import('@/lib/actions/import-action');
expect(await importResumeFromFile(form, config)).toEqual({
success: false,
error: 'Save this legacy Word document as DOCX or PDF, then import it.',
});
expect(mocks.generateText).not.toHaveBeenCalled();
expect(mocks.execute).not.toHaveBeenCalled();
});

it.each([
['pdf', 'application/pdf'],
['docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
])('extracts a real synthetic %s document before structuring', async (extension, mime) => {
const bytes = readFileSync(
new URL(`./fixtures/resume-import/synthetic.${extension}`, import.meta.url)
);
const form = new FormData();
form.set('file', new File([bytes], `synthetic.${extension}`, { type: mime }));
const { importResumeFromFile } = await import('@/lib/actions/import-action');
await importResumeFromFile(form, config);
const prompt = mocks.generateText.mock.calls[0]?.[0].prompt;
expect(prompt).toContain('Synthetic Candidate');
expect(prompt).toContain('2022-2025');
expect(prompt).toContain('240ms to 160ms');
expect(mocks.execute).not.toHaveBeenCalled();
});

it('loads without runtime code generation and keeps guest writes browser-local', async () => {
const form = input();
vi.stubGlobal(
'Function',
new Proxy(Function, {
construct() {
throw new EvalError('Code generation from strings disallowed');
},
})
);
const { importResumeFromFile } = await import('@/lib/actions/import-action');
vi.unstubAllGlobals();
const result = await importResumeFromFile(form, config);
expect(result).toEqual({ success: true, id: '', source });
expect(mocks.generateText.mock.calls[0]?.[0].prompt).toContain(source);
expect(mocks.execute).not.toHaveBeenCalled();
});

it('binds a signed-in import to the resolved owner', async () => {
mocks.getCurrentUserId.mockResolvedValue('owner-1');
const { importResumeFromFile } = await import('@/lib/actions/import-action');
const result = await importResumeFromFile(input(), config);
expect(result.success).toBe(true);
if (!result.success) throw new Error(result.error);
expect(result.id).not.toBe('');
expect(mocks.execute).toHaveBeenCalledWith({
sql: 'INSERT INTO resumes (id, name, source, user_id) VALUES (?, ?, ?, ?)',
args: [result.id, 'Synthetic import', source, 'owner-1'],
});
});

it('does not save an import when AI generation fails', async () => {
mocks.generateText.mockRejectedValue(new Error('Provider unavailable'));
const { importResumeFromFile } = await import('@/lib/actions/import-action');
await expect(importResumeFromFile(input(), config)).resolves.toEqual({
success: false,
error: 'Generation unavailable',
});
expect(mocks.execute).not.toHaveBeenCalled();
});

it('rejects empty input before generation or persistence', async () => {
const { importResumeFromFile } = await import('@/lib/actions/import-action');
await expect(importResumeFromFile(input(''), config)).resolves.toEqual({
success: false,
error: 'Empty file',
});
expect(mocks.generateText).not.toHaveBeenCalled();
expect(mocks.execute).not.toHaveBeenCalled();
});

it('does not save an empty model result', async () => {
mocks.generateText.mockResolvedValue({ text: ' ' });
const { importResumeFromFile } = await import('@/lib/actions/import-action');
expect(await importResumeFromFile(input(), config)).toEqual({
success: false,
error: 'The AI service returned an empty resume. Please try again.',
});
expect(mocks.execute).not.toHaveBeenCalled();
});

it('returns a safe persistence error without exposing database details', async () => {
mocks.getCurrentUserId.mockResolvedValue('owner-1');
mocks.execute.mockRejectedValue(new Error('private database detail'));
const { importResumeFromFile } = await import('@/lib/actions/import-action');
expect(await importResumeFromFile(input(), config)).toEqual({
success: false,
error: 'Could not save the imported resume. Please try again.',
});
expect(mocks.revalidatePath).not.toHaveBeenCalled();
});
});
63 changes: 63 additions & 0 deletions docs/operations/evidence/astro-7-2026-09-09/pixel-comparison.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"scope": "Local Chromium, JavaScript disabled and HTTP(S) requests blocked; 390px and1440px. Not hosted interaction or remote-font verification.",
"beforeAstro": "5.18.2",
"afterAstro": "7.2.8",
"comparisons": [
{
"file": "changelog.html-1440-before.png",
"sameDimensions": true,
"pixelIdentical": true,
"beforeSha256": "f2a725536282dc05bb411ec0edf3ef08c586be9e0c955a9de17840acafbfd38e",
"afterSha256": "f2a725536282dc05bb411ec0edf3ef08c586be9e0c955a9de17840acafbfd38e"
},
{
"file": "changelog.html-390-before.png",
"sameDimensions": true,
"pixelIdentical": true,
"beforeSha256": "ab579ee17a5dde4c7787cac8a521f4de03891a39608f9c5b21f8f0a702c30a1d",
"afterSha256": "ab579ee17a5dde4c7787cac8a521f4de03891a39608f9c5b21f8f0a702c30a1d"
},
{
"file": "docs.html-1440-before.png",
"sameDimensions": true,
"pixelIdentical": true,
"beforeSha256": "828fbb4188e46619f9e2aebf4c54fa8b3b5e9b6c215271dd977de098dda56908",
"afterSha256": "828fbb4188e46619f9e2aebf4c54fa8b3b5e9b6c215271dd977de098dda56908"
},
{
"file": "docs.html-390-before.png",
"sameDimensions": true,
"pixelIdentical": true,
"beforeSha256": "fcf2e446378d62e1863029f9b13ea9122786bfe53f71e1d1400a9bbf9ab4073c",
"afterSha256": "fcf2e446378d62e1863029f9b13ea9122786bfe53f71e1d1400a9bbf9ab4073c"
},
{
"file": "faq.html-1440-before.png",
"sameDimensions": true,
"pixelIdentical": true,
"beforeSha256": "ce9bb7ac28dd349765f0cc1722331afec1e3ad540ba118cd5912994a1c1c57b3",
"afterSha256": "ce9bb7ac28dd349765f0cc1722331afec1e3ad540ba118cd5912994a1c1c57b3"
},
{
"file": "faq.html-390-before.png",
"sameDimensions": true,
"pixelIdentical": true,
"beforeSha256": "c16efdfe0498e80361d30c9e37fa859262c849407441464048daa281c6f4dd6a",
"afterSha256": "c16efdfe0498e80361d30c9e37fa859262c849407441464048daa281c6f4dd6a"
},
{
"file": "index.html-1440-before.png",
"sameDimensions": true,
"pixelIdentical": true,
"beforeSha256": "32b59eba95938fe5afb7dbacd1bd5b0108721b461faaafa4d44b2bde4fc86ec5",
"afterSha256": "32b59eba95938fe5afb7dbacd1bd5b0108721b461faaafa4d44b2bde4fc86ec5"
},
{
"file": "index.html-390-before.png",
"sameDimensions": true,
"pixelIdentical": true,
"beforeSha256": "01fe93501d1e69990fb278bed2161c7718e538c85aa4ee390fb982489b44dfc8",
"afterSha256": "01fe93501d1e69990fb278bed2161c7718e538c85aa4ee390fb982489b44dfc8"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
[
{
"file": "index.html",
"width": 390,
"sameRenderedText": true,
"sameLinks": true,
"overflow": [
false,
false
]
},
{
"file": "docs.html",
"width": 390,
"sameRenderedText": true,
"sameLinks": true,
"overflow": [
false,
false
]
},
{
"file": "faq.html",
"width": 390,
"sameRenderedText": true,
"sameLinks": true,
"overflow": [
false,
false
]
},
{
"file": "changelog.html",
"width": 390,
"sameRenderedText": true,
"sameLinks": true,
"overflow": [
false,
false
]
},
{
"file": "index.html",
"width": 1440,
"sameRenderedText": true,
"sameLinks": true,
"overflow": [
false,
false
]
},
{
"file": "docs.html",
"width": 1440,
"sameRenderedText": true,
"sameLinks": true,
"overflow": [
false,
false
]
},
{
"file": "faq.html",
"width": 1440,
"sameRenderedText": true,
"sameLinks": true,
"overflow": [
false,
false
]
},
{
"file": "changelog.html",
"width": 1440,
"sameRenderedText": true,
"sameLinks": true,
"overflow": [
false,
false
]
}
]
Loading
Loading