fix(cv-qa): preserve reviewed mapping order and verify merge journeys

This commit is contained in:
cesnimda
2026-10-27 00:27:32 +01:00
parent 48b899c8de
commit cf0e5c23de
7 changed files with 683 additions and 3 deletions
@@ -15,6 +15,36 @@ namespace JobTrackerApi.Tests;
public class CvImportMappingControllerTests
{
[Fact]
public async Task Mapping_order_is_part_of_replay_identity_while_exact_legacy_receipts_still_work()
{
await using var db = TestHostFactory.CreateInMemoryDb("user-1");
var user = new ApplicationUser { Id = "user-1", UserName = "mapping-order" };
db.Users.Add(user);
var blocks = new[] { new CvImportBlock("b1", "Engineer First", "paragraph", 1), new CvImportBlock("b2", "Lead Second", "paragraph", 1) };
var document = new CvImportDocument("Engineer First Lead Second", false, "application/pdf", 1, 26, "synthetic.pdf", blocks, Array.Empty<string>(), "synthetic");
var run = new CvExtractionRun { OwnerUserId = user.Id, ParserVersion = ProfileCvController.DocumentParserVersion, Status = "pending_review", DocumentJson = JsonSerializer.Serialize(document), StructuredProfileJson = StructuredCvProfileJson.SerializePersisted(new()) };
db.CvExtractionRuns.Add(run);
await db.SaveChangesAsync();
var users = TestHostFactory.CreateUserManager();
users.Setup(u => u.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
users.Setup(u => u.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var controller = new ProfileCvController(users.Object, Mock.Of<ISummarizerService>(), db, null!) { ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
var mappings = new[]
{
new CvImportRecordMapping("z-first", "jobs", null, new[] { new CvImportMappedField("title", "Engineer", "b1"), new CvImportMappedField("company", "First", "b1") }),
new CvImportRecordMapping("a-second", "jobs", null, new[] { new CvImportMappedField("title", "Lead", "b2"), new CvImportMappedField("company", "Second", "b2") }),
};
var request = new ProfileCvController.ApplyReviewedRequest(0, Array.Empty<CvImportDecision>(), mappings);
Assert.IsType<OkObjectResult>(await controller.ApplyReviewed(run.Id, request));
var receipt = System.Text.Json.Nodes.JsonNode.Parse(run.ReviewJson!)!;
receipt["Hash"] = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(new { request.ExpectedVersion, Decisions = request.Decisions!.OrderBy(d => d.Id, StringComparer.Ordinal), Mappings = mappings.OrderBy(m => m.ClientId, StringComparer.Ordinal) })));
run.ReviewJson = receipt.ToJsonString();
await db.SaveChangesAsync();
Assert.IsType<OkObjectResult>(await controller.ApplyReviewed(run.Id, request));
Assert.IsType<ConflictObjectResult>(await controller.ApplyReviewed(run.Id, request with { Mappings = mappings.Reverse().ToArray() }));
Assert.Single(await db.CareerProfileVersions.ToListAsync());
}
[Fact]
public async Task Mapping_only_apply_is_revision_bound_audited_and_idempotent()
{
@@ -0,0 +1,227 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public class CvImportMergeScenarioTests
{
[Fact]
public void Explicit_new_record_order_is_not_replaced_by_random_client_identifier_order()
{
var blocks = new[] { new CvImportBlock("b1", "Engineer First AS", "paragraph", 1), new CvImportBlock("b2", "Lead Second AS", "paragraph", 1) };
var document = new CvImportDocument("Engineer First AS Lead Second AS", false, "application/pdf", 1, 35, "synthetic.pdf", blocks, Array.Empty<string>(), "synthetic");
var mappings = new[]
{
new CvImportRecordMapping("z-first", "jobs", null, new[] { new CvImportMappedField("title", "Engineer", "b1"), new CvImportMappedField("company", "First AS", "b1") }),
new CvImportRecordMapping("a-second", "jobs", null, new[] { new CvImportMappedField("title", "Lead", "b2"), new CvImportMappedField("company", "Second AS", "b2") }),
};
var result = CvImportMapping.Apply(new(), mappings, document, 1, 2, new string('a', 64));
Assert.Equal(new[] { "First AS", "Second AS" }, result.Jobs.Select(job => job.Company));
}
private static StructuredCvJob Job(string company = "Example Labs", string? start = "2020", string? end = "2022") => new()
{
Title = "Engineer", Company = company, Start = start, End = end,
};
private static StructuredCvProfile Profile(params StructuredCvJob[] jobs) => new() { Jobs = jobs.ToList() };
private static CvImportDecision[] Decide(StructuredCvProfile current, StructuredCvProfile incoming, string action) =>
CvDocumentReview.Build(current, incoming).SelectMany(g => g.Fields).Select(f => new CvImportDecision(f.Id, action)).ToArray();
private static CvImportDocument Source(string text) => new(text, false, "application/pdf", 1, text.Length, "scenario.pdf",
new[] { new CvImportBlock("source", text, "paragraph", 1) }, Array.Empty<string>(), "docling");
private static CvImportMappedField Field(string name, string value) => new(name, value, "source");
private static StructuredCvProfile Map(StructuredCvProfile current, string text, params CvImportRecordMapping[] mappings) =>
CvImportMapping.Apply(current, mappings, Source(text), 10, 20, "scenario-hash");
[Fact]
public void Reimporting_the_same_complete_extracted_record_twice_reuses_its_existing_identity()
{
var saved = Job();
saved.Id = "stable-job";
saved.Location = "Saved location";
saved.Bullets.Add("User-only achievement");
var current = Profile(saved);
var incomingJob = Job();
incomingJob.Bullets.Add("Imported achievement");
var incoming = Profile(incomingJob);
incoming.Metadata.Fields["jobs.0.bullets.0"] = new() { SourceBlockId = "original-occurrence", SourceExtractionRunId = 10 };
var first = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "use"));
var second = CvDocumentReview.Apply(first, incoming, Decide(first, incoming, "use"));
var retained = Assert.Single(second.Jobs);
Assert.Equal("stable-job", retained.Id);
Assert.Equal("Saved location", retained.Location);
Assert.Equal(new[] { "User-only achievement", "Imported achievement" }, retained.Bullets);
Assert.Equal("original-occurrence", second.Metadata.Fields["jobs.0.bullets.1"].SourceBlockId);
Assert.Single(current.Jobs[0].Bullets);
}
[Theory]
[InlineData("2020", "2023")]
[InlineData("2020", "2021")]
[InlineData("2021", "2024")]
[InlineData("2020-01", "2022")]
public void Revised_older_overlapping_or_differently_precise_dates_remain_distinct_until_reviewed(string start, string end)
{
var current = Profile(Job());
var incoming = Profile(Job(start: start, end: end));
Assert.Equal("New record", Assert.Single(CvDocumentReview.Build(current, incoming)).MatchDescription);
var rejected = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "keep"));
Assert.Single(rejected.Jobs);
Assert.Equal("2022", rejected.Jobs[0].End);
var approved = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "use"));
Assert.Equal(2, approved.Jobs.Count);
Assert.Equal("2020", approved.Jobs[0].Start);
Assert.Equal("2022", approved.Jobs[0].End);
Assert.Equal(start, approved.Jobs[1].Start);
Assert.Equal(end, approved.Jobs[1].End);
Assert.Null(approved.Jobs[1].StartDate);
}
[Theory]
[InlineData("Example Lab")]
[InlineData("Example Labs AS")]
[InlineData("Example Laboratories")]
public void Similar_but_distinct_employers_are_never_fuzzy_merged(string company)
{
var current = Profile(Job());
var incoming = Profile(Job(company));
Assert.Equal("New record", Assert.Single(CvDocumentReview.Build(current, incoming)).MatchDescription);
var approved = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "use"));
Assert.Equal(new[] { "Example Labs", company }, approved.Jobs.Select(j => j.Company));
}
[Fact]
public void Incomplete_identity_is_new_until_the_user_explicitly_targets_a_current_record()
{
var current = Profile(Job());
current.Jobs[0].Id = "saved";
current.Jobs[0].Bullets.Add("Saved achievement");
var partial = Profile(Job(end: null));
partial.Jobs[0].Bullets.Add("New achievement");
var group = Assert.Single(CvDocumentReview.Build(current, partial));
Assert.Equal("New record", group.MatchDescription);
Assert.All(group.Fields, f => Assert.Null(f.CurrentValue));
var rejected = CvDocumentReview.Apply(current, partial, Decide(current, partial, "keep"));
var updated = Map(rejected, "New achievement", new CvImportRecordMapping("explicit", "jobs", 0, new[] { Field("bullets", "New achievement") }));
var retained = Assert.Single(updated.Jobs);
Assert.Equal("saved", retained.Id);
Assert.Equal("2022", retained.End);
Assert.Equal(new[] { "Saved achievement", "New achievement" }, retained.Bullets);
}
[Fact]
public void Correcting_an_unmatched_extracted_identity_does_not_silently_retarget_a_saved_record()
{
var current = Profile(Job());
current.Jobs[0].Id = "saved";
var incoming = Profile(Job("Example Typo"));
var decisions = Decide(current, incoming, "use").Select(d => d.Id == "jobs.0.company" ? d with { Action = "correct", Value = "Example Labs" } : d).ToArray();
var result = CvDocumentReview.Apply(current, incoming, decisions);
Assert.Equal(2, result.Jobs.Count);
Assert.Equal("saved", result.Jobs[0].Id);
Assert.Null(result.Jobs[1].Id);
Assert.All(result.Jobs, j => Assert.Equal("Example Labs", j.Company));
}
[Fact]
public void Correcting_a_matched_identity_keeps_the_original_target_even_when_another_record_has_the_corrected_name()
{
var current = Profile(Job("First Labs"), Job("Second Labs"));
current.Jobs[0].Id = "first";
current.Jobs[1].Id = "second";
var incoming = Profile(Job("First Labs"));
var result = CvDocumentReview.Apply(current, incoming, new[] { new CvImportDecision("jobs.0.company", "correct", "Second Labs") });
Assert.Equal(2, result.Jobs.Count);
Assert.Equal("first", result.Jobs[0].Id);
Assert.Equal("Second Labs", result.Jobs[0].Company);
Assert.Equal("second", result.Jobs[1].Id);
Assert.Equal("Second Labs", result.Jobs[1].Company);
}
[Fact]
public void Explicit_target_adds_education_details_without_replacing_absent_identity_dates_or_evidence()
{
var current = new StructuredCvProfile { Education = { new() { Id = "degree", Qualification = "BSc", Institution = "Saved University", Location = "Oslo", Start = "2018", End = "2021", Details = { "Saved detail" } } } };
current.Metadata.Fields["education.0.details.0"] = new() { SourceBlockId = "saved-source", SourceExtractionRunId = 3 };
var result = Map(current, "Database specialisation", new CvImportRecordMapping("detail", "education", 0, new[] { Field("details", "Database specialisation") }));
var education = Assert.Single(result.Education);
Assert.Equal("degree", education.Id);
Assert.Equal("BSc", education.Qualification);
Assert.Equal("Saved University", education.Institution);
Assert.Equal("Oslo", education.Location);
Assert.Equal("2018", education.Start);
Assert.Equal("2021", education.End);
Assert.Equal(new[] { "Saved detail", "Database specialisation" }, education.Details);
Assert.Equal("saved-source", result.Metadata.Fields["education.0.details.0"].SourceBlockId);
Assert.Equal(20, result.Metadata.Fields["education.0.details.1"].SourceExtractionRunId);
}
[Fact]
public void Rejected_extracted_choices_are_not_saved_as_a_side_effect_of_authoring_a_missing_record()
{
var current = new StructuredCvProfile { Contact = new() { FullName = "Saved name", GitHub = "https://github.com/saved" }, Skills = { "Saved skill" } };
current.Metadata.Fields["contact.fullName"] = new() { SourceExtractionRunId = 2 };
var incoming = Profile(Job("Rejected employer"));
incoming.Contact.FullName = "Rejected name";
incoming.Skills.Add("Rejected skill");
var kept = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "keep"));
var result = Map(kept, "Mapped Engineer | Reviewed employer", new CvImportRecordMapping("only-approved", "jobs", null,
new[] { Field("title", "Mapped Engineer"), Field("company", "Reviewed employer") }));
var job = Assert.Single(result.Jobs);
Assert.Equal("Reviewed employer", job.Company);
Assert.Equal("Saved name", result.Contact.FullName);
Assert.Equal("https://github.com/saved", result.Contact.GitHub);
Assert.Equal(new[] { "Saved skill" }, result.Skills);
Assert.Equal(2, result.Metadata.Fields["contact.fullName"].SourceExtractionRunId);
Assert.DoesNotContain(result.Metadata.Fields.Values, m => m.SourceSnippet is "Rejected name" or "Rejected skill");
}
[Fact]
public void A_target_that_disappeared_from_the_current_snapshot_is_rejected_without_mutation()
{
var current = Profile(Job("First Labs"), Job("Second Labs"));
var mapping = new CvImportRecordMapping("explicit", "jobs", 1, new[] { Field("bullets", "New achievement") });
CvImportMapping.ValidateTargets(current, new[] { mapping });
var afterRemoval = Profile(Job("First Labs"));
Assert.Throws<ArgumentException>(() => CvImportMapping.ValidateTargets(afterRemoval, new[] { mapping }));
Assert.Throws<ArgumentException>(() => Map(afterRemoval, "New achievement", mapping));
Assert.Empty(afterRemoval.Jobs[0].Bullets);
}
[Fact]
public void Repeated_explicit_target_list_approval_is_additive_and_keeps_the_first_evidence()
{
var current = Profile(Job());
var mapping = new CvImportRecordMapping("explicit", "jobs", 0, new[] { Field("bullets", "New achievement") });
var first = Map(current, "New achievement", mapping);
var second = CvImportMapping.Apply(first, new[] { mapping }, Source("New achievement"), 99, 99, "later-hash");
Assert.Single(second.Jobs);
Assert.Equal(new[] { "New achievement" }, second.Jobs[0].Bullets);
Assert.Equal(20, second.Metadata.Fields["jobs.0.bullets.0"].SourceExtractionRunId);
}
[Fact]
public void Duplicate_client_ids_and_conflicting_scalar_mappings_fail_without_changing_the_input()
{
var current = Profile(Job());
var first = new CvImportRecordMapping("same", "jobs", 0, new[] { Field("title", "First title") });
var second = new CvImportRecordMapping("other", "jobs", 0, new[] { Field("title", "Second title") });
Assert.Throws<ArgumentException>(() => Map(current, "First title Second title", first, first));
Assert.Throws<ArgumentException>(() => Map(current, "First title Second title", first, second));
Assert.Equal("Engineer", current.Jobs[0].Title);
Assert.Empty(current.Metadata.Fields);
}
[Fact]
public void Null_mapping_target_is_a_deliberate_new_record_even_when_its_identity_already_exists()
{
var current = Profile(Job());
current.Jobs[0].Id = "saved";
var result = Map(current, "Engineer Example Labs", new CvImportRecordMapping("deliberately-new", "jobs", null,
new[] { Field("title", "Engineer"), Field("company", "Example Labs") }));
Assert.Equal(2, result.Jobs.Count);
Assert.Equal("saved", result.Jobs[0].Id);
Assert.Null(result.Jobs[1].Id);
Assert.Null(result.Jobs[1].Start);
Assert.Null(result.Jobs[1].End);
}
}
@@ -185,7 +185,7 @@ public sealed partial class ProfileCvController
if (mappings.Count > 25 || mappings.Any(m => m is null)) return BadRequest("Invalid or excessive record mappings.");
// Preserve hashes for old requests and already-applied historical reviews.
var hashBytes = mappings.Count == 0 ? JsonSerializer.SerializeToUtf8Bytes(new { request.ExpectedVersion, Decisions = request.Decisions.OrderBy(d => d.Id, StringComparer.Ordinal) })
: JsonSerializer.SerializeToUtf8Bytes(new { request.ExpectedVersion, Decisions = request.Decisions.OrderBy(d => d.Id, StringComparer.Ordinal), Mappings = mappings.OrderBy(m => m.ClientId, StringComparer.Ordinal) });
: JsonSerializer.SerializeToUtf8Bytes(new { request.ExpectedVersion, Decisions = request.Decisions.OrderBy(d => d.Id, StringComparer.Ordinal), Mappings = mappings });
var hash = Convert.ToHexString(SHA256.HashData(hashBytes));
try
{
@@ -197,7 +197,15 @@ public sealed partial class ProfileCvController
if (run.Status == "applied")
{
var prior = JsonSerializer.Deserialize<AppliedReview>(run.ReviewJson ?? "null");
return prior?.Hash == hash ? Ok(new { runId = id, status = "applied", revision = prior.Revision, structuredCv = prior.StructuredCv }) : Conflict("This import has already been applied with different decisions.");
var sameMappings = mappings.Count == 0 || JsonSerializer.Serialize(prior?.Mappings) == JsonSerializer.Serialize(mappings);
var identical = prior?.Hash == hash && sameMappings;
// Old mapping receipts sorted IDs; accept their exact original order only.
if (!identical && prior is not null && mappings.Count > 0 && sameMappings)
{
var legacyHash = Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(new { request.ExpectedVersion, Decisions = request.Decisions.OrderBy(d => d.Id, StringComparer.Ordinal), Mappings = mappings.OrderBy(m => m.ClientId, StringComparer.Ordinal) })));
identical = prior.Hash == legacyHash;
}
return identical ? Ok(new { runId = id, status = "applied", revision = prior!.Revision, structuredCv = prior.StructuredCv }) : Conflict("This import has already been applied with different decisions.");
}
if (run.Status != "pending_review" || string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This import is not awaiting review.");
var snapshot = await _careerProfileService.LoadSnapshotAsync(user.Id, HttpContext.RequestAborted);
+1 -1
View File
@@ -77,7 +77,7 @@ public static class CvImportMapping
var result = Node(current);
var metadata = result["metadata"]!["fields"]!.AsObject();
var scalarWrites = new HashSet<string>(StringComparer.Ordinal);
foreach (var mapping in mappings.OrderBy(m => m.ClientId, StringComparer.Ordinal))
foreach (var mapping in mappings)
{
var catalog = Categories.Single(c => c.Category == mapping.Category);
if (mapping.Fields.Count == 0) throw new ArgumentException("A record mapping must contain reviewed fields.");
@@ -0,0 +1,17 @@
# Review and merge — 2026-10-27
Review remains explicit: uncertain source is visible, missing records can be authored from owned source blocks, and no change is applied until the user approves it. Corrections are distinguished from literal source mappings. Current values and record labels are shown; existing targets refer to the compared profile revision. A stale response retains edits but clears target selection and confirmation, requiring renewed comparison.
Object mappings with no existing target deliberately add a record; a selected target updates only approved supplied fields. Scalar groups append using existing canonical normalization. Contact writes affect selected leaves only. Rejected originals are absent from applied changes; mapping-only application is supported. Similar titles alone do not establish a duplicate. Incomplete identities stay separate unless a user selects the destination. Missing fields never clear existing fields.
New mappings retain **submitted order**. Previously sorting randomly generated client IDs reordered jobs. Replay hashes now preserve mapping order, and exact historical sorted-hash receipts remain replayable only when their stored mappings have exactly the same order. A reordered request conflicts rather than returning a false successful acknowledgement.
## Verification
- `CvImportMergeScenarioTests`: exact/revised/older/overlapping imports, normalized dates, similar distinct employers, incomplete identity, target changes, selective correction/rejection, duplicate field/client-ID checks, source retention, detail deduplication and submitted ordering.
- Controller regression: legacy sorted receipt accepts the identical ordered request and rejects reordered mappings without another history write.
- RED ordering and replay failures retained in `.artifacts/cv-integration-qa/mapping-order-*.log`; successor `merge-order-green-02.log`: **126 focused tests passed**, including parser/mapping/controller suites. This overlaps other focused reports.
- Actual Docling browser run `real-browser-01.json`: **6 passed**, zero retries. Two existing journeys plus four new integration journeys; do not count the two existing cases twice. New cases cover EN/light desktop, Bokmål/dark mobile, tablet education authoring and repeated overlapping roles.
- Real UI tests inspect source, correct/map jobs or education, approve and verify canonical readback, compare existing targets, exercise a two-session stale write and reconfirmation, and preserve an unrelated variant/canonical data while customizing a CV and exporting PDF. Repeated-role reimport/replay does not add records.
This proves the exercised field groups and interactions, not every catalog field in every viewport. The wider catalog has backend/provider coverage. Automated accessibility assertions are separate from human assistive-technology acceptance. Original PDF tables with unresolved relationships still require user assignment; no automatic uncertain merge is claimed.
@@ -0,0 +1,387 @@
import { expect, test, type APIRequest, type APIRequestContext, type APIResponse, type BrowserContext, type Locator, type Page, type TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type { CvOutline, CvVariant } from '../src/cvBuilder';
import type { CoverLetter, SubmittedPackage, SubmittedPackageDetail } from '../src/applicationWorkspace';
import type { StructuredCvProfile } from '../src/profileCv';
import { translations } from '../src/i18n/translations';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Language = 'en' | 'nb';
type Headers = Record<string, string>;
type BrowserTools = { request: APIRequest };
type Snapshot = { revision: number; profile: StructuredCvProfile };
type Block = { id: string; text: string; page: number | null };
type Review = { runId: number; status: string; revision: number; sourceBlocks: Block[]; groups: { category: string; fields: { id: string }[] }[] };
type Run = { id: number; status: string; parserVersion: string; operation: { progressStage: string } };
type Timings = Record<string, number>;
type MappedValue = { name: string; value: string; evidence?: string; corrected?: boolean };
type Scenario = { language: Language; mode: 'light' | 'dark'; width: number; file: string; kind: 'job' | 'education' | 'repeated'; theme: 'minimal' | 'technical'; package?: boolean };
async function json<T>(response: APIResponse, status = 200): Promise<T> {
expect(response.status(), response.url()).toBe(status);
return response.json();
}
async function login(api: APIRequestContext, email: string): Promise<Headers> {
await json(await api.post(`${apiUrl}/auth/login`, { data: { email, password } }));
const me = await json<{ sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
expect(csrf).toBeDefined();
return { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext };
}
async function career(api: APIRequestContext) { return json<Snapshot>(await api.get(`${apiUrl}/career/profile`)); }
async function versions(api: APIRequestContext) { return json<unknown[]>(await api.get(`${apiUrl}/career/profile/versions`)); }
async function readVariant(api: APIRequestContext, id: number) { return json<CvVariant>(await api.get(`${apiUrl}/cv/variants/${id}`)); }
const sha = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex').toUpperCase();
const clean = (text: string) => text.replace(/\s+/g, ' ').trim();
const numbered = (text: string, number: number) => text.replace('{number}', String(number));
function panel(page: Page, runId: number) { return page.locator(`#cv-import-${runId}-review-panel`); }
async function keyboardClick(page: Page, button: Locator) { await button.focus(); await page.keyboard.press('Enter'); }
async function owner(page: Page, context: BrowserContext, playwright: BrowserTools, sample: Scenario, info: TestInfo) {
const admin = await playwright.request.newContext();
const email = `cv-qa-${Date.now()}-${info.testId.replace(/[^a-z0-9]/gi, '').slice(-14)}@example.test`;
try {
const adminHeaders = await login(admin, 'e2e@example.test');
await json(await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: ['Premium'] } }));
} finally { await admin.dispose(); }
const headers = await login(page.request, email);
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await context.addInitScript(({ language, mode }) => {
localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode);
}, sample);
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
});
await page.setViewportSize({ width: sample.width, height: 900 });
await page.emulateMedia({ reducedMotion: 'reduce' });
return { email, headers };
}
async function upload(page: Page, playwright: BrowserTools, language: Language, fileName: string, info: TestInfo, timings: Timings, stageName: string) {
const t = translations[language];
const before = await career(page.request);
const historyBefore = await versions(page.request);
const existing = new Set((await json<Run[]>(await page.request.get(`${apiUrl}/profile-cv/runs`))).map(run => run.id));
const fixture = path.resolve(process.env.CV_QA_FIXTURE_ROOT!, fileName);
const fixtureHash = sha(await readFile(fixture));
await page.goto('/career?section=import');
await expect(page.getByRole('button', { name: t.profileUploadCv, exact: true })).toBeEnabled();
const started = Date.now();
const accepted = page.waitForResponse(response => response.url() === `${apiUrl}/profile-cv/import` && response.request().method() === 'POST');
await page.locator('input[type=file][accept*=".pdf"]').setInputFiles(fixture);
expect((await accepted).status()).toBe(202);
const observed: { status: string; stage: string | null; elapsedMs: number }[] = [];
let pending: Run | undefined;
await expect.poll(async () => {
pending = (await json<Run[]>(await page.request.get(`${apiUrl}/profile-cv/runs`))).find(run => !existing.has(run.id));
if (pending) observed.push({ status: pending.status, stage: pending.operation?.progressStage ?? null, elapsedMs: Date.now() - started });
return pending?.status;
}, { timeout: 90_000, message: 'A real owned Docling run must reach persisted pending_review' }).toBe('pending_review');
timings[`${stageName}.uploadToPendingReviewMs`] = Date.now() - started;
expect(pending!.parserVersion).toBe('docling-v1');
expect(pending!.operation.progressStage).toBe('cv_review_ready');
expect(await career(page.request)).toEqual(before);
expect(await versions(page.request)).toEqual(historyBefore);
await page.reload();
await expect(page.locator('html')).toHaveAttribute('lang', language === 'nb' ? 'nb-NO' : 'en');
const review = await json<Review>(await page.request.get(`${apiUrl}/profile-cv/runs/${pending!.id}/review`));
expect(review.status).toBe('pending_review');
expect(review.revision).toBe(before.revision);
expect(review.sourceBlocks.length).toBeGreaterThan(0);
await expect(panel(page, pending!.id).getByRole('button', { name: t.cvImportApplyReviewed, exact: true })).toBeDisabled();
expect(await career(page.request)).toEqual(before);
expect(await versions(page.request)).toEqual(historyBefore);
const source = await page.request.get(`${apiUrl}/profile-cv/runs/${pending!.id}/source`);
expect(source.status()).toBe(200);
expect(source.headers()['cache-control']).toContain('no-store');
expect(sha(await source.body())).toBe(fixtureHash);
const stranger = await playwright.request.newContext();
try {
await login(stranger, 'e2e@example.test');
for (const suffix of ['source', 'review']) expect((await stranger.get(`${apiUrl}/profile-cv/runs/${pending!.id}/${suffix}`)).status()).toBe(404);
} finally { await stranger.dispose(); }
await info.attach(`${stageName}-persisted-stages`, { body: JSON.stringify({ runId: pending!.id, parserVersion: pending!.parserVersion, observed, fixtureHash,
boundary: 'Only sampled persisted stages are claimed; intermediate stages can complete between polls. Real local Docling, no extraction/model mocks.' }), contentType: 'application/json' });
return { runId: pending!.id, review, before, fixtureHash };
}
async function rejectOriginal(page: Page, runId: number) {
// Mapping editor selectors have cv-mapping IDs, so this addresses only extracted decisions.
for (const select of await panel(page, runId).locator('select[id^="cv-import-"]').all()) await select.selectOption('keep');
}
async function approveExtracted(page: Page, runId: number, language: Language) {
const buttons = panel(page, runId).getByRole('button', { name: translations[language].cvImportUseGroup, exact: true });
expect(await buttons.count()).toBeGreaterThanOrEqual(3);
for (const button of await buttons.all()) await button.click();
}
function evidence(review: Review, literal: string) {
const block = review.sourceBlocks.find(item => clean(item.text).includes(clean(literal)));
expect(block, `Original source block for literal ${JSON.stringify(literal)}`).toBeDefined();
return block!;
}
async function author(page: Page, runId: number, review: Review, language: Language, category: 'jobs' | 'education', fields: MappedValue[], target: number | null = null) {
const t = translations[language];
const reviewPanel = panel(page, runId);
await keyboardClick(page, reviewPanel.getByRole('button', { name: t.cvImportMapMissing, exact: true }));
const mapping = reviewPanel.getByRole('group', { name: numbered(t.cvImportMappingRecord, 1), exact: true });
await mapping.getByRole('combobox', { name: t.cvImportMappingCategory, exact: true }).selectOption(category);
await mapping.getByRole('combobox', { name: t.cvImportMappingTarget, exact: true }).selectOption(target === null ? '' : String(target));
const selected: { name: string; value: string; block: Block; corrected: boolean }[] = [];
for (const [index, field] of fields.entries()) {
const number = index + 1;
if (index >= 2) await mapping.getByRole('button', { name: t.cvImportMappingAddField, exact: true }).click();
await mapping.getByRole('combobox', { name: numbered(t.cvImportMappingField, number), exact: true }).selectOption(field.name);
const block = evidence(review, field.evidence ?? field.value);
const value = mapping.getByRole('textbox', { name: numbered(t.cvImportMappingValue, number), exact: true });
await mapping.getByRole('combobox', { name: numbered(t.cvImportMappingSource, number), exact: true }).selectOption(block.id);
await expect(value).toHaveValue(block.text);
await value.fill(field.value);
if (field.corrected) await mapping.getByRole('checkbox', { name: t.cvImportMappingCorrection, exact: true }).nth(index).check();
selected.push({ name: field.name, value: field.value, block, corrected: field.corrected ?? false });
}
await keyboardClick(page, mapping.getByRole('button', { name: t.cvImportMappingConfirm, exact: true }));
await expect(mapping.getByText(t.cvImportMappingConfirmed, { exact: true })).toBeVisible();
return { mapping, selected };
}
async function accessibility(page: Page, runId: number, info: TestInfo, name: string) {
const result = await new AxeBuilder({ page }).include(`#cv-import-${runId}-review-panel`).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach(`${name}-axe`, { body: JSON.stringify({ scope: `#cv-import-${runId}-review-panel`, violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' });
expect(result.violations).toEqual([]);
await page.screenshot({ path: info.outputPath(`${name}.png`), fullPage: true, animations: 'disabled' });
}
async function apply(page: Page, headers: Headers, runId: number, language: Language, before: Snapshot, timings: Timings, name: string) {
const endpoint = `${apiUrl}/profile-cv/runs/${runId}/apply-reviewed`;
const started = Date.now();
const response = page.waitForResponse(result => result.url() === endpoint && result.request().method() === 'POST');
await keyboardClick(page, panel(page, runId).getByRole('button', { name: translations[language].cvImportApplyReviewed, exact: true }));
const applied = await response;
expect(applied.status()).toBe(200);
await expect(page.locator(`#career-import-run-${runId}`).getByText(translations[language].cvImportSaved, { exact: true })).toBeVisible();
await expect.poll(async () => (await career(page.request)).revision).toBe(before.revision + 1);
const snapshot = await career(page.request);
timings[`${name}.applyToDurableReadbackMs`] = Date.now() - started;
const history = await versions(page.request);
const replay = await page.request.post(endpoint, { headers, data: applied.request().postDataJSON() });
expect(replay.status()).toBe(200);
expect(await career(page.request)).toEqual(snapshot);
expect(await versions(page.request)).toEqual(history);
return snapshot;
}
async function createVariant(api: APIRequestContext, headers: Headers, name: string, theme = 'minimal') {
return json<CvVariant>(await api.post(`${apiUrl}/cv/variants`, { headers, data: { name, settings: { themeId: theme } } }));
}
async function editAndExport(page: Page, headers: Headers, sample: Scenario, sibling: CvVariant, info: TestInfo, timings: Timings) {
const t = translations[sample.language];
const source = await career(page.request);
const sectionKey = sample.kind === 'education' ? 'education' : 'experience';
const sectionName = sample.kind === 'education' ? t.cvSectionEducation : t.cvSectionExperience;
const outline = await json<CvOutline>(await page.request.get(`${apiUrl}/cv/outline`));
const entries = outline.sections.find(section => section.key === sectionKey)!.entries;
expect(entries.length).toBeGreaterThan(0);
const first = entries[0];
const canonical = sample.kind === 'education' ? source.profile.education[0] : source.profile.jobs[0];
expect(canonical.id).toBeTruthy();
expect(first.key).toBe(canonical.id);
const cv = await json<CvVariant>(await page.request.post(`${apiUrl}/cv/variants`, { headers, data: {
name: `QA ${sample.language} ${sample.kind} CV`, settings: { themeId: sample.theme, sections: [{ key: sectionKey, itemOrder: entries.map(entry => entry.key!) }] },
} }));
const started = Date.now();
await page.goto(`/career/builder/${cv.id}`);
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(cv.name);
await page.getByRole('button', { name: t.cvEditorExpandSection.replace('{section}', sectionName), exact: true }).click();
const section = page.getByRole('group', { name: sectionName, exact: true });
const entry = section.getByRole('group', { name: first.title!, exact: true }).first();
const tailoredTitle = `CV-only ${first.title}`;
await entry.getByLabel(t.cvEditorEntryTitle, { exact: true }).fill(tailoredTitle);
await expect.poll(async () => (await readVariant(page.request, cv.id)).settings.overrides[first.key!]?.title).toBe(tailoredTitle);
await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible();
expect((await readVariant(page.request, cv.id)).version).toBeGreaterThan(cv.version);
expect(await career(page.request)).toEqual(source);
expect(await readVariant(page.request, sibling.id)).toEqual(sibling);
if (sample.width < 900) await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
const title = t.cvEditorPreviewPage.replace('{page}', '1');
await expect(page.getByTitle(title, { exact: true })).toBeVisible();
await expect(page.frameLocator(`iframe[title=${JSON.stringify(title)}]`).locator('body')).toContainText(tailoredTitle);
timings.editorOpenEditToPreviewMs = Date.now() - started;
await page.screenshot({ path: info.outputPath('tailored-preview.png'), fullPage: true, animations: 'disabled' });
const exportStarted = Date.now();
const download = page.waitForEvent('download');
await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click();
const pdf = await download;
expect(pdf.suggestedFilename()).toMatch(/\.pdf$/);
const pdfPath = info.outputPath(`cv-qa-${sample.language}-${sample.kind}-${sample.theme}.pdf`);
await pdf.saveAs(pdfPath);
expect(await pdf.failure()).toBeNull();
const bytes = await readFile(pdfPath);
expect(bytes.subarray(0, 5).toString()).toBe('%PDF-');
timings.pdfExportToSavedBytesMs = Date.now() - exportStarted;
expect(await career(page.request)).toEqual(source);
expect(await readVariant(page.request, sibling.id)).toEqual(sibling);
const markers = sample.kind === 'education' ? [tailoredTitle, 'Example Learning Centre', '2015', '2017']
: sample.kind === 'repeated' ? [tailoredTitle, 'Repeat AS', 'Built first service.', 'Built second service.', 'Reviewed third service.', '2018', '2020', '2022', '2023']
: [tailoredTitle, 'Ambiguous Labs', '2019', '2022'];
await info.attach('pdf-semantic-oracle-input', { body: JSON.stringify({ pdfPath, sha256: sha(bytes), expectedLiteralMarkers: markers,
canonicalEntryKey: first.key, canonicalTitle: first.title, cvOnlyTitle: tailoredTitle, theme: sample.theme,
boundary: 'Actual downloaded PDF bytes; semantic extraction and physical/render oracle are separate root-run gates.' }), contentType: 'application/json' });
await info.attach('actual-cv-pdf', { path: pdfPath, contentType: 'application/pdf' });
return { cv: await readVariant(page.request, cv.id), tailoredTitle, source };
}
async function freezeApplication(page: Page, headers: Headers, cv: CvVariant, tailoredTitle: string) {
const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'CV QA synthetic application company', source: 'User' } }), 201);
const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers, data: {
companyId: company.id, jobTitle: 'Reviewed QA engineering application', status: 'Saved', description: 'Synthetic source-backed qualification. No external delivery.',
} }), 201);
const url = `${apiUrl}/jobapplications/${job.id}`;
await json(await page.request.put(`${url}/cv`, { headers, data: { variantId: cv.id } }));
const initial = await json<CoverLetter>(await page.request.get(`${url}/cover-letter`));
const text = 'Manually reviewed frozen application letter — æøå. Preserve this exact text across later import and CV edits.';
await json(await page.request.put(`${url}/cover-letter`, { headers, data: { expectedVersion: initial.revision, text, source: 'manual' } }));
const captured = await json<SubmittedPackage>(await page.request.post(`${url}/submitted-packages`, { headers }));
const detailUrl = `${url}/submitted-packages/${captured.id}`;
const detail = await json<SubmittedPackageDetail>(await page.request.get(detailUrl));
expect(detail.coverLetterText).toBe(text);
expect(detail.renderedCvHtml).toContain(tailoredTitle);
expect(captured.cvVariantId).toBe(cv.id);
return { jobId: job.id, url, detailUrl, detail, text, letter: await json<CoverLetter>(await page.request.get(`${url}/cover-letter`)) };
}
const scenarios: Scenario[] = [
{ language: 'en', mode: 'light', width: 1440, file: 'frozen-ambiguous-employer-first.docx', kind: 'job', theme: 'minimal', package: true },
{ language: 'nb', mode: 'dark', width: 390, file: 'frozen-ambiguous-title-first.pdf', kind: 'job', theme: 'technical' },
{ language: 'en', mode: 'light', width: 768, file: 'frozen-ambiguous-education.docx', kind: 'education', theme: 'minimal' },
{ language: 'en', mode: 'dark', width: 768, file: 'frozen-repeated-overlapping-roles.docx', kind: 'repeated', theme: 'technical' },
];
for (const sample of scenarios) {
test(`[CV-QA] real Docling ${sample.file} ${sample.language}/${sample.mode}/${sample.width}: source, explicit review, canonical persistence, scoped CV and PDF`, async ({ page, context, playwright }, info) => {
const timings: Timings = {};
try {
const { email, headers } = await owner(page, context, playwright, sample, info);
const t = translations[sample.language];
const sibling = await createVariant(page.request, headers, 'Untouched pre-import sibling');
const first = await upload(page, playwright, sample.language, sample.file, info, timings, 'first');
let current: Snapshot;
if (sample.kind === 'repeated') {
await approveExtracted(page, first.runId, sample.language);
await accessibility(page, first.runId, info, 'extracted-review');
current = await apply(page, headers, first.runId, sample.language, first.before, timings, 'first');
expect(current.revision).toBe(1);
expect(current.profile.jobs).toHaveLength(3);
expect(current.profile.jobs.map(job => [job.company, job.title, job.start, job.end])).toEqual([
['Repeat AS', 'Engineer', '2018', '2021'], ['Repeat AS', 'Engineer', '2020', '2023'], ['Repeat AS', 'Lead', '2022', 'Present'],
]);
expect(current.profile.jobs.map(job => job.bullets)).toEqual([['Built first service.'], ['Built second service.'], ['Reviewed third service.']]);
expect(current.profile.jobs[2].isCurrent).toBe(true);
const origins = current.profile.jobs.map((_, index) => current.profile.metadata.fields[`jobs.${index}.company`]);
expect(new Set(origins.map(origin => origin.sourceBlockId)).size).toBe(3);
for (const [index, origin] of origins.entries()) {
expect(origin.sourceExtractionRunId).toBe(first.runId);
expect(origin.sourceArtifactId).toBeGreaterThan(0);
expect(origin.sourceFileSha256).toBe(first.fixtureHash);
expect(clean(first.review.sourceBlocks.find(block => block.id === origin.sourceBlockId)!.text)).toContain('Repeat AS');
expect(first.review.sourceBlocks.find(block => block.id === current.profile.metadata.fields[`jobs.${index}.bullets.0`].sourceBlockId)!.text).toContain(current.profile.jobs[index].bullets[0]);
}
} else {
expect(first.review.groups.some(group => group.category === 'jobs' || group.category === 'education')).toBe(false);
await rejectOriginal(page, first.runId);
const category = sample.kind === 'education' ? 'education' : 'jobs';
const fields: MappedValue[] = sample.kind === 'education'
? [{ name: 'qualification', value: 'Advanced Computing' }, { name: 'institution', value: 'Example Learning Centre' }, { name: 'start', value: '2015', evidence: '2015 - 2017' }, { name: 'end', value: '2017', evidence: '2015 - 2017' }]
: [{ name: 'title', value: 'Principal Engineer' }, { name: 'company', value: 'Ambiguous Labs' }, { name: 'start', value: '2019', evidence: '2019 - 2022' }, { name: 'end', value: '2022', evidence: '2019 - 2022' }];
const mapped = await author(page, first.runId, first.review, sample.language, category, fields);
await accessibility(page, first.runId, info, 'mapped-review');
current = await apply(page, headers, first.runId, sample.language, first.before, timings, 'first');
expect(current.revision).toBe(1);
expect(current.profile.otherSections).toEqual([]);
expect(current.profile.jobs).toHaveLength(sample.kind === 'education' ? 0 : 1);
expect(current.profile.education).toHaveLength(sample.kind === 'education' ? 1 : 0);
const record = sample.kind === 'education' ? current.profile.education[0] : current.profile.jobs[0];
expect(record.id).toBeTruthy();
for (const field of mapped.selected) {
expect(record).toHaveProperty(field.name, field.value);
expect(current.profile.metadata.fields[`${category}.0.${field.name}`]).toMatchObject({ method: 'user-source-mapping', sourceBlockId: field.block.id, sourceExtractionRunId: first.runId, sourceFileSha256: first.fixtureHash, reviewState: 'accepted' });
expect(current.profile.metadata.fields[`${category}.0.${field.name}`].sourceArtifactId).toBeGreaterThan(0);
}
expect((await versions(page.request))).toHaveLength(1);
}
const stableIds = [...current.profile.jobs, ...current.profile.education].map(record => record.id);
expect(stableIds.every(Boolean)).toBe(true);
expect(new Set(stableIds).size).toBe(stableIds.length);
expect(await readVariant(page.request, sibling.id)).toEqual(sibling);
const edited = await editAndExport(page, headers, sample, sibling, info, timings);
if (sample.kind === 'repeated') {
const repeated = await upload(page, playwright, sample.language, sample.file, info, timings, 'same-source');
await approveExtracted(page, repeated.runId, sample.language);
const reapplied = await apply(page, headers, repeated.runId, sample.language, repeated.before, timings, 'same-source');
expect(reapplied.revision).toBe(2);
expect(reapplied.profile.jobs).toHaveLength(3);
expect(reapplied.profile.jobs.map(job => job.id)).toEqual(stableIds);
expect(reapplied.profile.jobs.map(job => job.bullets)).toEqual(current.profile.jobs.map(job => job.bullets));
expect(new Set(reapplied.profile.jobs.map((_, index) => reapplied.profile.metadata.fields[`jobs.${index}.company`].sourceBlockId)).size).toBe(3);
for (const index of [0, 1, 2]) expect(reapplied.profile.metadata.fields[`jobs.${index}.company`].sourceExtractionRunId).toBe(repeated.runId);
expect(await versions(page.request)).toHaveLength(2);
}
if (sample.package) {
const frozen = await freezeApplication(page, headers, edited.cv, edited.tailoredTitle);
const next = await upload(page, playwright, sample.language, sample.file, info, timings, 'target-conflict');
await rejectOriginal(page, next.runId);
const draft = await author(page, next.runId, next.review, sample.language, 'jobs', [{ name: 'title', value: 'Principal Engineer' }, { name: 'company', value: 'Ambiguous Labs' }], 0);
await expect(draft.mapping.getByText(t.careerImportCurrentValue.replace('{value}', 'Principal Engineer'), { exact: true })).toBeVisible();
const concurrent = await playwright.request.newContext();
let changed: Snapshot;
try {
const concurrentHeaders = await login(concurrent, email);
const latest = await career(concurrent);
const profile = { ...latest.profile, jobs: latest.profile.jobs.map((job, index) => index === 0 ? { ...job, title: 'Concurrent owner-reviewed Engineer' } : job) };
await json(await concurrent.put(`${apiUrl}/career/profile`, { headers: concurrentHeaders, data: { expectedVersion: latest.revision, profile } }));
changed = await career(concurrent);
} finally { await concurrent.dispose(); }
const conflict = page.waitForResponse(response => response.url() === `${apiUrl}/profile-cv/runs/${next.runId}/apply-reviewed` && response.request().method() === 'POST');
await keyboardClick(page, panel(page, next.runId).getByRole('button', { name: t.cvImportApplyReviewed, exact: true }));
expect((await conflict).status()).toBe(409);
await expect(page.getByText(t.cvImportStaleReview, { exact: true })).toBeVisible();
expect(await career(page.request)).toEqual(changed!);
await expect(draft.mapping.getByRole('textbox', { name: numbered(t.cvImportMappingValue, 1), exact: true })).toHaveValue('Principal Engineer');
const target = draft.mapping.getByRole('combobox', { name: t.cvImportMappingTarget, exact: true });
await expect(target).toHaveValue('');
await expect(panel(page, next.runId).getByRole('button', { name: t.cvImportApplyReviewed, exact: true })).toBeDisabled();
await target.selectOption('0');
await expect(draft.mapping.getByText(t.careerImportCurrentValue.replace('{value}', 'Concurrent owner-reviewed Engineer'), { exact: true })).toBeVisible();
await rejectOriginal(page, next.runId);
await draft.mapping.getByRole('button', { name: t.cvImportMappingConfirm, exact: true }).click();
const final = await apply(page, headers, next.runId, sample.language, changed!, timings, 'target-reconfirmed');
expect(final.revision).toBe(3);
expect(final.profile.jobs).toHaveLength(1);
expect(final.profile.jobs[0].id).toBe(stableIds[0]);
expect(final.profile.jobs[0]).toMatchObject({ title: 'Principal Engineer', company: 'Ambiguous Labs', start: '2019', end: '2022' });
expect(final.profile.metadata.fields['jobs.0.start'].sourceExtractionRunId).toBe(first.runId);
expect(final.profile.metadata.fields['jobs.0.title'].sourceExtractionRunId).toBe(next.runId);
const latestCv = await readVariant(page.request, edited.cv.id);
await json(await page.request.put(`${apiUrl}/cv/variants/${latestCv.id}`, { headers, data: { name: latestCv.name, settings: { ...latestCv.settings, headline: 'Later CV-only headline' }, expectedVersion: latestCv.version, source: 'manual' } }));
expect(await json(await page.request.get(frozen.detailUrl))).toEqual(frozen.detail);
expect(await json(await page.request.get(`${frozen.url}/cover-letter`))).toEqual(frozen.letter);
expect(await versions(page.request)).toHaveLength(3);
await page.goto(`/jobs/${frozen.jobId}?section=application&sub=letter`);
await expect(page.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true })).toHaveValue(frozen.text);
await info.attach('frozen-application-preservation', { body: JSON.stringify({ packageId: frozen.detail.summary.id, renderedCvSha256: createHash('sha256').update(frozen.detail.renderedCvHtml!).digest('hex'), sourceRevisions: [1, changed!.revision, final.revision], letter: 'Exact DTO/text/history unchanged', packageDetail: 'Exact immutable detail unchanged', delivery: 'No send or external email endpoint called' }), contentType: 'application/json' });
}
expect(await readVariant(page.request, sibling.id)).toEqual(sibling);
const sourceAfter = await page.request.get(`${apiUrl}/profile-cv/runs/${first.runId}/source`);
expect(sourceAfter.status()).toBe(200);
expect(sha(await sourceAfter.body())).toBe(first.fixtureHash);
} finally {
await info.attach('actual-latencies', { body: JSON.stringify({ measurementsMs: timings, boundary: 'Measured local scenario samples, not an SLA, production load claim, live model result or human/device acceptance.' }), contentType: 'application/json' });
}
});
}
@@ -0,0 +1,11 @@
import { defineConfig } from '@playwright/test';
import imports from './playwright.cv-import.config';
if (!process.env.CV_QA_FIXTURE_ROOT) throw new Error('Set CV_QA_FIXTURE_ROOT to the frozen structural-corpus-01 documents.');
export default defineConfig({
...imports,
testMatch: ['cv-import-docling.spec.ts', 'cv-integration-qa.spec.ts'],
timeout: 420_000,
outputDir: '.artifacts/cv-integration-qa-browser',
});