Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f835e54a9 | |||
| 8699ae4f42 | |||
| 0b6f4b39eb | |||
| f7dc22e5e7 | |||
| 3e1057d951 | |||
| d1fcd23087 | |||
| cc7977131a | |||
| 9234d0af15 | |||
| 985c71989f | |||
| e5aba4cf69 |
@@ -111,7 +111,8 @@ jobs:
|
||||
done
|
||||
test "$parser_install_succeeded" = true
|
||||
PIP_CACHE_DIR="$parser_pip_cache" python3 /tmp/pip.pyz install --upgrade --target .python-ci pytest==8.3.5 httpx2==2.12.0
|
||||
PYTHONPATH="$PWD/.python-ci" AI_SERVICE_SKIP_MODEL_LOAD=1 python3 -m pytest -q
|
||||
python3 --version
|
||||
PYTHONFAULTHANDLER=1 PYTHONPATH="$PWD/.python-ci" AI_SERVICE_SKIP_MODEL_LOAD=1 python3 -m pytest -q
|
||||
|
||||
- name: Restore backend
|
||||
# The runner/proxy has occasionally supplied checksum-invalid NuGet cache entries (NU3008).
|
||||
@@ -312,9 +313,16 @@ jobs:
|
||||
# checksum-valid npm download (`true` became `tr<garbage>e`, or a stray `break` appeared).
|
||||
# Retry only that unmistakable source-corruption signature from a new isolated cache.
|
||||
# Ordinary Axe violations and application assertions remain terminal.
|
||||
if grep -q "AxeBuilder.analyze" "$smoke_log" \
|
||||
&& grep -Eq "page\.evaluate: (ReferenceError|SyntaxError)" "$smoke_log"; then
|
||||
echo "Axe runtime source appears corrupted — reinstalling the locked dependencies from a fresh cache and retrying the complete suite once..."
|
||||
if { grep -q "AxeBuilder.analyze" "$smoke_log" \
|
||||
&& grep -Eq "page\.evaluate: (ReferenceError|SyntaxError)" "$smoke_log"; } \
|
||||
|| { grep -Eq 'node_modules/playwright-core/lib/utilsBundle\.js:[0-9]+' "$smoke_log" \
|
||||
&& grep -Fq 'const { items }$= this;' "$smoke_log" \
|
||||
&& grep -Fq 'SyntaxError: Missing initializer in destructuring declaration' "$smoke_log"; }; then
|
||||
echo "Browser runtime source appears corrupted — reinstalling the locked dependencies from a fresh cache and retrying the complete suite once..."
|
||||
mkdir -p .artifacts/browser-runtime-attempt-1
|
||||
cp "$smoke_log" .artifacts/browser-runtime-attempt-1/smoke.log
|
||||
if [ -d test-results ]; then cp -a test-results .artifacts/browser-runtime-attempt-1/; fi
|
||||
if [ -f .artifacts/playwright-results.json ]; then cp .artifacts/playwright-results.json .artifacts/browser-runtime-attempt-1/; fi
|
||||
rm -rf node_modules
|
||||
npm_cache="$(mktemp -d)"
|
||||
npm ci --no-audit --no-fund --cache "$npm_cache"
|
||||
@@ -336,6 +344,7 @@ jobs:
|
||||
name: browser-diagnostics
|
||||
path: |
|
||||
job-tracker-ui/.artifacts/playwright-results.json
|
||||
job-tracker-ui/.artifacts/browser-runtime-attempt-1/
|
||||
job-tracker-ui/test-results
|
||||
retention-days: 7
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CompanyCreateCompatibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Exact_Norwegian_name_reuses_owned_company_without_overwriting_existing_fields()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var existing = new Company { OwnerUserId = "owner-a", Name = "Ærlig Økonomi Ås", Location = "Original location", Source = "Finn", RecruiterEmail = "retained@example.test" };
|
||||
fixture.Db.Companies.Add(existing);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var result = await fixture.Controller.Create(new(" Ærlig Økonomi Ås ", "Imported location", "LinkedIn"), default);
|
||||
|
||||
var company = Assert.IsType<Company>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
||||
Assert.Equal(existing.Id, company.Id);
|
||||
Assert.Equal("owner-a", company.OwnerUserId);
|
||||
Assert.Equal("Ærlig Økonomi Ås", company.Name);
|
||||
Assert.Equal("Original location", company.Location);
|
||||
Assert.Equal("Finn", company.Source);
|
||||
Assert.Equal("retained@example.test", company.RecruiterEmail);
|
||||
Assert.Equal(1, await fixture.Db.Companies.IgnoreQueryFilters().CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ascii_case_insensitive_reuse_and_trimmed_input_remain_compatible()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var existing = new Company { OwnerUserId = "owner-a", Name = "Northwind AS", Location = "Original location", Source = "Finn" };
|
||||
fixture.Db.Companies.Add(existing);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var result = await fixture.Controller.Create(new(" nORTHWIND aS ", "Another location", "LinkedIn"), default);
|
||||
|
||||
var company = Assert.IsType<Company>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
||||
Assert.Equal(existing.Id, company.Id);
|
||||
Assert.Equal("Northwind AS", company.Name);
|
||||
Assert.Equal("Original location", company.Location);
|
||||
Assert.Equal("Finn", company.Source);
|
||||
Assert.Equal(1, await fixture.Db.Companies.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_identical_foreign_name_creates_a_separate_owned_company_and_preserves_source_normalization()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var foreign = new Company { OwnerUserId = "owner-b", Name = "Ærlig AS", Location = "Private location", Source = "Private source", RecruiterEmail = "private@example.test" };
|
||||
fixture.Db.Companies.Add(foreign);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var result = await fixture.Controller.Create(new(" Ærlig AS ", " Bergen ", "https://www.nav.no/jobs"), default);
|
||||
|
||||
var company = Assert.IsType<Company>(Assert.IsType<CreatedAtActionResult>(result.Result).Value);
|
||||
Assert.NotEqual(foreign.Id, company.Id);
|
||||
Assert.Equal("owner-a", company.OwnerUserId);
|
||||
Assert.Equal("Ærlig AS", company.Name);
|
||||
Assert.Equal("Bergen", company.Location);
|
||||
Assert.Equal("NAV", company.Source);
|
||||
Assert.Null(company.RecruiterEmail);
|
||||
Assert.Equal(1, await fixture.Db.Companies.CountAsync());
|
||||
Assert.Equal(2, await fixture.Db.Companies.IgnoreQueryFilters().CountAsync());
|
||||
var retained = await fixture.Db.Companies.IgnoreQueryFilters().AsNoTracking().SingleAsync(value => value.Id == foreign.Id);
|
||||
Assert.Equal("owner-b", retained.OwnerUserId);
|
||||
Assert.Equal("Private location", retained.Location);
|
||||
Assert.Equal("Private source", retained.Source);
|
||||
Assert.Equal("private@example.test", retained.RecruiterEmail);
|
||||
}
|
||||
|
||||
private sealed class Fixture(SqliteConnection connection, JobTrackerContext db) : IAsyncDisposable
|
||||
{
|
||||
public JobTrackerContext Db { get; } = db;
|
||||
public CompaniesController Controller { get; } = new(db)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "owner-a")], "test")),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public static async Task<Fixture> CreateAsync()
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(value => value.UserId).Returns("owner-a");
|
||||
var db = new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options, currentUser.Object);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
return new Fixture(connection, db);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Db.DisposeAsync();
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ namespace JobTrackerApi.Controllers
|
||||
existingQuery = existingQuery.Where(c => c.OwnerUserId == userId);
|
||||
|
||||
var existing = await existingQuery
|
||||
.FirstOrDefaultAsync(c => c.Name == name || c.Name.ToLower() == name.ToLower(), cancellationToken);
|
||||
.FirstOrDefaultAsync(c => c.Name.ToLower() == name.ToLower(), cancellationToken);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Shared browser authentication fixture — 20 October 2026
|
||||
|
||||
The instrumented Linux suite returned HTTP 429 from fixture logins in Companies, correspondence and preservation journeys. The general browser fixture configured 100 authentication permits per five-minute window. Every test and retry shares the same loopback address and backend process; the server partitions that limiter by remote IP. Fast fixture failures and repeated identity setup can exhaust this shared allowance before the window renews. A rejected fixture login prevents the workflow from running; it is not an application assertion failure.
|
||||
|
||||
The general isolated browser fixture now allows 1,000 login permits per window. This is test-only environment configuration; the application default remains ten. The separate `playwright.rate-limits.config.ts` explicitly retains ten permits and zero retries for real middleware/rate-limit contracts. No production config, auth implementation, login assertion, permission requirement or dedicated throttle expectation is changed. The allowance supplies headroom for the current 331-case full suite, repeated identities and its existing one retry.
|
||||
|
||||
The dedicated real-HTTP throttle suite passed all nine cases at 07:34:38–07:34:50 UTC, zero skips/retries/flaky outcomes, with its unchanged ten-permit configuration. Browser TypeScript and lint pass. The subsequent full Linux run must prove the shared-fixture correction; focused workflow passes alone cannot prove cumulative capacity. The original 429 traces remain evidence. This correction does not establish deployment or Phase 7 acceptance.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Browser fixture portability and confirmation focus contracts
|
||||
|
||||
Run832 at main `d1fcd2308755dfc112a2bd6a736dd763bbc895f9` completed its331-case browser suite:314 passed,16 failed, one flaky Rules draft case. Deployment was skipped. The Playwright runtime-corruption signature did not recur in this completed run. These outcomes do not establish production readiness or resolve the existing intermittent Rules observation.
|
||||
|
||||
Eleven failures came from `seed-operation.py` calling Python3.12's `Path.is_junction` on the Debian runner's Python3.11.2 PosixPath. This was independently reproduced by running the unchanged helper in an isolated, network-disabled `node:20-bookworm` container on the runner host; host Python3.12.3 passed. Junction checks now execute only on Windows, where the existing Python3.12 guard is preserved. Absolute/direct-temp-root, symlink, hardlink, exact owner/schema and insert-only SQL protections remain unchanged. All29 self-checks pass both on local Windows and in that same Python3.11 container after the correction. No existing application data or production container is mounted into the test container.
|
||||
|
||||
Five failures were stale keyboard expectations after the verified shared confirmation scroll-body accessibility correction. The focusable named body now belongs in the native tab cycle. The tests explicitly verify Cancel → reverse Tab to named body → reverse Tab to Confirm → forward Tab to body → forward Tab to Cancel, followed by the original cancellation/focus/draft/action proofs. No focus assertion, axe rule, timeout or application behavior is relaxed. All five affected native cases pass with zero skips/flaky/retries,20October18:03:21–18:04:21UTC.
|
||||
|
||||
The bounded repair changes only the offline fixture and those two browser test consumers. Full release CI and deployed identity still require a new successful run. Production readiness remains NO-GO independently of the owner's authorization to publish main. The Qwen3.5 adapter preparation is separately tested; model activation and P5-13 qualification remain separate.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Browser fixture portability — 20 October 2026
|
||||
|
||||
The instrumented Linux release run on `32bc65c` exposed `spawnSync rtk ENOENT` in the Career Profile import-review and Notifications fixtures. Operations used the same invocation. RTK is a developer command wrapper, not an application or CI dependency. The runner has `python3`; Windows uses `python`.
|
||||
|
||||
The three fixtures now invoke the configured `PLAYWRIGHT_PYTHON_PATH` directly, otherwise the platform's Python executable. The fixture arguments, isolated SQLite paths, owner checks, insert-only authorizer and assertions remain unchanged. No production data or application code is changed.
|
||||
|
||||
All 17 affected native browser cases passed locally without retries, skips or flaky outcomes at 07:19:59–07:22:20 UTC. Browser TypeScript and lint passed. This is focused Windows evidence; the subsequent full Linux release run must establish runner compatibility. The failing Linux traces remain preserved under the original diagnostic run. There is no change to browser inventory or the existing release gates.
|
||||
|
||||
The independent dialog accessibility and fixture-login rate-limit findings remain under investigation. This repair alone does not establish deployment success or Phase 7 acceptance.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Confirmation transition must preserve deliberate focus
|
||||
|
||||
Release833 at main `f7dc22e5e7efe1fd1d64f6ace2c04cf2f95bf558` exposes a distinct confirmation failure after the earlier complete tab-cycle test correction: Cancel initially receives focus and reverse traversal succeeds, but the later opening-transition callback can reset focus while forward keyboard traversal is already under way. This is a user-visible shared-owner race, not permission to relax the tab sequence or increase its timeout.
|
||||
|
||||
A deterministic new component regression focuses the named scroll body before advancing the opening transition. Existing code fails with focus moved back to Cancel:2existing cases pass/1new case fails (`p712-confirm-transition-red-20261020T184351Z`). Removing the redundant `onEntered` forced focus and its button ref leaves the existing Cancel `autoFocus` and MUI focus trap/restoration in place. All3component cases then pass (`p712-confirm-transition-green-20261020T184416Z`). No confirmation resolution, destructive guard, data write or dialog body accessibility changes.
|
||||
|
||||
All5affected native journeys pass after this repair on20October18:44–18:46UTC, with zero skips/flaky/retries. They retain explicit Cancel → reverse Tab body → reverse Tab Confirm → forward Tab body → forward Tab Cancel, the original cancellation/focus/draft assertions, and Discovery create/delete persistence. App/browser TypeScript, lint and production build pass. Full integrated successor validation follows; Linux CI/deployed acceptance is not inferred from Windows.
|
||||
|
||||
The interrupted P7-12 whole-browser attempt also exposed two stale English-only `Technical` template selectors after the approved Bokmål gallery localisation. That separate development test consumer now uses the existing locale key without changing template entitlement or any assertion. Original failing evidence is retained and the fresh successor run must use the repaired committed source.
|
||||
|
||||
Release833 also records Chromium page crashes in the CV-controls and administrator route census. An18:39UTC runner observation reports no cgroup OOM events, no container memory limit,1GiB shared memory and no kernel errors in the queried interval. This observation neither explains the crashes nor proves hardware health. Keep this infrastructure/reliability qualification separate from the deterministically reproduced focus race.
|
||||
|
||||
Production readiness remains NO-GO independently of the owner's release authorization. No model activation or P5-13 qualification is part of this repair.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Confirmation keyboard scrolling — 20 October 2026
|
||||
|
||||
The Linux compose journey found a serious `scrollable-region-focusable` violation in the shared confirmation dialog at a short landscape viewport. Its message body can overflow independently while containing no focusable content. The shared owner is `ConfirmProvider`, also used by attachment deletion, application workspace changes, CV template changes and Career evidence deletion.
|
||||
|
||||
The body now exposes a keyboard-focusable region named by the existing localized dialog title. Cancel retains initial focus; confirmation, dismissal, focus restoration and Promise resolution are unchanged. No new strings, dialog framework or saving semantics are introduced.
|
||||
|
||||
The strengthened existing unit case failed ordinarily before the implementation (one failure, one pass). Afterward all eight cases in the confirmation, confirmation-flow, dialog-flow and modal-state suites passed. The full ten-case native compose suite passed without retries/skips/flaky outcomes at 07:28:17–07:29:43 UTC against a fresh production export. This includes the eight-width bilingual theme/keyboard journey and its unchanged all-enabled axe assertion. Build passed. No test inventory change, accessibility waiver or production authorization change is made.
|
||||
|
||||
Human screen-reader and physical-device acceptance remain pending. This narrow correction does not accept the remaining Phase 7 migration or establish live deployment success.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Notification caption fixture — 20 October 2026
|
||||
|
||||
The full Linux diagnostic found four ordinary shell failures, each repeated by the existing CI retry: the loose `getByText(t.notificationsUnread)` query matched both the visible localized Unread caption and the shell's unread-count live announcement. Both are intentional accessibility/UI content. The fixture intended to assert the exact caption.
|
||||
|
||||
The locator now uses `{ exact: true }`. It still requires the visible translated caption, retains the live status announcement and preserves every notification mark-read/dismiss assertion and axe scan. No product behavior changes. Browser inventory stays 331.
|
||||
|
||||
All 20 existing shell-readiness cases pass without skips/flaky/retries at 07:42:01–07:44:53 UTC, across English/Bokmål and light/dark, with browser TypeScript passing. Whitespace checks pass. Full Linux acceptance remains required for the new main candidate; this focused result is not deployment success.
|
||||
|
||||
Run 828 completed its 331-case browser lane with 274 passing and 57 failing cases. All failed cases are accounted for by the five investigated causes: 35 shared-login 429, 15 Python-wrapper absence, four caption locator collisions, two stale route expectations and one keyboard-scroll axe violation. Original retries and failure artifacts remain retained. Subsequent corrected-run results must determine whether these repairs are sufficient.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Playwright runtime corruption signature — 20 October 2026
|
||||
|
||||
Corrected release run831 did not reach deployment. Its browser lane encountered Chromium target crashes and an installed Playwright parser failure at `node_modules/playwright-core/lib/utilsBundle.js:35841`: `const { items }$= this;`, followed by `SyntaxError: Missing initializer in destructuring declaration`. A worker then exited before executing its cases. Only46 browser attempts appear in the terminal log; this is not a completed331-case regression. An ordinary preview-visibility assertion also failed and is retained, rather than labelled an infrastructure success.
|
||||
|
||||
This resembles earlier runner dependency/download corruption, but the job container was removed before its installed bytes could be compared with the lockfile archive. Neither persistent filesystem corruption nor faulty hardware is proven. The full900-entry diagnostic ZIP was downloaded and CRC-checked: SHA256 `f6d8e9659ca824bb12d283913f6e16f3933974bbc5e11c78e3524d8712e75632`. Original logs and traces remain retained.
|
||||
|
||||
The workflow already allows one complete-suite retry after its narrowly recognized Axe source-corruption signature, using a new npm cache and the unchanged lockfile. Extend that same bounded policy only when all three observed Playwright indicators occur together: the exact dependency path/line marker, the exact malformed statement and the exact parser error. Ordinary application assertion failures, target crashes alone, user-source syntax errors and incomplete signatures remain terminal. The six policy tests execute the actual Bash guard and pass. No assertion, browser inventory, timeout, ordinary retry, dependency integrity or release gate is relaxed.
|
||||
|
||||
Before the existing reinstall/retry, preserve the first smoke log, JSON report if available and complete traces/screenshots under `.artifacts/browser-runtime-attempt-1`; include them in the same always-uploaded artifact. A subsequent successful retry must still complete the entire browser suite; failure remains blocking. This does not repair or qualify the underlying runner, prove the preview assertion resolved or establish deployment success. Production/operator, physical hardware/runtime and manual application qualifications remain open. P5-13 and model activation are unchanged.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Qwen3.5 compatibility preparation
|
||||
|
||||
The owner requested `qwen3.5:9b-q4_K_M` after live deployment. The model is downloaded, but the active sidecar still uses the previous model. This change prepares the existing adapter; it does not activate a model or qualify the withheld P5-13 capability.
|
||||
|
||||
The existing adapter consumes Ollama's final `response`. A synthetic local probe found an empty response with the Qwen3.5 thinking default; a20October probe with `think:false` returned the requested synthetic JSON and no thinking text. The adapter now sets that request field only when the configured base family is exactly `qwen3.5`. Existing temperature, JSON mode, timeouts, response parsing, other model payloads and external-provider consent/fallback remain unchanged. Custom namespace aliases are outside this exact family match.
|
||||
|
||||
Seven new offline cases verify both text/JSON for the requested tag and bare family, and unchanged payloads for qwen2.5, qwen3 and a lookalike family. Four Qwen3.5 cases failed ordinarily before the two-line change; three compatibility cases passed. The full sidecar suite then passed **56/56**, zero failures/errors/skips,8.45seconds,20October13:32UTC. The prior49-case baseline grows by seven; no model calls are part of these tests. Raw RED/GREEN logs and JUnit are retained under `.artifacts/release-20261020/model-compat/`.
|
||||
|
||||
Live activation, deployed adapter identity, GPU residency and final-response smoke remain pending behind the release. P5-13's deployed-identity/366-call/independent-human-review/UI requirements remain unmet and its review feature stays disabled. Production readiness remains NO-GO independently of the owner's release authorization.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Bounded follow-redirects security patch
|
||||
|
||||
The21October fresh npm audit identifies production-classified transitive `follow-redirects`1.16.0 with high-severity [array-header credential stripping](https://github.com/advisories/GHSA-q3jh-wrc6-jgq8) and [custom sensitive-header escaping](https://github.com/advisories/GHSA-8r9p-f939-6h3c) advisories. Both advisory records identify1.16.1as patched. This is a dependency finding, not evidence that Jobjakt was exploited or that its browser adapter exercises either vector.
|
||||
|
||||
`npm update follow-redirects --ignore-scripts --no-audit --no-fund` changes exactly one package. The only tracked dependency diff is that lock entry's version, tarball URL and integrity:1.16.0→1.16.1. Existing package ranges, Axios, frameworks, other dependencies, audit policy and the owner's temporary dev-only braces exception remain unchanged. No override or new dependency is introduced.
|
||||
|
||||
Fresh production audit reports0vulnerabilities. The full audit policy passes with only the previously approved dev-only braces-derived high chain accepted through31October; moderate development findings remain recorded. No additional exception is added. Two synthetic loopback contracts verify array-form Authorization/Cookie/Proxy-Authorization stripping and custom credential names containing`^`/`|`, while retaining a public header. Both pass against the installed1.16.1; no real credentials or external target is involved.
|
||||
|
||||
Raw pre/post audit records are `.artifacts/phase7/current/p712-final-security/` and `.artifacts/phase7/current/p712-follow-redirects/`. The bounded helper is `.artifacts/phase7/current/p712-redirect-contract.cjs`. Production build and application/browser TypeScript/lint are rerun; final whole-application/browser proof uses the patched dependency set. The active pre-patch browser candidate is interrupted and retained rather than presented as validating the new lock.
|
||||
|
||||
This patch does not resolve the independent runner's Python/.NET native crashes, qualify deployed identity, activate Qwen3.5or complete P5-13. Production readiness remains NO-GO independently of the owner's release authorization. No Phase8 implementation.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Parser CI native exit investigation, 2026-10-21
|
||||
|
||||
## Verified evidence
|
||||
|
||||
CI run 834, API test job 1721, failed in `Test document parser boundary`.
|
||||
The fetched log is `.artifacts/phase7/p7-09/instrumented-release-ci/20261021T050601Z/failed-job-logs.log`;
|
||||
the adjacent `api-observation.json` records the GET of job 1721's logs.
|
||||
The runner's internal task ID 1689 is separate from the API job ID.
|
||||
|
||||
At 2026-10-20 20:36:38 UTC, pytest reported `56 passed, 1 warning in 5.36s`.
|
||||
The shell subsequently reported a segmentation fault and exit 139. Passing assertions
|
||||
did not constitute successful process completion. Later browser steps did not run;
|
||||
deployment remained blocked.
|
||||
|
||||
Read-only host inspection with
|
||||
`coredumpctl --no-pager info 3804681` confirmed:
|
||||
|
||||
- Signal: 11 (SIGSEGV).
|
||||
- Command: `python3 -m pytest -q`.
|
||||
- Executable: `/usr/bin/python3.11`.
|
||||
- Timestamp: 2026-10-20 22:36:38 CEST (20:36:38 UTC).
|
||||
- Docker scope: `040c0b99c92e0a5a48c00994e10a264884ca8172f5eac94d49e3d7aa1d92fd27`,
|
||||
matching the failed job's container.
|
||||
- Stored core: `/var/lib/systemd/coredump/core.python3.0.7aab2b376ce94d92bf796bbeb62063a4.3804681.1792528598000000.zst`.
|
||||
|
||||
The core was inaccessible to SSH user `pi`; `sudo -n` required a password.
|
||||
No native backtrace was obtained. Kernel journal error entries for
|
||||
20:30-20:40 UTC were empty; that does not establish host health.
|
||||
|
||||
## Interpretation and bounded diagnostic
|
||||
|
||||
The timing is consistent with interpreter shutdown or cleanup, but the root cause
|
||||
is unproven. `AI_SERVICE_SKIP_MODEL_LOAD=1` prevents model weight loading;
|
||||
`app.py` still imports Torch and Transformers. Native extension cleanup is therefore
|
||||
a candidate, not an identified cause. Previous download corruption and browser
|
||||
crashes do not prove the cause of this Python crash.
|
||||
|
||||
The workflow now prints the Python version and enables `PYTHONFAULTHANDLER=1`
|
||||
for the unchanged pytest command. It preserves the existing dependency installs,
|
||||
all tests, and process exit status, without adding retries or suppressing signals.
|
||||
The existing pip log already records installed distribution versions. In this run,
|
||||
the test-runtime install upgraded locked AnyIO 4.14.2 to 4.15.1 and IDNA 3.19 to
|
||||
3.20; this is a reproducibility observation, not a demonstrated cause, and no
|
||||
dependency change is included here.
|
||||
|
||||
Next evidence: inspect the retained core with authorized access, or capture the
|
||||
faulthandler output from a subsequent normal CI run before choosing a fix.
|
||||
The diagnostic change has only received workflow text and whitespace checks;
|
||||
it has not been exercised in CI. No production mutation, model qualification,
|
||||
or successful release is claimed.
|
||||
@@ -1,10 +1,6 @@
|
||||
# Phase 7 — Commit and evidence ledger
|
||||
|
||||
## Continuation — 20 October 2026
|
||||
|
||||
Company compatibility repair **5addcde1e22e9625d1b39df037a05b24c69c9cde** is pushed, directly verified 07:16:14 UTC. It preserves owner-scoped exact Norwegian names and ASCII case reuse; targeted backend 3, full backend 1057 and native Companies six pass. Separate [import completion](p7-09/IMPORT_COMPATIBILITY.md) has full frontend 837/97, native 25, types/lint/build and final hygiene PASS; its publication receipt follows this commit. Original candidate/RED evidence remains retained.
|
||||
|
||||
Latest published presentation: [P7-09 Preferences/Connections](p7-09/PREFERENCES.md), final91/6 frontend, unchanged-backend52, native5+OAuth3+Rules/Settings8+ordinaryP2-D01=17 and type/lint/build PASS onInBPthl1fsnID3NNdgwbI. All18 new PNGs reviewed;54 scans0/284 remain qualified. Source34/earlier31/32 scopes and every RED/failed fixture attempt are [recorded](p7-09/evidence/preferences-results.json). Final staged hygiene PASS; e7dba9a normal push directly verified clean08:50:42UTC; +17/0 derives824/95 and+5 browser331, backend1054 unchanged. Data/privacy/plan/backup and its separate import prerequisite remain pending; main unchanged/productionNO-GO.
|
||||
Current pending publication: [P7-09 Preferences/Connections](p7-09/PREFERENCES.md), final91/6 frontend, unchanged-backend52, native5+OAuth3+Rules/Settings8+ordinaryP2-D01=17 and type/lint/build PASS onInBPthl1fsnID3NNdgwbI. All18 new PNGs reviewed;54 scans0/284 remain qualified. Source34/earlier31/32 scopes and every RED/failed fixture attempt are [recorded](p7-09/evidence/preferences-results.json). Final staged hygiene/normal commit/push pending; +17/0 derives824/95 and+5 browser331, backend1054 unchanged. Data/privacy/plan/backup and its separate import prerequisite remain pending; main unchanged/productionNO-GO.
|
||||
|
||||
Latest published repair: [P7-09 OAuth sender](p7-09/OAUTH_SENDER.md), **dac29278db3c187349b97771ca2f383c9e6e0962**, clean normal push directly verified2026-10-17T08:27:48.643285UTC, HEAD=origin=live/main27657f3 unchanged. Final39/3 frontend, native3+5+ordinaryP2-D01=9, four type/lint/build gates and staged hygiene PASS. [Finite results](p7-09/evidence/oauth-results.json) retain both meaningful RED series, failed fixture attempts,23 held inputs, seven actual final image reviews and89 consumer axe incompletes. Bounded repair CONDITIONAL GO; +16 frontend/0 suites and+3 browser derive807/95 and326; backend1054 unchanged, no integrated run. Both Settings presentation boundaries remain pending.
|
||||
|
||||
@@ -144,8 +140,3 @@ Eighteen new Operations cases derive **791/95 frontend**; five browser cases der
|
||||
### P7-08 publication and P7-09 entry
|
||||
|
||||
Operations **c5176e3251c869e13284b994b59b2f7aa9220eb1** is normally pushed and directly verified **2026-10-17T02:03:53.815275UTC**: clean HEAD=origin=live development, local/origin/live main27657f3 unchanged. Both P7-08 UI boundaries remain implemented **CONDITIONAL GO and published**; manual/provider/output/release conditions remain. Earlier pending-publication text is superseded by this exact receipt in Operations results. P7-09 follows its approved two boundaries; a separately bounded OAuth sender repair first protects its two existing callback consumers. Fresh entry23/3 frontend and40 unchanged-binary backend cases pass;791/95,323,1054 remain derived inventories, not full-suite passes. No production or Phase8 action.
|
||||
|
||||
|
||||
### P7-09 Preferences/Connections publication
|
||||
|
||||
Preferences/Connections **e7dba9a2f776c55e3c4f4df7ef59076f93fa92df** was normally pushed and directly verified **2026-10-17T08:50:42.777224UTC**, with clean HEAD=origin=live development and local/origin/live main27657f3 unchanged. Final staged hygiene passed08:50:12UTC. The [portable publication receipt](p7-09/evidence/preferences-results.json) supersedes earlier pending-publication observations; data/privacy/plan/backup and its separate import prerequisite remain pending.
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
# Phase 7 — Initial preservation matrix
|
||||
|
||||
## Import continuation — 20 October 2026
|
||||
|
||||
All 57 groups retain **56 PARTIAL / C11 DEFERRED** and 33 routes. [Import compatibility](p7-09/IMPORT_COMPATIBILITY.md) adds bounded PRES-J10/E09/S03/S04/W11 proof: real export/create round trip, create-field allowlist, whole-file validation, owned graph exclusion, actual account disposal, acknowledged counts and uncertain-write stop, internal-tab retention and preserved Rules saving. Fresh full frontend 837/97, backend 1057 and 25 affected browser cases pass. This does not close S05 backup/restore, full integrated browser acceptance, manual accessibility, production or the remaining P7-09 presentation owners. No route, ownership or document rendering contract changes.
|
||||
|
||||
P7-09's [separate sender repair](p7-09/evidence/oauth-results.json) preserves M01/M02/M06/E09 and all57 statuses:39 affected caller cases,3 new local-sender journeys,5 existing Application journeys and ordinary P2-D01 pass. No real provider grant, new permission, route, data or document-output acceptance is claimed. Preferences and data lifecycle remain pending.
|
||||
|
||||
The first [Preferences/Connections boundary](p7-09/evidence/preferences-results.json) now preserves S03/S04/S05/M06/E09:91 frontend,52 unchanged-backend,17 native including Rules/Settings/OAuth/ordinaryP2-D01 PASS; all18 new PNGs reviewed. Section save scopes, six Rules fields, canonical ownership and server admission remain intact. Manual/whole-group qualifications and all57 statuses/33routes stay unchanged. First-boundary hygiene PASS and e7dba9a publication directly verified08:50:42UTC; data lifecycle remains pending.
|
||||
The first [Preferences/Connections boundary](p7-09/evidence/preferences-results.json) now preserves S03/S04/S05/M06/E09:91 frontend,52 unchanged-backend,17 native including Rules/Settings/OAuth/ordinaryP2-D01 PASS; all18 new PNGs reviewed. Section save scopes, six Rules fields, canonical ownership and server admission remain intact. Manual/whole-group qualifications and all57 statuses/33routes stay unchanged. Data lifecycle and final publication remain pending.
|
||||
|
||||
Latest completed P7-08 reconciliation: [Operations results](p7-08/evidence/operations-results.json) retain all 57 statuses below, with 56 PARTIAL and C11 DEFERRED. The original 32 routes remain alongside the approved `/documents` route (33 current patterns). Fresh affected77/7 and native12 pass, including ordinary P2-D01. Human/device/output and whole-group qualifications remain. P7-08 publication `c5176e3251c869e13284b994b59b2f7aa9220eb1` was directly verified clean at 2026-10-17T02:03:53.815275 UTC; older connection failures remain historical evidence.
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Phase 7 — Full migration
|
||||
|
||||
## Current continuation — 20 October 2026
|
||||
|
||||
P7-00–08 and P7-09 Preferences/Connections remain implemented CONDITIONAL GO. The [P7-09 import prerequisite](p7-09/IMPORT_COMPATIBILITY.md) now passes full frontend 837/97, full backend 1057, affected native 25 and type/lint/build gates. Its backend prerequisite `5addcde` is pushed and remotely verified; import UI publication follows final hygiene. Browser inventory is 335, with full integrated acceptance still due at P7-12. Privacy/usage/Quick Capture/backup, P7-10 Administration, P7-11 residual UI and P7-12 acceptance remain. The historical sections below retain their as-observed baselines.
|
||||
|
||||
The owner subsequently authorized main promotion despite existing release NO-GOs. Main currently contains `f9bf31c`, while the diagnostic Linux run exposed additional test portability, login-fixture and dialog accessibility failures. Release success is not claimed. P5-13 remains withheld, all 57 preservation qualifications remain binding, and Phase 8 implementation has not started.
|
||||
|
||||
**Authorised development execution; production remains NO-GO.** The owner resolved the main/deployment conflict by directing Phase 7 to continue on development while keeping main unchanged. Follow the [original Phase 7 scope](../IMPLEMENTATION_PLAN.md#phase-7--full-migration) and the exact [Phase 6 handoff](../phase-6/p6-05/PHASE7_HANDOFF.md); this record does not replace the approved architecture or introduce another phase plan.
|
||||
|
||||
The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-redesign-phase4`. Phase 6 application source is unchanged from `bd47214bdcd74336788dca0f294b8ddb9403cac1`, with measured export `2LDblja_XnSBWSuY6ys_V`. Main remains `27657f31131cd95d42f0e1290853ec9c9b3959a4`. The [Phase 6 acceptance](../phase-6/p6-05/README.md), [aggregate evidence](../phase-6/p6-05/evidence/validation.json) and [push ledger](../phase-6/COMMITS.md) establish implemented review CONDITIONAL GO, production NO-GO and Phase 7 readiness CONDITIONAL GO.
|
||||
@@ -24,7 +18,7 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede
|
||||
| P7-06F | Deferred `followMode` reviewed composer | Optional separately reviewed mail slice; **DEFERRED** until mode→draft/review action and fallback are explicitly decided |
|
||||
| [P7-07](p7-07/README.md) | Documents owner-scoped index | Implemented CONDITIONAL GO; contract/UI published and directly verified through9dfea12 at21:01:11UTC. Final affected86/8, native8+CV4+ordinaryP2-D01/types/lint/build PASS;18 reviewed PNGs,15axe0/101 qualified. Original32+Documents=33routes; actual MariaDB/human/device/production qualifications remain |
|
||||
| [P7-08](p7-08/README.md) | Existing notifications and operations | Implemented CONDITIONAL GO and published: UTC9dfea/recent40cfe04/Operationsc5176e3, clean direct refs verified02:03:53UTC. Ops77/7+native12/types/lint/build/14Ops+1P2image reviews PASS;12Opsaxe0/3+14notifaxe0/102 qualified.57groups33routes/F20 preserved |
|
||||
| [P7-09](p7-09/README.md) | Remaining Settings, Connections and data lifecycle | Sender repair dac2927 published. Preferences/Connections91/6+backend52+native17+gates PASS; all18 new PNGs reviewed; final hygiene PASS, e7dba9a normal push directly verified clean08:50:42UTC. Data/privacy/plan/backup follows with separate import compatibility prerequisite. Rules/save owners and all qualifications retained |
|
||||
| [P7-09](p7-09/README.md) | Remaining Settings, Connections and data lifecycle | Sender repair dac2927 published. Preferences/Connections91/6+backend52+native17+gates PASS; all18 new PNGs reviewed; final hygiene/publication pending. Data/privacy/plan/backup follows with separate import compatibility prerequisite. Rules/save owners and all qualifications retained |
|
||||
| P7-10 | Admin operational presentation | Existing distinct Users/Audit/System routes and server authorization; planned |
|
||||
| P7-11 | Remaining Analytics/error/empty/dialog integration | Inventory-confirmed residual presentation only; existing reports remain; planned |
|
||||
| P7-12 | Integrated full-migration acceptance | Every executed slice pushed with finite evidence; fresh applicable full regression and all debt dispositions; planned |
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Company-create compatibility prerequisite
|
||||
|
||||
The actual JSON export/import journey exposed a backend prerequisite: importing the exact existing Norwegian company name created a second company. SQLite's default `LOWER()` handles ASCII while the bound .NET lower-case parameter folds Norwegian letters. The previous comparison therefore missed an exact existing name containing `Æ`, `Ø` or `Å`. The original failing import trace and the candidate repair evidence remain under `.artifacts/phase7/p7-09/`; the importer does not work around the server by changing company names.
|
||||
|
||||
The bounded repair checks exact equality alongside the existing lower-case comparison inside the same owner-filtered query. Trimming, source normalization, 200-for-reuse/201-for-create, metadata retention and account ownership are preserved. It performs no schema migration, historical deduplication or destructive write. Broader Unicode case-folding equivalence and concurrent creation idempotency remain RD21 debt.
|
||||
|
||||
Three real SQLite regression cases prove exact Norwegian-name reuse without overwriting existing fields, retained ASCII case-insensitive reuse, and separate owned creation when an identical foreign company exists. The last case also verifies retained foreign metadata and source normalization. Fresh Release compilation and these three cases pass. A fresh unfiltered backend run passes **1,057/1,057**, zero skipped, at **20 October 2026 07:05:21–07:05:38 UTC**. The increase from 1,054 is exactly these three ordinary cases; existing cases were not removed or weakened.
|
||||
|
||||
The fresh native Companies suite passes **6/6**, zero retries/skipped/flaky, at **07:10:12–07:11:05 UTC**. It exercises the existing presentation and persisted owner behavior against the rebuilt API and current production frontend export. The [portable evidence](evidence/company-create-results.json) records commands, results and source/binary identities, explicitly identifying its post-run source observation. Earlier native import/company evidence is historical evidence, not substituted for these fresh results.
|
||||
|
||||
Preservation references are **PRES-J08, PRES-J10 and PRES-E09**. The complete matrix stays **56 PARTIAL / C11 DEFERRED**. Actual MariaDB/provider and production qualification, manual reader/device checks, the remaining import/data/privacy/plan/backup migration and RD21 concurrency work are not accepted here. P5-13 stays withheld, followMode stays deferred and Phase 8 is not implemented. This prerequisite is published separately from the active import UI work on the development branch; production readiness remains NO-GO despite the owner's separately authorized release repair.
|
||||
@@ -1,141 +0,0 @@
|
||||
# P7-09 — Job import compatibility prerequisite
|
||||
|
||||
**Candidate evidence; final acceptance and publication pending.** This is the bounded import prerequisite within [P7-09 Settings, Connections and data lifecycle](README.md), followed by the remaining data/privacy/plan/backup presentation. It is not completion of that second boundary or of Phase 7. No backend/schema, provider, restore, production or Phase 8 capability is added.
|
||||
|
||||
## Original defect and scope
|
||||
|
||||
The existing [JSON jobs export](../../../../JobTrackerApi/Controllers/ExportController.cs) calls `JsonSerializer.SerializeToUtf8Bytes(jobs)` without a camel-case naming policy. Its exported model members include `Company`, `JobTitle` and the remaining PascalCase job fields. The prior [import owner](../../../../job-tracker-ui/src/components/ImportExportJobs.tsx) cast parsed JSON to a TypeScript type, then read only `j.company` and `j.jobTitle`. The cast supplied no runtime conversion or validation. The ordinary RED records a company request with `undefined` and missing job fields for an app-export-shaped fixture. The prior camelCase path also omitted supported create fields, including structured compensation and description metadata.
|
||||
|
||||
The repair accepts the app's PascalCase export and the existing camelCase import shape. It normalizes only supported create fields, validates the complete array before making any create request, and retains the existing serial `POST /companies` then `POST /jobapplications` workflow. Export serialization, CSV generation/conversion, API ownership/defaults and endpoint semantics remain at their current owners. This is creation of new jobs from supported data, not restoration of an account export.
|
||||
|
||||
| Boundary | Candidate behavior |
|
||||
|---|---|
|
||||
| Root and row validation | Require an array of non-array row objects, a company object with a nonblank string name, and a nonblank string job title. Reject malformed JSON and invalid known fields before the first write, including an invalid later row. |
|
||||
| Casing | Recognize each supported field's camelCase and PascalCase spelling. Equivalent known aliases are accepted; conflicting aliases fail preflight instead of silently choosing one. |
|
||||
| Company payload | Forward only `name`, optional `location` and optional `source`. Exported company IDs, ownership and unrelated private/history fields are excluded. |
|
||||
| Job text payload | Preserve supplied `status`, `location`, `salary`, `salaryCurrency`, `salaryPeriod`, `nextAction`, `notes`, `description`, `translatedDescription`, `descriptionLanguage`, `tags`, `coverLetterText`, `jobUrl`, `source` and `countryCode`, alongside required `jobTitle`. |
|
||||
| Numeric/date payload | Preserve valid supplied `salaryMin`/`salaryMax`, including zero and supported finite decimal values, and supported `dateApplied`, `followUpAt`, `feedbackRequestedAt` and `deadline` values. Preflight also rejects the characterized decimal/DateTime converter-envelope failures below. Validate known types/calendar values without inventing a timezone or rewriting stored text. Omitted fields remain omitted; supported explicit nulls remain explicit. Server validation/normalization remains authoritative. |
|
||||
| Server-owned state | Ignore exported IDs, owners, original company associations, saved timestamps, deletion state, derived display/response flags, history, messages/events, attachments and CV associations. Use the company ID returned by the current authenticated create request. |
|
||||
| Admission and feedback | Reserve one component-local import synchronously during file reading and serial writes. Present checking/progress, acknowledged completion count, invalid-row guidance or stopped-row guidance. No automatic mutation retry. |
|
||||
|
||||
Canonical Career Profile data, CV variants/overrides/revisions, frozen package associations, document binaries and PDF pagination are outside this importer. None is reconstructed or mutated to make the round trip appear complete.
|
||||
|
||||
### Converter-envelope follow-up
|
||||
|
||||
A separate trust-boundary review found that JavaScript-finite numbers and JavaScript-parseable dates can still fail the server's JSON converters after earlier rows have committed. An offline probe loaded the installed **System.Text.Json 9.0.19 assembly in hosted PowerShell/.NET 10** to characterize those converter cases. That is converter characterization, not execution of the API host/runtime, a backend test suite or HTTP end-to-end evidence.
|
||||
|
||||
Two additional ordinary regressions cover the bounded repair:
|
||||
|
||||
- Salary values must be null or finite JavaScript numbers with `abs(value) < 2^96`. The .NET decimal maximum is `2^96 - 1`, but JavaScript rounds that upper boundary to `2^96`; rejecting equality prevents forwarding the rounded, converter-invalid value. Positive controls retain zero, supported negative values and a representable value immediately below the characterized upper edge.
|
||||
- Supported date text has at most 16 fractional-second digits and numeric offsets no greater than `±14:00` (`14` hours requires zero offset minutes). Explicit numeric offsets must also resolve within UTC years 0001–9999. That UTC-range check applies only when an explicit numeric offset is present. No-zone minimum/maximum values remain accepted unchanged with their unspecified local-time meaning; no timezone is added to make them pass. Positive controls retain `0001-01-01T00:00:00Z`, `9999-12-31T23:59:59Z`, a `+14:00` value, 16 fractional digits and no-zone `0001-01-01T00:00` / `9999-12-31T23:59`.
|
||||
|
||||
These checks reject the reproduced converter failures during whole-array preflight. They do not replace backend business validation, establish full converter parity across environments, add transactional behavior or alter existing request/session ownership. The original RED and the strengthened RED with explicit no-zone positive controls are both retained below. The latter is the exact final-test precursor to the converter GREEN.
|
||||
|
||||
## Partial commits and uncertain outcomes
|
||||
|
||||
Whole-array preflight prevents known invalid input from starting a partial batch; it does **not** make the later HTTP requests transactional. Company creation can complete before its job request fails. Earlier job requests can complete before a later row fails. The displayed count advances only after a job response is accepted, so it represents acknowledged completions; a lost response can leave an additional server commit uncertain.
|
||||
|
||||
There is no rollback, company cleanup, creation-intent idempotency key, deduplication, resumable import or automatic replay. A local admission mutex does not resolve cross-tab/server concurrency or a lost response. Users must inspect Jobs before explicitly retrying an interrupted or failed import; re-importing can create additional resources. The server's authenticated ownership, validation and persistence contracts remain unchanged. This qualification retains [RD21 and RD28](../DEBT.md#every-retained-debt).
|
||||
|
||||
## Settings lifetime foundation
|
||||
|
||||
[SettingsView](../../../../job-tracker-ui/src/components/SettingsView.tsx) adds an opt-in lazy `keepMounted` option to its existing `TabPanel`; **General alone opts in**. After activation, an inactive retained panel has HTML `hidden`. Its controls are absent from the active accessibility/keyboard flow, while its existing child owners keep their own state and request lifetimes. No save owner, global store, localStorage draft, route blocker or import queue is introduced.
|
||||
|
||||
An accepted import therefore continues when the user switches General → Notifications/Rules → General. The real ImportExportJobs owner retains its synchronous admission, progress and result. The existing privacy owner also retains its unsaved choice and initial read across these internal tabs. This is a lifetime foundation, not a repair or acceptance of the remaining privacy save/error behavior.
|
||||
|
||||
Leaving the Settings route actually unmounts the children. ImportExportJobs checks its mounted generation and current session before each subsequent write and before later feedback. Existing session invalidation, cookie/context checks and API cancellation remain unchanged; an old account cannot obtain a new account's write context. These checks stop queued work and ignore late client continuation, but cannot undo an HTTP request already committed by the server.
|
||||
|
||||
Rules and Notifications retain their existing parent-owned draft/save contracts and conditional panel behavior. Backup remains conditional, mounts only on explicit entry, and unmounts when leaving its tab. This foundation does not retain Backup or qualify pending account ZIP completion; that remains in the later approved data lifecycle work.
|
||||
|
||||
After the lifetime pair finished, the owner appended the following scope warning to the shared catalog:
|
||||
|
||||
> Keep Settings open until the import finishes. If you leave, the import may be incomplete.
|
||||
|
||||
The paired Bokmål warning is:
|
||||
|
||||
> Hold Innstillinger åpent til importen er ferdig. Hvis du går ut, kan importen bli ufullstendig.
|
||||
|
||||
“Leave” means leaving the Settings session/route; the retained General panel allows internal section switches. The preceding catalog copy also states that IDs, owners, history, attachments and CV associations are not restored. The post-pair catalog SHA256 is `52cd246d3176096d8e7b9ff2de86d5b998d0d715bc2ac51c2a8151c0f5f1ad60`; final validation must cover this later copy.
|
||||
|
||||
## Measured regression evidence
|
||||
|
||||
All timestamps below are **2026-10-17 UTC**. These are finite mocked-network frontend tests with the real component owners, not live HTTP persistence, native browser, human accessibility or production evidence. No skips, expected failures or relaxed assertions were introduced to obtain these results.
|
||||
|
||||
| Run | Exact recorded time | Result and scope |
|
||||
|---|---|---|
|
||||
| Import compatibility RED | Shell command `08:55:44.198675–08:55:51.702201` | **11 PASS / 8 FAIL of 19**, three suites: new job-import regression plus existing CSV-export and file-picker cases. The app-export mismatch, validation, partial-count, admission and disposal failures remain recorded. |
|
||||
| Import compatibility candidate GREEN | Shell command `13:33:47.272272–13:33:58.610639` | **19/19 PASS**, the same three-suite scope. This is the importer candidate before the Settings lifetime pair. |
|
||||
| Settings lifetime RED | Jest runner start `13:35:22.446`; suite `13:35:23.184–13:35:30.313` | **0 PASS / 2 FAIL**. Switching tabs disposes the accepted batch after its first company request; the privacy draft input is removed. Both are ordinary runtime failures. |
|
||||
| Settings lifetime GREEN | Jest runner start `13:35:54.419`; new suite `13:35:55.128–13:35:58.999`; existing Settings suite `13:35:59.016–13:36:14.662` | **23/23 PASS**: two new integration cases plus all 21 existing Settings cases. The new cases prove one completed two-job batch across internal tabs, result retention, actual unmount stopping a subsequent queued job, retained privacy draft/read through a Bokmål switch without writes, and lazy/disposable Backup reads. |
|
||||
| Converter-envelope initial RED | Shell command `13:40:39.075122–13:40:48.966580` | **19 PASS / 2 FAIL of 21**, three suites. Existing cases remain passing; two new converter-boundary regressions fail ordinarily. |
|
||||
| Converter-envelope final-test RED | Shell command `13:41:47.755723–13:41:56.617918` | **19 PASS / 2 FAIL of 21**, with the exact final test source and strengthened no-zone positive controls. The earlier RED remains historical evidence rather than being overwritten. |
|
||||
| Converter-envelope GREEN | Shell command `13:42:19.716130–13:42:28.377505` | **21/21 PASS / 3 suites**, covering the 11 new import cases and 10 existing CSV/picker cases. Product `d6c34ac9…`, test `c10ea4ad…` and the later warning catalog `52cd246d…` are the as-run sources. This does not rerun the earlier Settings lifetime suite or qualify the final combined/native gates. |
|
||||
|
||||
The two lifetime commands, run from `job-tracker-ui`, were:
|
||||
|
||||
```text
|
||||
rtk proxy npm test -- --runInBand --runTestsByPath src/settings-import-lifetime.test.tsx --json --outputFile=../.artifacts/phase7/p7-09/settings-import-lifetime-red-jest.json
|
||||
rtk proxy npm test -- --runInBand --runTestsByPath src/settings-import-lifetime.test.tsx src/settings-view.test.tsx --json --outputFile=../.artifacts/phase7/p7-09/settings-import-lifetime-green-jest.json
|
||||
```
|
||||
|
||||
The compatibility lane's exact `npm.cmd` commands, source/post snapshots and counts are retained in its command/result files. Do not sum the overlapping Settings/consumer suites into a whole-suite result.
|
||||
|
||||
### Source and raw identities
|
||||
|
||||
| Artifact/source | SHA256 |
|
||||
|---|---|
|
||||
| Importer original RED source | `e47ab9ca06212d868f54d7e5a7d2e6a51f90574eaad4b8bd397d4489e3cee6a2` |
|
||||
| Importer candidate used by both lifetime runs | `411e65bed101a05775e18cbda4c68ed14338b12adbc52f6eaf961b09a583c974` |
|
||||
| Import regression source before converter-envelope additions | `142f2ead0c379eb95ebb7c05c50e238a161dd33c30e15cef85224bbb1ab24d22` |
|
||||
| Importer after converter-envelope repair | `d6c34ac9cf12b4351eaf010fa57469b5c811d82dae13360104f955fec9349fd3` |
|
||||
| Exact final import regression source | `c10ea4ad047ee4cd64d7a900f9f093ab002236b37156c7b02e37c9914bf54113` |
|
||||
| SettingsView before lifetime repair | `d1078255adfbe9ef3a20b87a7f3bbe78707561587e6cd1316afe9b89cae8b294` |
|
||||
| SettingsView after lifetime repair | `dbd611dd58b18bd9b48683f08a6d4af973374107d678c93fa148925c94037ed7` |
|
||||
| New lifetime regression source, unchanged RED → GREEN | `24af2a5954533087ddbff40659ff9f8dcf70805435dca772314c2be7d8bf6133` |
|
||||
| Existing Settings regression source | `1b5dfa2e75554ea53c462ec21c041f9761884390aace42d4f3a02a218b90db17` |
|
||||
| Catalog used by these candidate runs, before the later warning | `c7d0651b2ea0f479d70e651320f3fb95a8a72a1ca68bb35244bfe585c89ded34` |
|
||||
| Import RED capsule `job-import/p709-job-import-red-20261017/results.json` | `bb962cc4eb7ef4a0db3ac48ad926384631434a4c567fdb1d37e294cebe5caceb` |
|
||||
| Import candidate GREEN capsule `job-import/p709-job-import-candidate1-20261017/results.json` | `ced734e77e6c34250a4b32d7572416afab30b8972c9c837e8ea6b35f39765d28` |
|
||||
| Lifetime RED raw `settings-import-lifetime-red-jest.json` | `571ea7a543ada3b2deb1454fe43db7b7829745b4ed62c0a6f9d13f324150fe9d` |
|
||||
| Lifetime GREEN raw `settings-import-lifetime-green-jest.json` | `7df561d8e32982a990ab028da508cbcab42c1586576ba76e84b0c77ac2904594` |
|
||||
| Lifetime worker receipt `settings-import-lifetime-worker-receipt.json` | `dd7cb29b221b36ba92aebc00604c12b1e4ab00dae60c88f331426e86deb1171a` |
|
||||
| Converter initial RED capsule `job-import/p709-job-import-contract-red-20261017/results.json` | `04862b10fe3754d2e6304eea6b9f2af20d6a08ee5e88605b2afe824a599c5ec9` |
|
||||
| Converter final-test RED capsule `job-import/p709-job-import-contract-red2-20261017/results.json` | `b8e265fe65932c8d4dba451b809fc1da7a04373caa213438e154d0223704ddb3` |
|
||||
| Converter GREEN capsule `job-import/p709-job-import-contract-green-20261017/results.json` | `a6fe9244232b40104ba6f844c6eacfd511dc4d23b8fa2d184869f0ba11db3b96` |
|
||||
| Converter GREEN raw `job-import/p709-job-import-contract-green-20261017/p709-job-import-contract-green-20261017-20261017T134219Z.json` | `0b85e4d9eaa0b4aeb90506e911ce6670f0f18b5edbc0c8ae2c7475c49c229199` |
|
||||
|
||||
Artifact paths in this table are relative to `.artifacts/phase7/p7-09/`. The import and converter capsules contain complete source/post snapshots with `allSourcesMatch: true`. The lifetime RED source record was captured before that run; its worker receipt was captured after GREEN and confirms that the test/importer/privacy/Backup/catalog bytes match the earlier record, except for the authorized SettingsView repair. That post-run receipt is not represented as a pre-run source capsule. The later warning catalog is covered by the converter GREEN but not by the earlier lifetime run. Final combined checks, browser export and publication are not certified by these historical candidate hashes.
|
||||
|
||||
## Preservation, debt and remaining gates
|
||||
|
||||
The [57-group preservation matrix](../PRESERVATION.md) and [retained debt register](../DEBT.md) remain authoritative. This candidate changes no group to PASS: **56 PARTIAL / PRES-C11 DEFERRED** remains the full-group disposition.
|
||||
|
||||
| Contract | Retained boundary / remaining qualification |
|
||||
|---|---|
|
||||
| PRES-J10 | Existing CSV/JSON export, conversion, picker activation and supported import/count behavior; complete real HTTP/native round trip and target-spreadsheet compatibility still require their own evidence. |
|
||||
| PRES-E09 | Existing authenticated ownership, immutable page session/context and actual route/session disposal; unit mocks do not establish all deployed/BFCache/offline cases. |
|
||||
| PRES-S03 / PRES-W11 | Existing General controls, Rules/Notifications independent owners, internal draft and language behavior; RD11/RD12/RD13 server root causes remain open. |
|
||||
| PRES-S04 | Privacy draft survives internal tabs while the same card owns its data; privacy pending-save/error, usage/plan/billing presentation and provider qualifications remain separate work. |
|
||||
| PRES-S05 | No account ZIP, encrypted backup, lifecycle, deletion or restore behavior is migrated here. Backup is verified to remain lazy/conditional only. Downloading a backup is not restore proof. |
|
||||
|
||||
Final applicable native round-trip/owner-isolation and controlled-failure journeys, responsive EN/NB/light/dark images, keyboard/focus/reflow and all-enabled axe results remain **pending** for this prerequisite. Final TypeScript, lint, production build, hygiene/document/secret checks, commit, normal development push and direct remote verification also remain **pending** in this candidate report. No such gate is claimed from the focused tests above.
|
||||
|
||||
Keep the [manual acceptance requirements](../../phase-5/p5-15/p5-15-02/MANUAL_ACCEPTANCE.md) open: applicable M01–M12 operator/device results, especially M09 spoken names/errors/progress and M11 contextual contrast/forced colors, plus physical touch/software-keyboard, real zoom and production font/photo/print qualifications. M13 remains the separate deferred AI return obligation; do not execute it while P5-13 is withheld.
|
||||
|
||||
RD21 partial-write/concurrency/idempotency limits, RD24 billing/deletion/retention policy and RD28 evidence provenance remain explicit. The remaining data/privacy/plan/backup work must not activate billing, deletion, restore, model qualification or provider calls. Production remains **NO-GO** under RD01–RD05 and the existing ingress, complete backup/restore, effective configuration/schema and tested rollback requirements. Main/deployment and Phase 8 remain outside this import boundary.
|
||||
|
||||
## Final import boundary — 20 October 2026
|
||||
|
||||
**Implemented CONDITIONAL GO.** This section supersedes the candidate's pending automated gates above. The owner subsequently authorized production promotion despite the retained release qualifications; that independent release investigation is recorded separately. Phase 7 and production acceptance remain distinct.
|
||||
|
||||
The importer now accepts the actual PascalCase export and supported legacy camelCase input through an explicit create-field allowlist. It validates the entire file before any write, excludes foreign IDs/owners/history/files/CV graphs, preserves converter-supported dates and decimals, counts acknowledged job writes only and stops after an uncertain write. There is no automatic replay or transactional-resume claim. Accepted imports and privacy drafts survive internal Settings tabs; actual route/session disposal stops subsequent queued work. Existing Rules ownership is retained.
|
||||
|
||||
A final demonstrated failure showed a valid JSON import incorrectly using the invalid-JSON toast after a network failure. Its strengthened existing regression failed ordinarily (10 passed, one failed), then passed after selecting the existing localized stopped-import message. The real-HTTP browser case verifies the accurate Bokmål toast while General is hidden and retains the durable partial-write warning on return. There are no new persistence semantics.
|
||||
|
||||
Fresh full frontend **837/837 tests, 97 suites PASS**, no pending/todo/failure, at **07:07:06–07:13:22 UTC**. Compared with release baseline 824/95, this boundary adds 11 import cases and two Settings-lifetime cases in two suites. Fresh full backend **1057/1057 PASS**, zero skipped, includes the separately published three-case [company compatibility repair](COMPANY_CREATE_COMPATIBILITY.md), commit `5addcde1e22e9625d1b39df037a05b24c69c9cde`. It is not counted as three new import UI tests.
|
||||
|
||||
Fresh native **25/25 PASS**, zero skipped/flaky/retries, at **07:10:12–07:14:40 UTC**: Companies six, import four, actual CSV consumer one, Preferences five, Settings/Rules consumers eight and ordinary P2-D01 one. The four new import browser cases derive an inventory of **335**; this focused run is not the full 335-case integrated acceptance. App/browser TypeScript, lint and production build pass. Export identity is `bu1vQUTbty4F3HzM8S37n`. The [portable results](evidence/import-compatibility-results.json) preserve command timestamps, result hashes and explicitly post-run source identities.
|
||||
|
||||
The four real-HTTP import cases cover actual export/import round trip, supported legacy graph exclusion and account disposal, whole-file preflight with zero writes, and committed-write/controlled-503 uncertainty with internal tab retention. Eleven retained PNGs and eleven all-enabled axe scans cover English/Bokmål, light/dark and 320, 390, 768, 1024, 1280, 1440 and 1920 widths. All scans report zero violations and one color-contrast incomplete group each. Triage retains invisible zero-count badges and overlapping/background text during fixed chrome/snackbar states as contextual manual contrast requirements; incompletes are not passing contrast checks. The 320px Bokmål partial-failure and 1440px English actual-round-trip images were visually inspected in this final run; the other nine images are retained automated evidence, not claimed as newly inspected. All eleven earlier-run image inspections remain historical evidence only.
|
||||
|
||||
Preservation remains **56 PARTIAL / C11 DEFERRED**, 33 routes. PRES-J10, E09, S03, S04 and W11 gain the bounded automated evidence above; S05 backup/restore and the remainder of P7-09 remain pending. Screen-reader/device/real-zoom/font/photo/print requirements and RD11/RD12/RD13/RD21 remain open. P5-13 stays withheld. Phase 8 has not started. Publication requires the final hygiene gate and direct remote receipt recorded in the chronological commit ledger.
|
||||
@@ -47,5 +47,3 @@ Every incomplete category is recorded: new43/consumer174 overlapped backgrounds,
|
||||
**17 new frontend cases / zero suites** derive **824/95**; **five new browser cases** derive **331**; backend remains1054. These remain inventories, not full-suite execution; inherited whole573/89,1033 backend and49 sidecar awaitP7-12. All57 groups remain56 PARTIAL/C11 DEFERRED, with33 routes; RD11/RD12/RD13/RD21 and manual/provider/output/release debt remain. The next data lifecycle boundary first needs the separately reviewable PascalCase-export/camelCase-import compatibility repair; it is not implemented here.
|
||||
|
||||
Final staged hygiene **PASS**,2026-10-17T08:50:12 UTC: secret and working/staged/phase whitespace gates,26 docs/593 local links,57 groups/33 routes. All33 tracked staged inputs match the final canonical-LF snapshots; the sole ignored input is the recorded local runner. Normal commit/push follows; the actual hash/remote receipt is recorded after observation. Data lifecycle and wholeP7-09 remain pending.
|
||||
|
||||
Preferences/Connections **e7dba9a2f776c55e3c4f4df7ef59076f93fa92df** was normally pushed and directly verified **2026-10-17T08:50:42.777224UTC**, with clean HEAD=origin=live development and local/origin/live main27657f3 unchanged. Final staged hygiene passed08:50:12UTC. The [portable publication receipt](evidence/preferences-results.json) supersedes earlier pending-publication observations; data/privacy/plan/backup and its separate import prerequisite remain pending.
|
||||
|
||||
@@ -29,10 +29,6 @@ Notification reads depend on locale callbacks and can replace an unsaved choice;
|
||||
|
||||
All **57 groups remain 56 PARTIAL / C11 DEFERRED**, with **33 current routes**. RD11 language, RD12 notification and RD13 Rules failures remain independently diagnosed; successful reruns do not close them. F20/RD21, other reliability/scaling debt, human screen-reader, physical touch/software keyboard, real zoom and production fonts/photos/print remain qualified. P5-13 is withheld; followMode is deferred. Production remains **NO-GO**, main is unchanged and Phase 8 implementation has not started.
|
||||
|
||||
The [separate sender repair](OAUTH_SENDER.md) is published asdac2927, directly verified clean08:27:48 UTC; its39/3, native9, type/lint/build/hygiene and all qualifications remain recorded. The first [Preferences/Connections boundary](PREFERENCES.md) now has final91/6, backend52, native17 and gates PASS, all18 new PNGs reviewed and qualified54 axe scans0/284. [Finite results](evidence/preferences-results.json) retain precise source/run scopes and failures. Derived inventories become824/95 frontend and331 browser; backend1054 unchanged. First-boundary final hygiene PASS and normal e7dba9a publication directly verified08:50:42UTC; data/privacy/plan/backup follows, beginning with the separately bounded import compatibility repair. Neither the second boundary nor allP7-09 is accepted yet.
|
||||
The [separate sender repair](OAUTH_SENDER.md) is published asdac2927, directly verified clean08:27:48 UTC; its39/3, native9, type/lint/build/hygiene and all qualifications remain recorded. The first [Preferences/Connections boundary](PREFERENCES.md) now has final91/6, backend52, native17 and gates PASS, all18 new PNGs reviewed and qualified54 axe scans0/284. [Finite results](evidence/preferences-results.json) retain precise source/run scopes and failures. Derived inventories become824/95 frontend and331 browser; backend1054 unchanged. First-boundary final hygiene/publication remain pending; data/privacy/plan/backup follows, beginning with the separately bounded import compatibility repair. Neither the second boundary nor allP7-09 is accepted yet.
|
||||
|
||||
## Import prerequisite completion — 20 October 2026
|
||||
|
||||
The [import compatibility boundary](IMPORT_COMPATIBILITY.md) is implemented **CONDITIONAL GO**: fresh full frontend 837/97, full backend 1057, native 25 and type/lint/build gates pass; 11 all-enabled scans have zero violations with contextual incompletes retained. The independent company contract repair is published as `5addcde1e22e9625d1b39df037a05b24c69c9cde`, remote verified 07:16:14 UTC. Import's 13 new frontend cases/two suites and four browser cases derive inventory 837/97 and 335 respectively. These do not claim a complete 335-case browser acceptance.
|
||||
|
||||
**P7-09 acceptance: PENDING.** Privacy, usage/plan presentation, Quick Capture and backup/data lifecycle remain next within the approved second boundary. Each needs meaningful regression, applicable native/visual evidence, preservation reconciliation, hygiene, commit and normal push/direct verification. Main was separately owner-authorized for deployment despite retained NO-GO qualifications; current release CI is being repaired, with no deployment success claimed.
|
||||
**P7-09 acceptance: PENDING.** Each repair/presentation boundary needs meaningful regression, applicable native/visual evidence, preservation reconciliation, hygiene, commit and normal push/direct verification.
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "P7-09 company-create prerequisite",
|
||||
"observedUtc": "2026-10-20T07:15:05.299883+00:00",
|
||||
"baselineBackend": 1054,
|
||||
"newBackendCases": 3,
|
||||
"targeted": {
|
||||
"path": ".artifacts/phase7/current/backend-career/p709-company-compatibility-fresh-20261020T070423Z/command.json",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:/Users/Connor/.dotnet/dotnet.exe",
|
||||
"test",
|
||||
"JobTrackerApi.Tests/JobTrackerApi.Tests.csproj",
|
||||
"--configuration",
|
||||
"Release",
|
||||
"--no-restore",
|
||||
"--filter",
|
||||
"FullyQualifiedName~CompanyCreateCompatibilityTests",
|
||||
"--logger",
|
||||
"trx;LogFileName=career.trx",
|
||||
"--results-directory",
|
||||
"E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\backend-career\\p709-company-compatibility-fresh-20261020T070423Z"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:04:23.391824+00:00",
|
||||
"finishedUtc": "2026-10-20T07:04:41.335549+00:00",
|
||||
"exitCode": 0,
|
||||
"logSha256": "f9fe87f762d872f11e2d6a7e14448e9f0451c2794b4094f5fe34eed1cd7cd6ad",
|
||||
"counts": {
|
||||
"total": 3,
|
||||
"passed": 3,
|
||||
"failed": 0,
|
||||
"notExecuted": 0
|
||||
},
|
||||
"resultSha256": "54e0867d3d80c438e9cdd026c1811ea8a52a7d8d9d057c847dac3a1593cf9fa9"
|
||||
},
|
||||
"fullBackend": {
|
||||
"path": ".artifacts/phase7/current/backend-career/p709-company-full-regression-20261020T070521Z/command.json",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:/Users/Connor/.dotnet/dotnet.exe",
|
||||
"test",
|
||||
"JobTrackerApi.Tests/JobTrackerApi.Tests.csproj",
|
||||
"--configuration",
|
||||
"Release",
|
||||
"--no-build",
|
||||
"--logger",
|
||||
"trx;LogFileName=career.trx",
|
||||
"--results-directory",
|
||||
"E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\backend-career\\p709-company-full-regression-20261020T070521Z"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:05:21.416786+00:00",
|
||||
"finishedUtc": "2026-10-20T07:05:38.375404+00:00",
|
||||
"exitCode": 0,
|
||||
"logSha256": "fa6461b4e59d07bfa2ffe1d0a59daf7ab616a4e87b9dbb80f4548cee703a3537",
|
||||
"counts": {
|
||||
"total": 1057,
|
||||
"passed": 1057,
|
||||
"failed": 0,
|
||||
"notExecuted": 0
|
||||
},
|
||||
"resultSha256": "0c995ebf824d6a4b446af9878bdcfd53e72ee90759fead491d1bd1108be3b45e"
|
||||
},
|
||||
"native": {
|
||||
"path": ".artifacts/phase7/current/ui/companies.json",
|
||||
"command": {
|
||||
"name": "companies",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"node",
|
||||
"node_modules/@playwright/test/cli.js",
|
||||
"test",
|
||||
"e2e/phase7-companies.spec.ts",
|
||||
"--retries=0",
|
||||
"--reporter=list,json",
|
||||
"--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\companies-results"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:10:12.619786+00:00",
|
||||
"finishedUtc": "2026-10-20T07:11:05.493360+00:00",
|
||||
"exitCode": 0
|
||||
},
|
||||
"stats": {
|
||||
"startTime": "2026-10-20T07:10:13.244Z",
|
||||
"duration": 52203.75399999999,
|
||||
"expected": 6,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
}
|
||||
},
|
||||
"preservation": [
|
||||
"PRES-J08",
|
||||
"PRES-J10",
|
||||
"PRES-E09"
|
||||
],
|
||||
"sources": [
|
||||
{
|
||||
"path": "JobTrackerApi/Controllers/CompaniesController.cs",
|
||||
"sha256Raw": "a913bcd1e9993bc4f8fd88b5554b92eade263967c4027b2179244354ed96f7ec",
|
||||
"sha256Lf": "4db8eb5a0d434a346538003c426f9ace9fad55695b9a57c3a4bf441e5c0d37e3"
|
||||
},
|
||||
{
|
||||
"path": "JobTrackerApi.Tests/CompanyCreateCompatibilityTests.cs",
|
||||
"sha256Raw": "b15bbe1b256cae2a5712a954d2f84470d5a9acc1b734143d5879690486b5227c",
|
||||
"sha256Lf": "b15bbe1b256cae2a5712a954d2f84470d5a9acc1b734143d5879690486b5227c"
|
||||
}
|
||||
],
|
||||
"binaries": [
|
||||
{
|
||||
"path": "JobTrackerApi/bin/Release/net9.0/JobTrackerApi.dll",
|
||||
"sha256": "addbeef095ec5a69b9dc4646f790117d18faa24ab5cb7e36663e84f4fa3f9a66"
|
||||
},
|
||||
{
|
||||
"path": "JobTrackerApi.Tests/bin/Release/net9.0/JobTrackerApi.Tests.dll",
|
||||
"sha256": "12f4003d9e0a4f98598175a72d1bc7291c1f08fc166bd2b4e1b47f844c33b013"
|
||||
}
|
||||
],
|
||||
"qualification": "Source/binary identities observed after the fresh backend and Companies runs; earlier RED/native evidence remains separately retained. Not a pre-run capture or production/MariaDB acceptance."
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "p7-09",
|
||||
"status": "PASS",
|
||||
"checkedUtc": "2026-10-20T07:15:55.632615+00:00",
|
||||
"commands": [
|
||||
{
|
||||
"name": "secret-scan",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
|
||||
"-X",
|
||||
"utf8",
|
||||
"scripts/supply-chain.py",
|
||||
"scan-secrets"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:15:51.896284+00:00",
|
||||
"finishedUtc": "2026-10-20T07:15:54.661570+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261020T071551Z/secret-scan.log",
|
||||
"logSha256Raw": "be5119e6057287d397e87759f855c91e634e3bfac61c8be7584d05592ba2dfcd"
|
||||
},
|
||||
{
|
||||
"name": "working-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:15:54.662586+00:00",
|
||||
"finishedUtc": "2026-10-20T07:15:54.772491+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261020T071551Z/working-whitespace.log",
|
||||
"logSha256Raw": "7d4d21b7cfbda7f0adedeec159d5457b079dd3046eab064066ed668c6d1139cd"
|
||||
},
|
||||
{
|
||||
"name": "staged-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:15:54.772959+00:00",
|
||||
"finishedUtc": "2026-10-20T07:15:54.895580+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261020T071551Z/staged-whitespace.log",
|
||||
"logSha256Raw": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"name": "phase7-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:15:54.896077+00:00",
|
||||
"finishedUtc": "2026-10-20T07:15:55.135880+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261020T071551Z/phase7-whitespace.log",
|
||||
"logSha256Raw": "c42dc788a516c9c9baadd44ae7a8c4c6d5b0e4cbff688f5b45647999e953e099"
|
||||
}
|
||||
],
|
||||
"untrackedSecretScan": {
|
||||
"files": 6,
|
||||
"findings": 0
|
||||
},
|
||||
"documentation": {
|
||||
"files": 29,
|
||||
"relativeLinks": 607,
|
||||
"errors": []
|
||||
},
|
||||
"preservation": {
|
||||
"groups": 57,
|
||||
"statuses": {
|
||||
"PARTIAL": 56,
|
||||
"PASS": 0,
|
||||
"FAIL": 0,
|
||||
"DEFERRED": 1
|
||||
},
|
||||
"originalRoutesPreserved": true,
|
||||
"currentConcreteRoutes": 33
|
||||
},
|
||||
"production": {
|
||||
"main": "f9bf31c9a9a9f40edefb855a0911d7f057fa4990",
|
||||
"originMain": "f9bf31c9a9a9f40edefb855a0911d7f057fa4990",
|
||||
"status": "NO-GO",
|
||||
"qualification": "Local fetched refs; direct push observation recorded separately."
|
||||
},
|
||||
"hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.",
|
||||
"limits": [
|
||||
"Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.",
|
||||
"Local links check target existence, not URL/heading anchors.",
|
||||
"Static evidence is not runtime/manual/device/provider/production acceptance."
|
||||
]
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
{
|
||||
"observedUtc": "2026-10-20T07:28:14.828600+00:00",
|
||||
"identityScope": "Post-run source identities; not represented as a pre-run capture",
|
||||
"sources": {
|
||||
"job-tracker-ui/src/components/ImportExportJobs.tsx": "037c58c30a0fa0f7a3082613ec751872f6f0ff86c8720b1a108dc711dd3c02fe",
|
||||
"job-tracker-ui/src/components/SettingsView.tsx": "dbd611dd58b18bd9b48683f08a6d4af973374107d678c93fa148925c94037ed7",
|
||||
"job-tracker-ui/src/i18n/translations.ts": "52cd246d3176096d8e7b9ff2de86d5b998d0d715bc2ac51c2a8151c0f5f1ad60",
|
||||
"job-tracker-ui/src/job-import.test.tsx": "9c69000a6f8ca744fb425c2007ce48247be46ecad01a177c2f002ba2ac9a0d73",
|
||||
"job-tracker-ui/src/settings-import-lifetime.test.tsx": "24af2a5954533087ddbff40659ff9f8dcf70805435dca772314c2be7d8bf6133",
|
||||
"job-tracker-ui/e2e/phase7-job-import.spec.ts": "c16a82463eeeeee52430fff4cf71139586225c150db8cee1f1c572c9e17635b2",
|
||||
"job-tracker-ui/e2e/csv-export.spec.ts": "8c02ff73f975b1073038b81f2b2d5dde187b8d25ed81b195f1006562c6b2b082"
|
||||
},
|
||||
"buildId": "bu1vQUTbty4F3HzM8S37n",
|
||||
"gates": {
|
||||
"jest": {
|
||||
"commandReport": ".artifacts/phase7/current/ui/jest-command.json",
|
||||
"sha256": "55ac65e07ec3f953db3519f85ce9d72e786bfaba7555966617c0a0a954cb346c",
|
||||
"name": "jest",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"npm.cmd",
|
||||
"test",
|
||||
"--",
|
||||
"--runInBand",
|
||||
"--watchAll=false",
|
||||
"--json",
|
||||
"--outputFile=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\jest.json"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:07:06.571284+00:00",
|
||||
"finishedUtc": "2026-10-20T07:13:22.241827+00:00",
|
||||
"exitCode": 0,
|
||||
"resultSha256": "22bc4de9ed40a3b2c3113012211f0857c8a409ffc2f3ab19fe38b6f89197e430",
|
||||
"counts": {
|
||||
"numPassedTests": 837,
|
||||
"numFailedTests": 0,
|
||||
"numPendingTests": 0,
|
||||
"numPassedTestSuites": 97
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"commandReport": ".artifacts/phase7/current/ui/typecheck-command.json",
|
||||
"sha256": "ceec771f001f4735689ecc82cbe79c1c30ceadda05f22c937a375d4beed732f6",
|
||||
"name": "typecheck",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"npm.cmd",
|
||||
"run",
|
||||
"typecheck"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:07:16.772507+00:00",
|
||||
"finishedUtc": "2026-10-20T07:07:28.703060+00:00",
|
||||
"exitCode": 0
|
||||
},
|
||||
"browser-typecheck": {
|
||||
"commandReport": ".artifacts/phase7/current/ui/browser-typecheck-command.json",
|
||||
"sha256": "327379d06029f66274c6989066e51624d08208534e845df408b92f9075c9fe8d",
|
||||
"name": "browser-typecheck",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"npm.cmd",
|
||||
"exec",
|
||||
"tsc",
|
||||
"--",
|
||||
"--project",
|
||||
"../.artifacts/phase7/e2e-tsconfig.json",
|
||||
"--noEmit"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:10:05.338959+00:00",
|
||||
"finishedUtc": "2026-10-20T07:10:12.612270+00:00",
|
||||
"exitCode": 0
|
||||
},
|
||||
"lint": {
|
||||
"commandReport": ".artifacts/phase7/current/ui/lint-command.json",
|
||||
"sha256": "aae9013d67c02232b06e32ffad8b2b5a63ff1dceed93c157b9a12bff4327d1b3",
|
||||
"name": "lint",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"npm.cmd",
|
||||
"run",
|
||||
"lint"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:07:35.243502+00:00",
|
||||
"finishedUtc": "2026-10-20T07:08:02.462346+00:00",
|
||||
"exitCode": 0
|
||||
},
|
||||
"build": {
|
||||
"commandReport": ".artifacts/phase7/current/ui/build-command.json",
|
||||
"sha256": "92310fad5db59445d638e3aa61cec8f76a35d0eec50fda8e50cf4aa352641c6e",
|
||||
"name": "build",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"npm.cmd",
|
||||
"run",
|
||||
"build"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:08:02.465903+00:00",
|
||||
"finishedUtc": "2026-10-20T07:08:25.764501+00:00",
|
||||
"exitCode": 0
|
||||
},
|
||||
"companies": {
|
||||
"commandReport": ".artifacts/phase7/current/ui/companies-command.json",
|
||||
"sha256": "fe219c806c97e0acb715e73303f8dff458119d90476c533c7ef2d68404c069d8",
|
||||
"name": "companies",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"node",
|
||||
"node_modules/@playwright/test/cli.js",
|
||||
"test",
|
||||
"e2e/phase7-companies.spec.ts",
|
||||
"--retries=0",
|
||||
"--reporter=list,json",
|
||||
"--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\companies-results"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:10:12.619786+00:00",
|
||||
"finishedUtc": "2026-10-20T07:11:05.493360+00:00",
|
||||
"exitCode": 0,
|
||||
"resultSha256": "fd24bf490707bff4f2b86cb3fc4a0473eef77226559bb4c84159ba2c240f611e",
|
||||
"stats": {
|
||||
"startTime": "2026-10-20T07:10:13.244Z",
|
||||
"duration": 52203.75399999999,
|
||||
"expected": 6,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
}
|
||||
},
|
||||
"job-import": {
|
||||
"commandReport": ".artifacts/phase7/current/ui/job-import-command.json",
|
||||
"sha256": "900eb45861eda5b9c072620cbf96d5971590ea9b6def321d4a09d7adfe3fdc27",
|
||||
"name": "job-import",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"node",
|
||||
"node_modules/@playwright/test/cli.js",
|
||||
"test",
|
||||
"e2e/phase7-job-import.spec.ts",
|
||||
"--retries=0",
|
||||
"--reporter=list,json",
|
||||
"--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\job-import-results"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:11:05.499597+00:00",
|
||||
"finishedUtc": "2026-10-20T07:11:41.110228+00:00",
|
||||
"exitCode": 0,
|
||||
"resultSha256": "51635ca717cb8608b9f903e4c65e460c137beceaeae546120fcb0664a2e5232a",
|
||||
"stats": {
|
||||
"startTime": "2026-10-20T07:11:06.038Z",
|
||||
"duration": 35029.068,
|
||||
"expected": 4,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
}
|
||||
},
|
||||
"settings-preferences": {
|
||||
"commandReport": ".artifacts/phase7/current/ui/settings-preferences-command.json",
|
||||
"sha256": "63f4de0d1df8b43d403eea7eee605815d66b1d779fb02f796ab9fadd312a9fab",
|
||||
"name": "settings-preferences",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"node",
|
||||
"node_modules/@playwright/test/cli.js",
|
||||
"test",
|
||||
"e2e/phase7-settings-preferences.spec.ts",
|
||||
"--retries=0",
|
||||
"--reporter=list,json",
|
||||
"--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\settings-preferences-results"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:11:54.651237+00:00",
|
||||
"finishedUtc": "2026-10-20T07:12:45.654251+00:00",
|
||||
"exitCode": 0,
|
||||
"resultSha256": "0cc6eaca905753d76f942fa9cb4b77058ab5f363652bad0cbb1939a2a1c57e9f",
|
||||
"stats": {
|
||||
"startTime": "2026-10-20T07:11:55.204Z",
|
||||
"duration": 50381.121999999996,
|
||||
"expected": 5,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
}
|
||||
},
|
||||
"job-import-consumers": {
|
||||
"commandReport": ".artifacts/phase7/current/ui/job-import-consumers-command.json",
|
||||
"sha256": "c9f8378e3d8f8542be0c501e4f028767a183175a6e33375f31d00e584a141d6b",
|
||||
"name": "job-import-consumers",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"node",
|
||||
"node_modules/@playwright/test/cli.js",
|
||||
"test",
|
||||
"e2e/csv-export.spec.ts",
|
||||
"--retries=0",
|
||||
"--reporter=list,json",
|
||||
"--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\job-import-consumers-results"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:11:41.117364+00:00",
|
||||
"finishedUtc": "2026-10-20T07:11:54.644752+00:00",
|
||||
"exitCode": 0,
|
||||
"resultSha256": "93e1412dff306bbcd51c911da805f567a5fd87ed46fce2ee73831bbc16f7f44d",
|
||||
"stats": {
|
||||
"startTime": "2026-10-20T07:11:41.648Z",
|
||||
"duration": 12966.131000000001,
|
||||
"expected": 1,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
}
|
||||
},
|
||||
"settings-consumers": {
|
||||
"commandReport": ".artifacts/phase7/current/ui/settings-consumers-command.json",
|
||||
"sha256": "b5b0debf9dc501c7ebd6eb867c8aaaf1b93514625c1bf6193d2f2bc8c7088521",
|
||||
"name": "settings-consumers",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"node",
|
||||
"node_modules/@playwright/test/cli.js",
|
||||
"test",
|
||||
"e2e/rules-draft-repair.spec.ts",
|
||||
"e2e/settings-page-layout.spec.ts",
|
||||
"--retries=0",
|
||||
"--reporter=list,json",
|
||||
"--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\settings-consumers-results"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:12:45.662849+00:00",
|
||||
"finishedUtc": "2026-10-20T07:14:27.857467+00:00",
|
||||
"exitCode": 0,
|
||||
"resultSha256": "84911d3f4c411d0ee621da7d26a535c712ab1d2373450e9446921a34e892bb4f",
|
||||
"stats": {
|
||||
"startTime": "2026-10-20T07:12:46.327Z",
|
||||
"duration": 101491.027,
|
||||
"expected": 8,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
}
|
||||
},
|
||||
"application-p2d01": {
|
||||
"commandReport": ".artifacts/phase7/current/ui/application-p2d01-command.json",
|
||||
"sha256": "4a20e0cb2df68fab9a66277e9ac954912a0bab178e01444d6540db60e3e4d1b0",
|
||||
"name": "application-p2d01",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"node",
|
||||
"node_modules/@playwright/test/cli.js",
|
||||
"test",
|
||||
"e2e/redesign-preservation.spec.ts",
|
||||
"--grep",
|
||||
"legacy workspace preserves frozen documents",
|
||||
"--retries=0",
|
||||
"--reporter=list,json",
|
||||
"--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\application-p2d01-results"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:14:27.864302+00:00",
|
||||
"finishedUtc": "2026-10-20T07:14:40.056326+00:00",
|
||||
"exitCode": 0,
|
||||
"resultSha256": "97788d4329b6b69be394addc45007dd337393e50eb170fe3b30d2271e39c3cf7",
|
||||
"stats": {
|
||||
"startTime": "2026-10-20T07:14:28.386Z",
|
||||
"duration": 11633.233,
|
||||
"expected": 1,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "p7-09",
|
||||
"status": "PASS",
|
||||
"checkedUtc": "2026-10-20T07:28:46.012987+00:00",
|
||||
"commands": [
|
||||
{
|
||||
"name": "secret-scan",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
|
||||
"-X",
|
||||
"utf8",
|
||||
"scripts/supply-chain.py",
|
||||
"scan-secrets"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:28:42.343340+00:00",
|
||||
"finishedUtc": "2026-10-20T07:28:45.016844+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261020T072842Z/secret-scan.log",
|
||||
"logSha256Raw": "be5119e6057287d397e87759f855c91e634e3bfac61c8be7584d05592ba2dfcd"
|
||||
},
|
||||
{
|
||||
"name": "working-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:28:45.017350+00:00",
|
||||
"finishedUtc": "2026-10-20T07:28:45.134506+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261020T072842Z/working-whitespace.log",
|
||||
"logSha256Raw": "7d4d21b7cfbda7f0adedeec159d5457b079dd3046eab064066ed668c6d1139cd"
|
||||
},
|
||||
{
|
||||
"name": "staged-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:28:45.135011+00:00",
|
||||
"finishedUtc": "2026-10-20T07:28:45.221988+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261020T072842Z/staged-whitespace.log",
|
||||
"logSha256Raw": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"name": "phase7-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||
],
|
||||
"startedUtc": "2026-10-20T07:28:45.221988+00:00",
|
||||
"finishedUtc": "2026-10-20T07:28:45.421590+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261020T072842Z/phase7-whitespace.log",
|
||||
"logSha256Raw": "c42dc788a516c9c9baadd44ae7a8c4c6d5b0e4cbff688f5b45647999e953e099"
|
||||
}
|
||||
],
|
||||
"untrackedSecretScan": {
|
||||
"files": 7,
|
||||
"findings": 0
|
||||
},
|
||||
"documentation": {
|
||||
"files": 29,
|
||||
"relativeLinks": 609,
|
||||
"errors": []
|
||||
},
|
||||
"preservation": {
|
||||
"groups": 57,
|
||||
"statuses": {
|
||||
"PARTIAL": 56,
|
||||
"PASS": 0,
|
||||
"FAIL": 0,
|
||||
"DEFERRED": 1
|
||||
},
|
||||
"originalRoutesPreserved": true,
|
||||
"currentConcreteRoutes": 33
|
||||
},
|
||||
"production": {
|
||||
"main": "f9bf31c9a9a9f40edefb855a0911d7f057fa4990",
|
||||
"originMain": "f9bf31c9a9a9f40edefb855a0911d7f057fa4990",
|
||||
"status": "NO-GO",
|
||||
"qualification": "Local fetched refs; direct push observation recorded separately."
|
||||
},
|
||||
"hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.",
|
||||
"limits": [
|
||||
"Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.",
|
||||
"Local links check target existence, not URL/heading anchors.",
|
||||
"Static evidence is not runtime/manual/device/provider/production acceptance."
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"checkpoint": "P7-09",
|
||||
"slice": "Preferences and Connections presentation",
|
||||
"recordedUtc": "2026-10-17T08:48:39.039670+00:00",
|
||||
"decision": "Implemented first presentation boundary CONDITIONAL GO; staged hygiene PASS and normal publication directly verified; data lifecycle remains pending",
|
||||
"decision": "Implemented first presentation boundary CONDITIONAL GO; final staged hygiene PASS, normal publication pending; data lifecycle remains pending",
|
||||
"precedingPublication": {
|
||||
"command": [
|
||||
"rtk",
|
||||
@@ -2343,29 +2343,5 @@
|
||||
"path": ".artifacts/phase7/p7-09/preferences-staged-source.json",
|
||||
"sha256": "de9f96d2b875f7e3634113307568c0ab264d096a1faf6fc158ac5a6485e15f1f",
|
||||
"trackedInputs": 33
|
||||
},
|
||||
"publication": {
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"push",
|
||||
"origin",
|
||||
"HEAD:refs/heads/codex/ui-redesign-phase4"
|
||||
],
|
||||
"startedUtc": "2026-10-17T08:50:40.976845+00:00",
|
||||
"finishedUtc": "2026-10-17T08:50:42.577887+00:00",
|
||||
"exitCode": 0,
|
||||
"head": "e7dba9a2f776c55e3c4f4df7ef59076f93fa92df",
|
||||
"log": ".artifacts/phase7/p7-09/preferences-push-receipt.log",
|
||||
"logSha256": "c47104abee159d6adef8fd3549ffc3ca086ec5a93b6366489fa92fc64080cd44",
|
||||
"observedUtc": "2026-10-17T08:50:42.777224+00:00",
|
||||
"originDevelopment": "e7dba9a2f776c55e3c4f4df7ef59076f93fa92df",
|
||||
"liveDevelopment": "e7dba9a2f776c55e3c4f4df7ef59076f93fa92df",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"pendingPaths": [],
|
||||
"workingTreeClean": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,7 @@
|
||||
import { expect, test, type BrowserContext } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const apiUrl = "http://localhost:5302/api";
|
||||
const networkBoundaries = new WeakMap<BrowserContext, { abortedFontStylesheets: string[]; unexpectedExternalRequests: string[] }>();
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
const boundary = { abortedFontStylesheets: [] as string[], unexpectedExternalRequests: [] as string[] };
|
||||
networkBoundaries.set(context, boundary);
|
||||
await context.route("**/*", route => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (url.protocol === "https:" && url.hostname === "fonts.googleapis.com" && url.port === "" && url.pathname === "/css2" && request.method() === "GET" && request.resourceType() === "stylesheet") {
|
||||
boundary.abortedFontStylesheets.push(url.href);
|
||||
return route.abort();
|
||||
}
|
||||
if (["http:", "https:"].includes(url.protocol) && (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) || !["3300", "5302"].includes(url.port))) {
|
||||
boundary.unexpectedExternalRequests.push(`${request.method()} ${url.origin}${url.pathname}`);
|
||||
return route.abort();
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ context }, info) => {
|
||||
const boundary = networkBoundaries.get(context);
|
||||
await info.attach("csv-export-network-boundary", { body: JSON.stringify(boundary), contentType: "application/json" });
|
||||
expect(boundary?.unexpectedExternalRequests).toEqual([]);
|
||||
networkBoundaries.delete(context);
|
||||
});
|
||||
|
||||
test("CSV export has safe cells and headers, and browser download preserves its UTF-8 bytes", async ({ page }) => {
|
||||
await page.addInitScript(() => window.localStorage.setItem("uiLanguage", "en"));
|
||||
|
||||
@@ -69,11 +69,11 @@ def seed_operation(payload: object) -> str:
|
||||
except OSError as error:
|
||||
raise SeedDenied("The isolated dataRoot must already exist") from error
|
||||
if (root != requested or root.parent != temporary or not root.is_dir()
|
||||
or requested.is_symlink() or requested.is_junction()
|
||||
or requested.is_symlink() or (sys.platform == "win32" and requested.is_junction())
|
||||
or not re.fullmatch(r"jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*", root.name)):
|
||||
raise SeedDenied("Only an unlinked jobtracker-e2e-* direct child of system temp is allowed")
|
||||
database = root / "jobtracker.db"
|
||||
if (not database.is_file() or database.is_symlink() or database.is_junction()
|
||||
if (not database.is_file() or database.is_symlink() or (sys.platform == "win32" and database.is_junction())
|
||||
or database.resolve(strict=True) != database or database.stat().st_nlink != 1):
|
||||
raise SeedDenied("The unlinked existing direct jobtracker.db file is required")
|
||||
# EF's SQLite Guid converter writes uppercase canonical text.
|
||||
@@ -110,7 +110,7 @@ def temporary_fixture():
|
||||
yield root
|
||||
finally:
|
||||
assert root.parent == Path(tempfile.gettempdir()).resolve()
|
||||
assert root.name.startswith("jobtracker-e2e-operation-selftest-") and not root.is_symlink() and not root.is_junction()
|
||||
assert root.name.startswith("jobtracker-e2e-operation-selftest-") and not root.is_symlink() and not (sys.platform == "win32" and root.is_junction())
|
||||
|
||||
|
||||
def self_test() -> dict:
|
||||
|
||||
@@ -171,8 +171,13 @@ for (const [mode, language, width] of [["light", "en", 1440], ["dark", "nb", 144
|
||||
await expect(cancel).toBeFocused();
|
||||
await expect(confirm).toHaveAccessibleDescription(t.attachmentsDeleteConfirm.replace("{name}", "Reviewed æøå.txt"));
|
||||
await cancel.press("Shift+Tab");
|
||||
const messageRegion = confirm.getByRole("region", { name: t.attachmentsDeleteTitle, exact: true });
|
||||
await expect(messageRegion).toBeFocused();
|
||||
await messageRegion.press("Shift+Tab");
|
||||
await expect(confirm.getByRole("button", { name: t.attachmentsDelete, exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(messageRegion).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(cancel).toBeFocused();
|
||||
await scan(page, info, "confirm", true);
|
||||
await capture(page, info, `confirm-${suffix}`);
|
||||
|
||||
@@ -113,7 +113,7 @@ function seedPending(info: TestInfo, owner: Owner) {
|
||||
expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir()));
|
||||
expect(path.basename(dataRoot)).toMatch(/^jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*$/);
|
||||
const helper = path.resolve('e2e/helpers/seed-career-review.py');
|
||||
const result = spawnSync('rtk', ['proxy', process.env.PLAYWRIGHT_PYTHON_PATH || 'python', '-X', 'utf8', helper], {
|
||||
const result = spawnSync(process.env.PLAYWRIGHT_PYTHON_PATH || (process.platform === 'win32' ? 'python' : 'python3'), ['-X', 'utf8', helper], {
|
||||
input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email, profile: incoming(),
|
||||
rawText: 'Synthetic pending extraction text — æøå', normalizedText: 'Synthetic pending normalized text; pending contact email; Norwegian B1.' }),
|
||||
encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024,
|
||||
|
||||
@@ -366,7 +366,11 @@ test('[P7-05 Discovery] keyboard native create and alert updates persist; delete
|
||||
const cancel = dialog.getByRole('button', { name: t.cancel, exact: true });
|
||||
await expect(cancel).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
const messageRegion = dialog.getByRole('region', { name: await dialog.getByRole('heading').innerText(), exact: true });
|
||||
await expect(messageRegion).toBeFocused();
|
||||
await messageRegion.press('Shift+Tab');
|
||||
await expect(dialog.getByRole('button', { name: t.adminUsersDelete, exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Tab'); await expect(messageRegion).toBeFocused();
|
||||
await page.keyboard.press('Tab'); await expect(cancel).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(dialog).toHaveCount(0); await expect(deleteButton).toBeFocused();
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type TestInfo } from '@playwright/test';
|
||||
import fs from 'node:fs/promises';
|
||||
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 Owner = { id: string; email: string; headers: Headers };
|
||||
type RequestRecord = { channel: 'browser' | 'api'; method: string; path: string; query: string; status?: number; body?: unknown };
|
||||
type Session = { owner: Owner; admin: APIRequestContext; adminHeaders: Headers; requests: RequestRecord[] };
|
||||
type Job = { id: number; companyId: number; jobTitle: string; company: { id: number; name: string }; [key: string]: unknown };
|
||||
const boundary = 'Actual isolated Program/SQLite/local Identity/CSRF and verified Free owners; actual JSON exporter/download and native browser file chooser; existing company deduplication and additive job creates. The malformed/legacy files are explicitly constructed inputs, not projected backend successes. Only the named partial-result case relays one controlled503 after an actual job POST201 commit, then holds that reply for progress/duplicate-admission evidence. This is a new-job import, not account/history/file/CV restore. No real provider/SMTP/model request, Docker/schema/destructive/deployment/main/Phase8 action. External requests are aborted; exact Google Fonts css2 stylesheet attempts are recorded separately. Emulated viewport/themes/locales and keyboard are finite browser evidence; physical chooser/device/reader/zoom acceptance remains pending.';
|
||||
function copy(key: string, language: Language = 'en', params: Record<string, number | string> = {}) { return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`)); }
|
||||
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
|
||||
function browserRequests(session: Session, path: string, method = 'POST') { return session.requests.filter(value => value.channel === 'browser' && value.path === `/api${path}` && value.method === method); }
|
||||
async function call(records: RequestRecord[], api: APIRequestContext, method: 'GET' | 'POST' | 'PUT' | 'PATCH', path: string, headers?: Headers, data?: unknown) {
|
||||
const result = await api.fetch(`${apiUrl}${path}`, { method, headers, data }); const url = new URL(result.url()); records.push({ channel: 'api', method, path: url.pathname, query: url.search, status: result.status() }); return result;
|
||||
}
|
||||
async function json<T>(response: APIResponse, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
|
||||
async function login(records: RequestRecord[], api: APIRequestContext, email: string): Promise<Owner> {
|
||||
expect((await call(records, api, 'POST', '/auth/login', undefined, { email, password })).status()).toBe(200);
|
||||
const me = await json<{ id: string; sessionContext: string; entitlements: { ai: boolean } }>(await call(records, api, 'GET', '/auth/me'));
|
||||
const csrf = (await api.storageState()).cookies.find(value => value.name === 'XSRF-TOKEN'); expect(csrf).toBeDefined(); return { id: me.id, email, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
|
||||
}
|
||||
async function newAccount(records: RequestRecord[], admin: APIRequestContext, headers: Headers) { const email = `p709-import-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await call(records, admin, 'POST', '/users', headers, { email, password, roles: [] })).status()).toBe(200); return email; }
|
||||
async function free(session: Session, api: APIRequestContext) { expect((await json<{ entitlements: { ai: boolean } }>(await call(session.requests, api, 'GET', '/auth/me'))).entitlements.ai).toBe(false); }
|
||||
const test = base.extend<{ session: Session }>({
|
||||
session: async ({ page, context, playwright }, provide, info) => {
|
||||
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
|
||||
const records: RequestRecord[] = []; const forbidden: string[] = []; const fonts: string[] = []; const admin = await playwright.request.newContext();
|
||||
context.on('request', request => { const url = new URL(request.url()); records.push({ channel: 'browser', method: request.method(), path: url.pathname, query: url.search, ...(['POST', 'PUT', 'PATCH'].includes(request.method()) && ['/api/companies', '/api/jobapplications'].includes(url.pathname) ? { body: request.postDataJSON() } : {}) }); });
|
||||
context.on('response', result => { const url = new URL(result.url()); const record = [...records].reverse().find(value => value.channel === 'browser' && value.status === undefined && value.method === result.request().method() && value.path === url.pathname && value.query === url.search); if (record) record.status = result.status(); });
|
||||
await context.route('**/*', route => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { fonts.push(url.href); return route.abort(); }
|
||||
const mailbox = /^\/api\/(gmail|microsoft-graph|imap)(?:\/|$)/.test(url.pathname); const mailboxRead = request.method() === 'GET' && /^\/api\/(gmail|microsoft-graph|imap)\/status$/.test(url.pathname);
|
||||
const ai = /^\/api\/(?:ai|cv\/ai)(?:\/|$)/.test(url.pathname); const settingsRead = request.method() === 'GET' && ['/api/ai/settings', '/api/ai/usage'].includes(url.pathname);
|
||||
if (mailbox && !mailboxRead || ai && !settingsRead || /\/api\/(?:email\/send|billing\/(?:checkout|portal)|account-lifecycle\/delete|backup|export\/account|admin\/system)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { forbidden.push(`${request.method()} ${url.origin}${url.pathname}`); return route.abort(); }
|
||||
return route.continue();
|
||||
});
|
||||
await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light'); });
|
||||
try { const administrator = await login(records, admin, 'e2e@example.test'); const owner = await login(records, page.request, await newAccount(records, admin, administrator.headers)); const session = { owner, admin, adminHeaders: administrator.headers, requests: records }; await free(session, page.request); await provide(session); expect(forbidden).toEqual([]); }
|
||||
finally { await info.attach('job-import-boundary-and-requests', { body: JSON.stringify({ boundary, requests: records, forbiddenRequests: forbidden, abortedFontStylesheets: fonts }), contentType: 'application/json' }); await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
async function keyboard(control: Locator, key = 'Enter') { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await control.press(key); }
|
||||
async function enter(page: Page, session: Session, language: Language = 'en', mode = 'light', width = 1440) {
|
||||
expect((await call(session.requests, page.request, 'PUT', '/auth/preferences/language', session.owner.headers, { language: language === 'nb' ? 'nb-NO' : 'en' })).status()).toBe(204);
|
||||
await page.addInitScript(value => { localStorage.setItem('uiLanguage', value.language); localStorage.setItem('jobtracker.themeMode', value.mode); }, { language, mode });
|
||||
await page.setViewportSize({ width, height: width < 768 ? 844 : 900 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/settings'); const group = page.getByRole('group', { name: copy('importExportTitle', language), exact: true }); await expect(group).toBeVisible(); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', mode); return group;
|
||||
}
|
||||
async function chooseSection(page: Page, language: Language, key: string) {
|
||||
const main = page.getByRole('main'); const selector = main.getByRole('combobox', { name: copy('settingsNavigation', language), exact: true }); const tab = main.getByRole('tab', { name: copy(key, language), exact: true });
|
||||
await expect.poll(async () => await selector.isVisible() || await tab.isVisible()).toBe(true);
|
||||
if (await selector.isVisible()) { await keyboard(selector); const option = page.getByRole('option', { name: copy(key, language), exact: true }); await option.focus(); await option.press('Enter'); await expect(selector).toBeFocused(); }
|
||||
else { await keyboard(tab); await expect(tab).toHaveAttribute('aria-selected', 'true'); }
|
||||
}
|
||||
async function upload(page: Page, group: Locator, rows: unknown, language: Language = 'en', key = 'Enter') { const choosing = page.waitForEvent('filechooser'); await keyboard(group.getByRole('button', { name: copy('importJson', language), exact: true }), key); await (await choosing).setFiles({ name: 'explicit-import-fixture.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(rows), 'utf8') }); }
|
||||
async function exportRows(session: Session, api: APIRequestContext) { return json<Array<Record<string, unknown>>>(await call(session.requests, api, 'GET', '/export/jobs?format=json&includeDeleted=false')); }
|
||||
async function readJob(session: Session, api: APIRequestContext, id: number) { return json<Job>(await call(session.requests, api, 'GET', `/jobapplications/${id}`)); }
|
||||
async function createJob(page: Page, session: Session, companyName: string, data: Record<string, unknown>) {
|
||||
await free(session, page.request); const company = await json<{ id: number; name: string }>(await call(session.requests, page.request, 'POST', '/companies', session.owner.headers, { name: companyName, location: 'Oslo', source: 'finn.no' }), 201); const job = await json<Job>(await call(session.requests, page.request, 'POST', '/jobapplications', session.owner.headers, { companyId: company.id, ...data }), 201); return { company, job };
|
||||
}
|
||||
async function noImportedGraphs(session: Session, api: APIRequestContext, jobId: number) {
|
||||
const history = await json<Array<{ type: string }>>(await call(session.requests, api, 'GET', `/jobapplications/${jobId}/history`)); expect(history.map(event => event.type)).toEqual(['Created']);
|
||||
expect(await json(await call(session.requests, api, 'GET', `/attachments/${jobId}`))).toEqual([]); expect(await json(await call(session.requests, api, 'GET', `/correspondence/${jobId}`))).toEqual([]); expect(await json(await call(session.requests, api, 'GET', `/jobapplications/${jobId}/submitted-packages`))).toEqual([]);
|
||||
}
|
||||
async function capture(page: Page, group: Locator, info: TestInfo, name: string) {
|
||||
const geometry = await group.evaluate(element => { const bounds = element.getBoundingClientRect(); return { width: innerWidth, rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, outside: Array.from(element.querySelectorAll('p,button,[role="status"],[role="alert"]')).filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden').map(node => ({ text: node.textContent?.slice(0, 100), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right })).filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1) }; });
|
||||
await info.attach(`${name}-reflow`, { body: JSON.stringify(geometry), contentType: 'application/json' }); expect(geometry.rootOverflow).toBeLessThanOrEqual(1); expect(geometry.outside).toEqual([]); await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
||||
const result = await new AxeBuilder({ page }).analyze(); await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete, qualification: 'All enabled axe rules; incomplete contexts retained for separate human triage.' }), contentType: 'application/json' }); expect(result.violations).toEqual([]);
|
||||
}
|
||||
|
||||
test('[P7-09 J10] actual PascalCase JSON download imports supported fields through the keyboard native chooser without changing export bytes or existing history', async ({ page, session }, info) => {
|
||||
test.setTimeout(150_000); const sourceData = { jobTitle: 'P709 Første – API developer', status: 'Applied', dateApplied: '2025-01-15T12:00:00Z', location: 'Oslo', salary: 'Avtales: 600 000–800 000 NOK', salaryMin: 600000, salaryMax: 800000, salaryCurrency: 'NOK', salaryPeriod: 'year', nextAction: 'Følg opp', followUpAt: '2030-02-14T09:15:00Z', feedbackRequestedAt: '2025-01-20T00:00:00Z', notes: 'Notater æøå\nLinje to', description: 'Known source description', translatedDescription: 'Kjent oversatt beskrivelse', descriptionLanguage: 'en', tags: '["TypeScript","API"]', deadline: '2030-02-14T00:00:00Z', coverLetterText: 'Bevart søknadstekst æøå', jobUrl: 'https://example.test/opportunity/42?ref=jobjakt' };
|
||||
const { company, job } = await createJob(page, session, `P709 Ærlig Export ${Date.now()}`, sourceData); expect((await call(session.requests, page.request, 'PATCH', `/jobapplications/${job.id}/followup`, session.owner.headers, { followUpAt: sourceData.followUpAt })).status()).toBe(204); const source = await readJob(session, page.request, job.id); const sourceHistory = await json<Array<{ type: string }>>(await call(session.requests, page.request, 'GET', `/jobapplications/${job.id}/history`)); expect(sourceHistory).toHaveLength(2);
|
||||
const group = await enter(page, session); const pending = page.waitForEvent('download'); const exported = page.waitForResponse(value => new URL(value.url()).pathname === '/api/export/jobs' && value.request().method() === 'GET'); await keyboard(group.getByRole('button', { name: copy('exportJson'), exact: true })); const download = await pending; const native = await exported; expect(native.status()).toBe(200); expect(native.headers()['content-type']).toContain('application/json'); const file = await download.path(); expect(file).not.toBeNull(); const bytes = await fs.readFile(file!); expect(bytes).toEqual(await native.body()); expect(download.suggestedFilename()).toMatch(/^job-tracker-export-\d{4}-\d{2}-\d{2}\.json$/); const rows = JSON.parse(bytes.toString('utf8')); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ Id: job.id, OwnerUserId: session.owner.id, JobTitle: sourceData.jobTitle, Company: { Id: company.id, Name: company.name } }); expect(rows[0]).not.toHaveProperty('jobTitle'); await info.attach('actual-job-export-original-bytes', { body: bytes, contentType: 'application/json' });
|
||||
const choosing = page.waitForEvent('filechooser'); await keyboard(group.getByRole('button', { name: copy('importJson'), exact: true })); await (await choosing).setFiles(file!); await expect(group.getByRole('status')).toHaveText(copy('lastImport', 'en', { count: 1 })); const after = await exportRows(session, page.request); expect(after).toHaveLength(2); const importedRaw = after.find(value => value.Id !== job.id)!; expect(importedRaw.OwnerUserId).toBe(session.owner.id); expect(importedRaw.CompanyId).toBe(company.id); const imported = await readJob(session, page.request, Number(importedRaw.Id));
|
||||
for (const field of ['jobTitle', 'status', 'dateApplied', 'location', 'salary', 'salaryMin', 'salaryMax', 'salaryCurrency', 'salaryPeriod', 'nextAction', 'followUpAt', 'feedbackRequestedAt', 'notes', 'description', 'translatedDescription', 'descriptionLanguage', 'tags', 'deadline', 'coverLetterText', 'jobUrl']) expect(imported[field], field).toEqual(source[field]); expect(imported.id).not.toBe(job.id); expect(imported).toMatchObject({ hasResume: false, hasCoverLetter: false, hasPortfolio: false, hasOtherAttachment: false, responseReceived: false, responseDate: null, isDeleted: false, tailoredCvText: null }); await noImportedGraphs(session, page.request, imported.id); expect(await json(await call(session.requests, page.request, 'GET', `/jobapplications/${job.id}/history`))).toEqual(sourceHistory); expect((await json<Array<unknown>>(await call(session.requests, page.request, 'GET', '/companies')))).toHaveLength(1); expect(browserRequests(session, '/companies')).toHaveLength(1); expect(browserRequests(session, '/jobapplications')).toHaveLength(1); await capture(page, group, info, 'actual-json-export-import-en-light-1440');
|
||||
});
|
||||
|
||||
test('[P7-09 J10 E09] legacy import excludes foreign graphs and an actual account switch disposes a held batch before its next queued row', async ({ page, playwright, session }, info) => {
|
||||
test.setTimeout(150_000); const sourceOwnerId = session.owner.id; const sourceEmail = session.owner.email; const source = await createJob(page, session, `P709 Foreign Graph ${Date.now()}`, { jobTitle: 'Owner A original', status: 'Saved', dateApplied: null }); const ownerA = await playwright.request.newContext({ storageState: await page.context().storageState() }); const anonymous = await playwright.request.newContext();
|
||||
try {
|
||||
const accountB = await login(session.requests, page.request, await newAccount(session.requests, session.admin, session.adminHeaders)); session.owner = accountB; await free(session, page.request); const group = await enter(page, session, 'nb', 'light', 768);
|
||||
const row = { id: 987654321, ownerUserId: sourceOwnerId, jobId: 987654321, companyId: source.company.id, company: { id: source.company.id, ownerUserId: sourceOwnerId, name: source.company.name, location: 'Bergen', source: 'manual', recruiterEmail: 'must-not-import@example.test' }, jobTitle: 'Legacy import – æøå', status: 'Saved', dateApplied: null, salary: 'Etter avtale', salaryMin: 500000, salaryMax: 750000, salaryCurrency: 'NOK', salaryPeriod: 'year', notes: 'Legacy supported prose', savedAt: '1900-01-01T00:00:00Z', responseReceived: true, responseDate: '2020-01-01T00:00:00Z', isDeleted: true, deletedAt: '2020-01-01T00:00:00Z', hasResume: true, hasCoverLetter: true, hasPortfolio: true, hasOtherAttachment: true, tailoredCvText: 'FROZEN TEXT MUST NOT BE ADOPTED', tailoredCvDraft: { id: 999, status: 'submitted' }, messages: [{ id: 999, content: 'DO NOT RESTORE' }], attachments: [{ id: 999, fileName: 'DO-NOT-RESTORE.pdf' }], events: [{ id: 999, type: 'Restored' }], submittedPackages: [{ id: 999, cvHtml: 'FROZEN HTML MUST NOT BE ADOPTED' }] };
|
||||
await upload(page, group, [row], 'nb', 'Space'); await expect(group.getByRole('status')).toHaveText(copy('lastImport', 'nb', { count: 1 })); const bRows = await exportRows(session, page.request); expect(bRows).toHaveLength(1); const raw = bRows[0]; expect(raw.OwnerUserId).toBe(accountB.id); expect(raw.Id).not.toBe(row.id); expect(raw.JobId).not.toBe(row.jobId); expect(raw.CompanyId).not.toBe(source.company.id); const imported = await readJob(session, page.request, Number(raw.Id)); expect(imported).toMatchObject({ jobTitle: row.jobTitle, company: { name: source.company.name, location: 'Bergen', source: 'Manual', recruiterEmail: null }, status: 'Saved', dateApplied: null, responseReceived: false, responseDate: null, isDeleted: false, deletedAt: null, hasResume: false, hasCoverLetter: false, hasPortfolio: false, hasOtherAttachment: false, tailoredCvText: null, salary: row.salary, salaryMin: row.salaryMin, salaryMax: row.salaryMax }); expect(String(raw.SavedAt)).not.toBe(row.savedAt); await noImportedGraphs(session, page.request, imported.id); expect(await json(await call(session.requests, page.request, 'GET', '/cv/variants'))).toEqual([]);
|
||||
expect((await call(session.requests, page.request, 'GET', `/jobapplications/${source.job.id}`)).status()).toBe(404); expect((await call(session.requests, ownerA, 'GET', `/jobapplications/${imported.id}`)).status()).toBe(404); const aRows = await exportRows(session, ownerA); expect(aRows).toHaveLength(1); expect(aRows[0].Id).toBe(source.job.id); expect(await exportRows(session, page.request)).toEqual(bRows); expect((await call(session.requests, anonymous, 'GET', '/export/jobs?format=json')).status()).toBe(401); expect((await call(session.requests, anonymous, 'GET', `/jobapplications/${imported.id}`)).status()).toBe(401);
|
||||
for (const request of browserRequests(session, '/companies')) expect(Object.keys(request.body as object).sort()).toEqual(['location', 'name', 'source']); const posted = browserRequests(session, '/jobapplications')[0].body as Record<string, unknown>; for (const key of ['id', 'ownerUserId', 'jobId', 'savedAt', 'isDeleted', 'deletedAt', 'responseReceived', 'responseDate', 'hasResume', 'hasCoverLetter', 'hasPortfolio', 'hasOtherAttachment', 'tailoredCvText', 'tailoredCvDraft', 'messages', 'attachments', 'events', 'submittedPackages']) expect(posted).not.toHaveProperty(key); await capture(page, group, info, 'legacy-input-owner-graph-safety-nb-light-768'); await info.attach('legacy-file-graph-exclusion-boundary', { body: JSON.stringify({ boundary, controlledInput: 'Explicit manually constructed camelCase file contains foreign identifiers and history/file/frozen graphs. Those are never forwarded or restored.', actualReadback: { jobsA: aRows.length, jobsB: bRows.length, importedOwner: raw.OwnerUserId, sourceA: source.job.id, createdB: imported.id, attachments: 0, correspondence: 0, submittedPackages: 0, cvVariants: 0 }, anonymous: [401, 401], foreign: [404, 404] }), contentType: 'application/json' });
|
||||
const retainedB = await playwright.request.newContext({ storageState: await page.context().storageState() });
|
||||
const gate = hold(); let committedB: number | null = null; let pendingPosts = 0; let cancelledReply: string | null = null;
|
||||
await page.route(`${apiUrl}/jobapplications`, async route => {
|
||||
expect(route.request().method()).toBe('POST'); pendingPosts++; const actual = await route.fetch(); expect(actual.status()).toBe(201); committedB = (await actual.json() as { id: number }).id;
|
||||
await gate.pending; const failure = route.request().failure(); if (failure) { cancelledReply = failure.errorText; return; } return route.fulfill({ response: actual });
|
||||
});
|
||||
try {
|
||||
await upload(page, group, [{ jobTitle: 'Owner B accepted before switch', status: 'Saved', dateApplied: null, company: { name: source.company.name } }, { jobTitle: 'Owner B next queued row must not write', status: 'Saved', dateApplied: null, company: { name: source.company.name } }], 'nb');
|
||||
await expect.poll(() => Number.isInteger(committedB) && Number(committedB) > 0).toBe(true); expect(pendingPosts).toBe(1); await expect(group).toHaveAttribute('aria-busy', 'true');
|
||||
session.owner = await login(session.requests, page.request, sourceEmail); expect(session.owner.id).toBe(sourceOwnerId); await free(session, page.request);
|
||||
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/); gate.release(); await expect(group).toHaveCount(0);
|
||||
const afterB = await exportRows(session, retainedB); expect(afterB).toHaveLength(2); expect(afterB.some(value => value.Id === committedB && value.JobTitle === 'Owner B accepted before switch')).toBe(true); expect(afterB.some(value => value.JobTitle === 'Owner B next queued row must not write')).toBe(false);
|
||||
const afterA = await exportRows(session, page.request); expect(afterA).toHaveLength(1); expect(afterA[0].Id).toBe(source.job.id); expect(pendingPosts).toBe(1); expect(browserRequests(session, '/jobapplications')).toHaveLength(2);
|
||||
await info.attach('actual-import-owner-switch-disposal', { body: JSON.stringify({ boundary, controlledTiming: 'An actual owner-B job POST201 acknowledgement was held. Actual login changed the shared cookies back to A, disposing the earlier page; only then was the held reply released.', committedB, cancelledReply, jobsB: afterB.length, jobsA: afterA.length, oldBatchJobPosts: pendingPosts, nextQueuedRowWritten: false, qualification: 'The first B write remains committed. Disposal stops queued writes; it neither rolls back accepted work nor adopts the next account session.' }), contentType: 'application/json' });
|
||||
} finally { gate.release(); await retainedB.dispose(); }
|
||||
} finally { await ownerA.dispose(); await anonymous.dispose(); }
|
||||
});
|
||||
|
||||
test('[P7-09 J10] invalid later rows and conflicting null aliases reject the complete file before the first company or job write', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000); const group = await enter(page, session, 'en', 'dark', 320); const good = { jobTitle: 'Valid earlier row must never write', status: 'Saved', dateApplied: null, company: { name: 'No company should be created' } };
|
||||
for (const invalid of [{ ...good, company: 42 }, { ...good, dateApplied: '2026-02-31' }, { ...good, notes: null, Notes: 'Conflicting populated alias' }, { ...good, salaryMin: 1e100 }, { ...good, dateApplied: '2026-01-01T00:00:00+15:00' }]) {
|
||||
await upload(page, group, [good, invalid]); await expect(group.locator('input[accept="application/json"]')).toHaveValue(''); await expect(group.getByRole('alert')).toContainText(copy('jobImportInvalidRow', 'en', { row: 2 })); await expect(group).toHaveAttribute('aria-busy', 'false'); expect(browserRequests(session, '/companies')).toHaveLength(0); expect(browserRequests(session, '/jobapplications')).toHaveLength(0); expect(await exportRows(session, page.request)).toEqual([]); expect(await json(await call(session.requests, page.request, 'GET', '/companies'))).toEqual([]);
|
||||
}
|
||||
await expect(group.locator('input[accept="application/json"]')).toHaveValue(''); await capture(page, group, info, 'invalid-later-row-zero-writes-en-dark-320'); await info.attach('preflight-whole-file-validation', { body: JSON.stringify({ boundary, explicitlyConstructedInvalidSecondRows: ['company is a number', 'nonexistent February31 date', 'null camelCase notes conflicts with populated PascalCase Notes', 'finite1e100 exceeds the server decimal envelope', '+15:00 exceeds the server DateTime offset envelope'], earlierValidRowsWritten: 0, companyPosts: 0, jobPosts: 0, actualCompaniesAndJobs: [0, 0], inputResetForDeliberateReselection: true }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-09 J10] controlled503 after an actual second job commit reports only acknowledged rows and warns about uncertainty without replaying or admitting another batch', async ({ page, session }, info) => {
|
||||
test.setTimeout(150_000); const group = await enter(page, session, 'nb', 'dark', 390); const companyName = `P709 Partial ${Date.now()}`; const rows = [1, 2, 3].map(number => ({ JobTitle: `Partial native row ${number}`, Status: 'Saved', DateApplied: null, Company: { Name: companyName, Location: 'Trondheim', Source: 'manual' } })); const gate = hold(); const actualCommits: { id: number; status: number }[] = [];
|
||||
await page.route(`${apiUrl}/jobapplications`, async route => { expect(route.request().method()).toBe('POST'); const actual = await route.fetch(); expect(actual.status()).toBe(201); const created = await actual.json() as { id: number }; actualCommits.push({ id: created.id, status: actual.status() }); if (actualCommits.length === 2) { await gate.pending; return route.fulfill({ status: 503, json: {} }); } return route.fulfill({ response: actual }); });
|
||||
try {
|
||||
await upload(page, group, rows, 'nb'); await expect.poll(() => actualCommits.length).toBe(2); await expect(group).toHaveAttribute('aria-busy', 'true'); await expect(group.getByRole('status')).toContainText(copy('jobImportProgress', 'nb', { count: 1, total: 3 })); const importing = group.getByRole('button', { name: copy('profileUploading', 'nb'), exact: true }); await expect(importing).toBeDisabled(); const chooserEvents: string[] = []; const unexpectedChooser = () => { chooserEvents.push('unexpected second native chooser'); }; page.on('filechooser', unexpectedChooser); await page.keyboard.press('Space'); await group.locator('input[accept="application/json"]').setInputFiles({ name: 'explicit-duplicate-selection.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(rows), 'utf8') }); expect(browserRequests(session, '/jobapplications')).toHaveLength(2); expect(browserRequests(session, '/companies')).toHaveLength(2); expect(chooserEvents).toEqual([]); page.off('filechooser', unexpectedChooser);
|
||||
expect((await exportRows(session, page.request)).map(value => value.JobTitle).sort()).toEqual(['Partial native row 1', 'Partial native row 2']);
|
||||
await capture(page, group, info, 'held-real-commits-progress-nb-dark-390');
|
||||
await chooseSection(page, 'nb', 'settingsTabNotifications'); await expect(group).not.toBeVisible();
|
||||
const relayed = page.waitForResponse(value => new URL(value.url()).pathname === '/api/jobapplications' && value.request().method() === 'POST');
|
||||
gate.release(); expect((await relayed).status()).toBe(503);
|
||||
await expect(page.getByRole('alert').filter({ hasText: copy('jobImportStopped', 'nb', { row: 2, count: 1, total: 3 }) })).toHaveCount(1);
|
||||
await expect(page.getByRole('alert').filter({ hasText: copy('importFailedJson', 'nb') })).toHaveCount(0);
|
||||
await chooseSection(page, 'nb', 'settingsTabGeneral'); await expect(group).toBeVisible(); await expect(group).toHaveAttribute('aria-busy', 'false');
|
||||
await expect(group.getByRole('alert')).toContainText(copy('jobImportStopped', 'nb', { row: 2, count: 1, total: 3 })); await expect(group.getByRole('alert')).toContainText(copy('jobImportInspectBeforeRetry', 'nb')); await expect(group.getByRole('button', { name: copy('retry', 'nb'), exact: true })).toHaveCount(0); await expect(group.getByRole('button', { name: copy('importJson', 'nb'), exact: true })).toBeEnabled(); await expect(group.locator('input[accept="application/json"]')).toHaveValue('');
|
||||
const samples = [
|
||||
{ width: 320, language: 'nb', mode: 'dark' }, { width: 390, language: 'en', mode: 'light' },
|
||||
{ width: 768, language: 'nb', mode: 'light' }, { width: 1024, language: 'en', mode: 'dark' },
|
||||
{ width: 1280, language: 'nb', mode: 'dark' }, { width: 1440, language: 'en', mode: 'light' },
|
||||
{ width: 1920, language: 'en', mode: 'dark' },
|
||||
] as const;
|
||||
for (const sample of samples) {
|
||||
await page.setViewportSize({ width: sample.width, height: sample.width < 768 ? 844 : 900 });
|
||||
const languageButton = page.locator('header').getByRole('button', { name: sample.language === 'nb' ? 'Norsk' : 'English', exact: true });
|
||||
if (await languageButton.getAttribute('aria-pressed') !== 'true') {
|
||||
const savedLanguage = page.waitForResponse(value => new URL(value.url()).pathname === '/api/auth/preferences/language' && value.request().method() === 'PUT');
|
||||
await keyboard(languageButton); expect((await savedLanguage).status()).toBe(204);
|
||||
}
|
||||
const current = page.getByRole('group', { name: copy('importExportTitle', sample.language), exact: true });
|
||||
await expect(current.getByRole('alert')).toContainText(copy('jobImportStopped', sample.language, { row: 2, count: 1, total: 3 }));
|
||||
if (await page.locator('html').getAttribute('data-color-scheme') !== sample.mode) {
|
||||
const theme = page.getByRole('main').getByRole('combobox', { name: copy('settingsTheme', sample.language), exact: true });
|
||||
await keyboard(theme); const option = page.getByRole('option', { name: copy(sample.mode === 'dark' ? 'settingsThemeDark' : 'settingsThemeLight', sample.language), exact: true }); await option.focus(); await option.press('Enter');
|
||||
}
|
||||
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.mode);
|
||||
await expect(current).toContainText(copy('jobImportScope', sample.language)); await expect(current.getByRole('alert')).toContainText(copy('jobImportInspectBeforeRetry', sample.language));
|
||||
const importButton = current.getByRole('button', { name: copy('importJson', sample.language), exact: true }); await importButton.focus(); await expect(importButton).toBeFocused(); expect(await importButton.evaluate(node => getComputedStyle(node).outlineStyle)).toBe('solid');
|
||||
await capture(page, current, info, `partial-warning-${sample.width}-${sample.language}-${sample.mode}`);
|
||||
}
|
||||
const after = await exportRows(session, page.request); expect(after).toHaveLength(2); expect(after.map(value => value.JobTitle).sort()).toEqual(['Partial native row 1', 'Partial native row 2']); expect(await json<Array<unknown>>(await call(session.requests, page.request, 'GET', '/companies'))).toHaveLength(1); expect(actualCommits).toHaveLength(2); expect(browserRequests(session, '/jobapplications')).toHaveLength(2); expect(browserRequests(session, '/companies')).toHaveLength(2);
|
||||
await info.attach('actual-commit-controlled503-uncertainty', { body: JSON.stringify({ boundary, actualCommits, controlledFault: 'Only the second browser-visible job reply is replaced with503 AFTER actual route.fetch returned201; its job really exists. This is a fault-relay fixture, not a real backend503 or a fabricated successful write.', fileRows: 3, acknowledgedJobs: 1, actualJobs: 2, thirdRowNotAttempted: true, wholeFileOrRowAutoReplay: false, duplicateActivation: 'Disabled natural button plus explicitly controlled second file-input selection while busy; exactly one batch admitted.', internalTabLifetime: 'Accepted import completed while General was hidden by Notifications; the stopped result and its acknowledgement count remain on General return.', samples, layoutQualification: 'Seven paired samples, not Cartesian; same persisted partial result and uncertainty copy survive real language/theme controls without reload or projected result state.', companies: 1 }), contentType: 'application/json' });
|
||||
} finally { gate.release(); }
|
||||
});
|
||||
@@ -42,7 +42,7 @@ function seedQueued(info: TestInfo, owner: Owner) {
|
||||
const metadataRoot = info.config.metadata.isolatedDataRoot;
|
||||
if (typeof metadataRoot !== 'string' || process.env.PLAYWRIGHT_REUSE_SERVERS === 'true') throw new Error('Operation seeding requires explicit isolated non-reused dataRoot');
|
||||
expect(path.isAbsolute(metadataRoot)).toBe(true); const dataRoot = realpathSync(metadataRoot); expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir())); expect(path.basename(dataRoot)).toMatch(/^jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*$/);
|
||||
const result = spawnSync('rtk', ['proxy', process.env.PLAYWRIGHT_PYTHON_PATH || 'python', '-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 });
|
||||
const result = spawnSync(process.env.PLAYWRIGHT_PYTHON_PATH || (process.platform === 'win32' ? 'python' : 'python3'), ['-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 });
|
||||
expect(result.error, 'guarded helper must start').toBeUndefined(); expect(result.status, result.stderr).toBe(0); const value = JSON.parse(result.stdout) as { operationId: string }; expect(Object.keys(value)).toEqual(['operationId']); expect(value.operationId).toMatch(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/); return value.operationId;
|
||||
}
|
||||
async function cancel(api: APIRequestContext, owner: Owner, id: string) { const value = await json<Operation>(await api.post(`${apiUrl}/operations/${id}/cancel`, { headers: owner.headers })); utcFields(value); expect(value).toMatchObject({ id, status: 'cancelled', canCancel: false, canRetry: true }); const notice = (await notices(api)).find(item => item.operationId === id)!; expect(notice).toBeDefined(); expect(notice).toMatchObject({ operationId: id, kind: 'operation_cancelled', linkPath: null, readAtUtc: null }); expect(notice.createdAtUtc).toBe(value.completedAtUtc); return { value, notice }; }
|
||||
|
||||
@@ -42,7 +42,7 @@ function seedQueued(info: TestInfo, owner: Owner) {
|
||||
const metadataRoot = info.config.metadata.isolatedDataRoot;
|
||||
if (typeof metadataRoot !== 'string' || process.env.PLAYWRIGHT_REUSE_SERVERS === 'true') throw new Error('Operation seeding requires explicit isolated non-reused dataRoot');
|
||||
expect(path.isAbsolute(metadataRoot)).toBe(true); const dataRoot = realpathSync(metadataRoot); expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir())); expect(path.basename(dataRoot)).toMatch(/^jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*$/);
|
||||
const result = spawnSync('rtk', ['proxy', process.env.PLAYWRIGHT_PYTHON_PATH || 'python', '-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 });
|
||||
const result = spawnSync(process.env.PLAYWRIGHT_PYTHON_PATH || (process.platform === 'win32' ? 'python' : 'python3'), ['-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 });
|
||||
expect(result.error, 'guarded helper must start').toBeUndefined(); expect(result.status, result.stderr).toBe(0); const value = JSON.parse(result.stdout) as { operationId: string }; expect(Object.keys(value)).toEqual(['operationId']); expect(value.operationId).toMatch(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/); return value.operationId;
|
||||
}
|
||||
async function cancel(api: APIRequestContext, owner: Owner, id: string) { const value = await json<Operation>(await api.post(`${apiUrl}/operations/${id}/cancel`, { headers: owner.headers })); utcFields(value); expect(value).toMatchObject({ id, status: 'cancelled', canCancel: false, canRetry: true }); const notice = (await notices(api)).find(item => item.operationId === id)!; expect(notice).toBeDefined(); expect(notice).toMatchObject({ operationId: id, kind: 'operation_cancelled', linkPath: null, readAtUtc: null }); expect(notice.createdAtUtc).toBe(value.completedAtUtc); return { value, notice }; }
|
||||
|
||||
@@ -268,7 +268,7 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
|
||||
? route.fulfill({ status: 204, body: "" }) : route.continue());
|
||||
await page.getByRole("button", { name: t.notifications, exact: true }).click();
|
||||
await expect(page.getByText("CV export failed")).toBeVisible();
|
||||
await expect(page.getByText(t.notificationsUnread)).toBeVisible();
|
||||
await expect(page.getByText(t.notificationsUnread, { exact: true })).toBeVisible();
|
||||
await capture(page, info, `utility-notifications-${suffix}`);
|
||||
await scan(page, info, `utility-notifications-${suffix}`, ".MuiPopover-paper");
|
||||
await page.getByRole("button", { name: t.notificationsMarkRead }).click();
|
||||
|
||||
Generated
+3
-3
@@ -7729,9 +7729,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
|
||||
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -42,7 +42,8 @@ export default defineConfig({
|
||||
Auth__AdminEmail: "e2e@example.test",
|
||||
Auth__AdminPassword: "E2ePassword123!",
|
||||
Auth__AllowRegistration: "false",
|
||||
RateLimits__AuthLoginPermitLimit: "100",
|
||||
// The full browser suite shares one loopback IP. Real throttle contracts use the separate 10-permit fixture.
|
||||
RateLimits__AuthLoginPermitLimit: "1000",
|
||||
App__Version: "e2e-verification",
|
||||
App__CommitSha: "e2e1234",
|
||||
Cors__Origins__0: appUrl,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Alert, Box, Button, Typography } from "@mui/material";
|
||||
import { Box, Button, Typography } from "@mui/material";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
import { api } from "../api";
|
||||
import { Company, JobApplication } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { checkSessionCookie, getSessionContext, isSessionInvalidated, SESSION_INVALIDATED_EVENT } from "../sessionBoundary";
|
||||
|
||||
// savedAt is omitted alongside id: both are assigned by the server on create, so an imported
|
||||
// row has no business supplying one.
|
||||
@@ -15,105 +14,6 @@ type ImportJob = Omit<JobApplication, "id" | "company" | "savedAt"> & {
|
||||
company: Pick<Company, "name" | "location" | "source">;
|
||||
};
|
||||
|
||||
type ImportFields = Record<string, string | number | null>;
|
||||
type PreparedImport = { company: ImportFields; job: ImportFields };
|
||||
type ImportState =
|
||||
| { kind: "checking" }
|
||||
| { kind: "running"; count: number; total: number }
|
||||
| { kind: "invalid"; row?: number }
|
||||
| { kind: "stopped"; row: number; count: number; total: number };
|
||||
|
||||
class InvalidImport extends Error {
|
||||
constructor(readonly row?: number) { super("Invalid import"); }
|
||||
}
|
||||
|
||||
function importRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new InvalidImport();
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function importAlias<T>(record: Record<string, unknown>, name: string, parse: (value: unknown) => T): T | undefined {
|
||||
const pascal = name[0].toUpperCase() + name.slice(1);
|
||||
const camelPresent = Object.prototype.hasOwnProperty.call(record, name);
|
||||
const pascalPresent = Object.prototype.hasOwnProperty.call(record, pascal);
|
||||
const camel = camelPresent ? parse(record[name]) : undefined;
|
||||
const exported = pascalPresent ? parse(record[pascal]) : undefined;
|
||||
if (camelPresent && pascalPresent && JSON.stringify(camel) !== JSON.stringify(exported)) throw new InvalidImport();
|
||||
return camelPresent ? camel : exported;
|
||||
}
|
||||
|
||||
function importText(value: unknown): string | null {
|
||||
if (value !== null && typeof value !== "string") throw new InvalidImport();
|
||||
return value;
|
||||
}
|
||||
|
||||
function importRequiredText(value: unknown): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new InvalidImport();
|
||||
return value;
|
||||
}
|
||||
|
||||
function importDate(value: unknown): string | null {
|
||||
const text = importText(value);
|
||||
if (text === null) return null;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,16}))?)?(?:Z|([+-])(\d{2}):(\d{2}))?)?$/.exec(text);
|
||||
const instant = Date.parse(text);
|
||||
if (!match || !Number.isFinite(instant)) throw new InvalidImport();
|
||||
const [, year, month, day, hour, minute, second, , offset, offsetHour, offsetMinute] = match;
|
||||
if (Number(year) < 1 || Number(month) < 1 || Number(month) > 12 || Number(day) < 1 ||
|
||||
Number(day) > new Date(Date.UTC(Number(year), Number(month), 0)).getUTCDate() ||
|
||||
Number(hour ?? 0) > 23 || Number(minute ?? 0) > 59 || Number(second ?? 0) > 59 ||
|
||||
Number(offsetHour ?? 0) > 14 || Number(offsetMinute ?? 0) > 59 || Number(offsetHour) === 14 && Number(offsetMinute) !== 0) throw new InvalidImport();
|
||||
// DateTime offsets must fit its year range; no-zone values retain unspecified local-time semantics.
|
||||
if (offset && (instant < Date.parse("0001-01-01T00:00:00Z") || instant > Date.parse("9999-12-31T23:59:59.999Z"))) throw new InvalidImport();
|
||||
return text;
|
||||
}
|
||||
|
||||
function importNumber(value: unknown): number | null {
|
||||
// decimal.MaxValue is 2^96 - 1; JavaScript rounds that bound up, so equality must also be rejected.
|
||||
if (value !== null && (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) >= 2 ** 96)) throw new InvalidImport();
|
||||
return value;
|
||||
}
|
||||
|
||||
function importCompany(value: unknown): ImportFields {
|
||||
const source = importRecord(value);
|
||||
const name = importAlias(source, "name", importRequiredText);
|
||||
if (name === undefined) throw new InvalidImport();
|
||||
const company: ImportFields = { name };
|
||||
for (const field of ["location", "source"]) {
|
||||
const text = importAlias(source, field, importText);
|
||||
if (text !== undefined) company[field] = text;
|
||||
}
|
||||
return company;
|
||||
}
|
||||
|
||||
function prepareImport(text: string): PreparedImport[] {
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(text); } catch { throw new InvalidImport(); }
|
||||
if (!Array.isArray(parsed)) throw new InvalidImport();
|
||||
return parsed.map((value, index) => {
|
||||
try {
|
||||
const source = importRecord(value);
|
||||
const company = importAlias(source, "company", importCompany);
|
||||
const jobTitle = importAlias(source, "jobTitle", importRequiredText);
|
||||
if (!company || jobTitle === undefined) throw new InvalidImport();
|
||||
const job: ImportFields = { jobTitle };
|
||||
for (const field of ["status", "location", "salary", "salaryCurrency", "salaryPeriod", "nextAction", "notes", "description", "translatedDescription", "descriptionLanguage", "tags", "coverLetterText", "jobUrl", "source", "countryCode"]) {
|
||||
const text = importAlias(source, field, importText);
|
||||
if (text !== undefined) job[field] = text;
|
||||
}
|
||||
for (const field of ["dateApplied", "followUpAt", "feedbackRequestedAt", "deadline"]) {
|
||||
const date = importAlias(source, field, importDate);
|
||||
if (date !== undefined) job[field] = date;
|
||||
}
|
||||
for (const field of ["salaryMin", "salaryMax"]) {
|
||||
const number = importAlias(source, field, importNumber);
|
||||
if (number !== undefined) job[field] = number;
|
||||
}
|
||||
return { company, job };
|
||||
} catch { throw new InvalidImport(index + 1); }
|
||||
});
|
||||
}
|
||||
|
||||
function downloadFile(filename: string, content: BlobPart, mime: string) {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -280,25 +180,8 @@ export default function ImportExportJobs() {
|
||||
const { t } = useI18n();
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [lastImportCount, setLastImportCount] = useState<number | null>(null);
|
||||
const [importState, setImportState] = useState<ImportState | null>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const csvInputRef = useRef<HTMLInputElement>(null);
|
||||
const importBody = useRef<HTMLDivElement>(null);
|
||||
const importSession = useRef({ mounted: true, busy: false, generation: 0 });
|
||||
const currentTranslation = useRef(t);
|
||||
|
||||
useEffect(() => { currentTranslation.current = t; }, [t]);
|
||||
useEffect(() => {
|
||||
const session = importSession.current;
|
||||
session.mounted = true;
|
||||
const invalidate = () => { session.generation += 1; };
|
||||
window.addEventListener(SESSION_INVALIDATED_EVENT, invalidate);
|
||||
return () => {
|
||||
session.mounted = false;
|
||||
invalidate();
|
||||
window.removeEventListener(SESSION_INVALIDATED_EVENT, invalidate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const exportAll = async (format: "json" | "csv") => {
|
||||
try {
|
||||
@@ -315,61 +198,40 @@ export default function ImportExportJobs() {
|
||||
};
|
||||
|
||||
const onImportFile = async (file: File | null) => {
|
||||
const session = importSession.current;
|
||||
checkSessionCookie();
|
||||
if (!file || !session.mounted || session.busy || isSessionInvalidated()) return;
|
||||
session.busy = true;
|
||||
const context = getSessionContext();
|
||||
const generation = session.generation;
|
||||
const active = () => {
|
||||
checkSessionCookie();
|
||||
return session.mounted && generation === session.generation && !isSessionInvalidated() && getSessionContext() === context;
|
||||
};
|
||||
if (importBody.current?.contains(document.activeElement)) importBody.current.focus();
|
||||
if (!file) return;
|
||||
setImporting(true);
|
||||
setLastImportCount(null);
|
||||
setImportState({ kind: "checking" });
|
||||
let total = 0;
|
||||
let created = 0;
|
||||
let row = 1;
|
||||
try {
|
||||
const text = await file.text();
|
||||
if (!active()) return;
|
||||
const jobs = prepareImport(text);
|
||||
total = jobs.length;
|
||||
setImportState({ kind: "running", count: created, total });
|
||||
for (const j of jobs) {
|
||||
if (!active()) return;
|
||||
row = created + 1;
|
||||
const parsed = JSON.parse(text) as ImportJob[];
|
||||
if (!Array.isArray(parsed)) throw new Error("Invalid format");
|
||||
|
||||
let created = 0;
|
||||
for (const j of parsed) {
|
||||
const companyRes = await api.post<Company>("/companies", j.company);
|
||||
if (!active()) return;
|
||||
const company = companyRes.data;
|
||||
if (!Number.isInteger(company?.id) || company.id <= 0) throw new Error("Invalid company response");
|
||||
// Only the normalized create allow-list reaches this request; exported identities and history never do.
|
||||
await api.post("/jobapplications", { ...j.job, companyId: company.id });
|
||||
if (!active()) return;
|
||||
await api.post("/jobapplications", {
|
||||
jobTitle: j.jobTitle,
|
||||
companyId: company.id,
|
||||
status: j.status,
|
||||
dateApplied: j.dateApplied,
|
||||
location: j.location,
|
||||
salary: j.salary,
|
||||
nextAction: j.nextAction,
|
||||
followUpAt: j.followUpAt,
|
||||
jobUrl: j.jobUrl,
|
||||
notes: j.notes,
|
||||
coverLetterText: j.coverLetterText,
|
||||
});
|
||||
created += 1;
|
||||
setImportState({ kind: "running", count: created, total });
|
||||
}
|
||||
|
||||
setLastImportCount(created);
|
||||
setImportState(null);
|
||||
toast(currentTranslation.current("importedJobs", { count: created }), "success");
|
||||
} catch (error) {
|
||||
if (!active()) return;
|
||||
if (error instanceof InvalidImport) setImportState({ kind: "invalid", row: error.row });
|
||||
else if (total === 0) setImportState({ kind: "invalid" });
|
||||
else {
|
||||
setLastImportCount(created);
|
||||
setImportState({ kind: "stopped", row, count: created, total });
|
||||
}
|
||||
toast(currentTranslation.current(total > 0 ? "jobImportStopped" : "importFailedJson", { row, count: created, total }), "error");
|
||||
toast(t("importedJobs", { count: created }), "success");
|
||||
} catch {
|
||||
toast(t("importFailedJson"), "error");
|
||||
} finally {
|
||||
if (active()) {
|
||||
session.busy = false;
|
||||
setImporting(false);
|
||||
if (importInputRef.current) importInputRef.current.value = "";
|
||||
}
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -390,8 +252,7 @@ export default function ImportExportJobs() {
|
||||
|
||||
return (
|
||||
<PageSection level="h3" title={t("importExportTitle")} description={helper}>
|
||||
<Box ref={importBody} role="group" aria-label={t("importExportTitle")} aria-busy={importing} tabIndex={-1} sx={{ display: "grid", gap: 1.5, minWidth: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary">{t("jobImportScope")}</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Button variant="outlined" onClick={() => void exportAll("json")}>{t("exportJson")}</Button>
|
||||
<Button variant="outlined" onClick={() => void exportAll("csv")}>{t("exportCsv")}</Button>
|
||||
@@ -406,19 +267,8 @@ export default function ImportExportJobs() {
|
||||
</Button>
|
||||
<input ref={csvInputRef} type="file" accept=".csv,text/csv" hidden onChange={(e) => void onConvertCsv(e.target.files?.[0] ?? null)} />
|
||||
|
||||
</Box>
|
||||
{importState?.kind === "checking" || importState?.kind === "running" ? <Typography role="status" variant="body2" color="text.secondary">
|
||||
{importState.kind === "checking" ? t("jobImportChecking") : t("jobImportProgress", { count: importState.count, total: importState.total })}
|
||||
</Typography> : null}
|
||||
{importState?.kind === "invalid" ? <Alert severity="error">
|
||||
{importState.row === undefined ? t("jobImportInvalidFile") : t("jobImportInvalidRow", { row: importState.row })}
|
||||
</Alert> : null}
|
||||
{importState?.kind === "stopped" ? <Alert severity="error">
|
||||
{t("jobImportStopped", { row: importState.row, count: importState.count, total: importState.total })}
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>{t("jobImportInspectBeforeRetry")}</Typography>
|
||||
</Alert> : null}
|
||||
{lastImportCount !== null && (
|
||||
<Typography role="status" variant="body2" color="text.secondary">
|
||||
{lastImportCount !== null && (
|
||||
<Typography sx={{ ml: 1, color: "text.secondary" }}>
|
||||
{t("lastImport", { count: lastImportCount })}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -40,11 +40,9 @@ interface Props {
|
||||
onThemeModeChange: (v: ThemeModePref) => void;
|
||||
}
|
||||
|
||||
function TabPanel({ value, index, label, children, keepMounted = false }: { value: number; index: number; label: string; children: React.ReactNode; keepMounted?: boolean }) {
|
||||
const [hasBeenActive, setHasBeenActive] = useState(value === index);
|
||||
useEffect(() => { if (value === index) setHasBeenActive(true); }, [value, index]);
|
||||
if (value !== index && (!keepMounted || !hasBeenActive)) return null;
|
||||
return <Box role="tabpanel" hidden={value !== index} id={`settings-panel-${index}`} aria-labelledby={`settings-tab-${index}`} aria-label={label} sx={{ mt: "var(--app-section-gap)", minWidth: 0 }}>{children}</Box>;
|
||||
function TabPanel({ value, index, label, children }: { value: number; index: number; label: string; children: React.ReactNode }) {
|
||||
if (value !== index) return null;
|
||||
return <Box role="tabpanel" id={`settings-panel-${index}`} aria-labelledby={`settings-tab-${index}`} aria-label={label} sx={{ mt: "var(--app-section-gap)", minWidth: 0 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
type NotificationPrefs = {
|
||||
@@ -249,7 +247,7 @@ export default function SettingsView({
|
||||
|
||||
{rulesDirty ? <Typography role="status" variant="body2" sx={{ mt: 1, color: "text.secondary" }}>{t("assetsUnsavedChanges")}. {t("rulesDraftScope")}</Typography> : null}
|
||||
|
||||
<TabPanel value={tab} index={0} label={t("settingsTabGeneral")} keepMounted>
|
||||
<TabPanel value={tab} index={0} label={t("settingsTabGeneral")}>
|
||||
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: "var(--app-section-gap)" }}>
|
||||
<PageSection surface title={t("settingsAppearance")}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { ConfirmProvider, useConfirm } from './confirm';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
|
||||
@@ -31,6 +31,10 @@ test('renders app-owned confirmation dialog', async () => {
|
||||
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog', { name: 'Delete job' })).toHaveAccessibleDescription('Are you sure?');
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
|
||||
const messageRegion = screen.getByRole('region', { name: 'Delete job' });
|
||||
expect(messageRegion).toHaveAttribute('tabindex', '0');
|
||||
messageRegion.focus();
|
||||
expect(messageRegion).toHaveFocus();
|
||||
});
|
||||
|
||||
test('uses the selected language for shared dialog defaults', async () => {
|
||||
@@ -48,3 +52,18 @@ test('uses the selected language for shared dialog defaults', async () => {
|
||||
expect(screen.getByRole('button', { name: 'Bekreft' })).toBeInTheDocument();
|
||||
window.localStorage.removeItem('uiLanguage');
|
||||
});
|
||||
|
||||
test('finishing the opening transition preserves focus already moved inside the dialog', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
render(<ConfirmProvider><Demo /></ConfirmProvider>);
|
||||
fireEvent.click(screen.getByRole('button', { name: /open confirm/i }));
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
|
||||
const message = screen.getByRole('region', { name: 'Delete job' });
|
||||
act(() => message.focus());
|
||||
await act(async () => { jest.advanceTimersByTime(1000); });
|
||||
expect(message).toHaveFocus();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -23,8 +23,8 @@ const ConfirmContext = createContext<ConfirmContextValue | null>(null);
|
||||
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
const i18n = useOptionalI18n();
|
||||
const messageId = useId();
|
||||
const titleId = useId();
|
||||
const resolverRef = useRef<((value: boolean) => void) | null>(null);
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [state, setState] = useState<ConfirmState>({
|
||||
open: false,
|
||||
message: "",
|
||||
@@ -60,13 +60,12 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ConfirmContext.Provider value={value}>
|
||||
{children}
|
||||
<Dialog open={state.open} onClose={() => closeWith(false)} aria-describedby={messageId} fullWidth maxWidth="xs"
|
||||
<Dialog open={state.open} onClose={() => closeWith(false)} aria-labelledby={titleId} aria-describedby={messageId} fullWidth maxWidth="xs"
|
||||
slotProps={{
|
||||
paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } },
|
||||
transition: { onEntered: () => cancelButtonRef.current?.focus() },
|
||||
}}>
|
||||
<DialogTitle sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogTitle id={titleId} sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
|
||||
<DialogContent role="region" aria-labelledby={titleId} tabIndex={0}>
|
||||
<Alert severity={state.destructive ? "warning" : "info"} variant="outlined" sx={{ mb: 2 }}>
|
||||
{state.destructive
|
||||
? i18n?.t("confirmDestructiveHint") ?? "This action may be hard to undo."
|
||||
@@ -75,7 +74,7 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
<Typography id={messageId} sx={{ color: "text.secondary" }}>{state.message}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
|
||||
<Button ref={cancelButtonRef} autoFocus onClick={() => closeWith(false)}>{state.cancelLabel}</Button>
|
||||
<Button autoFocus onClick={() => closeWith(false)}>{state.cancelLabel}</Button>
|
||||
<Button color={state.destructive ? "error" : "primary"} variant="contained" onClick={() => closeWith(true)}>
|
||||
{state.confirmLabel}
|
||||
</Button>
|
||||
|
||||
@@ -2297,13 +2297,6 @@ export const translations = {
|
||||
errorBoundaryRefresh: "Refresh",
|
||||
importExportTitle: "Import / Export",
|
||||
importExportBody: "Import expects the JSON exported by this app (an array of job objects with embedded company).",
|
||||
jobImportScope: "Imports create new jobs from supported fields. IDs, owners, history, attachments and CV associations are not restored. Keep Settings open until the import finishes. If you leave, the import may be incomplete.",
|
||||
jobImportChecking: "Checking import file…",
|
||||
jobImportProgress: "{count} of {total} jobs confirmed imported.",
|
||||
jobImportInvalidFile: "Choose a JSON array of jobs. No jobs or companies were written.",
|
||||
jobImportInvalidRow: "Row {row} has missing, invalid or conflicting fields. No jobs or companies were written.",
|
||||
jobImportStopped: "Import stopped at row {row}. {count} of {total} jobs are confirmed imported.",
|
||||
jobImportInspectBeforeRetry: "This row may have been partly saved. Check your job list and company list before importing again; repeating the file can create duplicates.",
|
||||
exportJson: "Export JSON",
|
||||
exportCsv: "Export CSV",
|
||||
importJson: "Import JSON",
|
||||
@@ -5067,13 +5060,6 @@ export const translations = {
|
||||
errorBoundaryRefresh: "Oppdater",
|
||||
importExportTitle: "Import / eksport",
|
||||
importExportBody: "Import forventer JSON eksportert av denne appen (en matrise med jobbobjekter med innebygd selskap).",
|
||||
jobImportScope: "Import oppretter nye jobber fra støttede felt. ID-er, eiere, historikk, vedlegg og CV-koblinger gjenopprettes ikke. Hold Innstillinger åpent til importen er ferdig. Hvis du går ut, kan importen bli ufullstendig.",
|
||||
jobImportChecking: "Kontrollerer importfil…",
|
||||
jobImportProgress: "{count} av {total} jobber er bekreftet importert.",
|
||||
jobImportInvalidFile: "Velg en JSON-liste med jobber. Ingen jobber eller selskaper ble lagret.",
|
||||
jobImportInvalidRow: "Rad {row} har manglende, ugyldige eller motstridende felt. Ingen jobber eller selskaper ble lagret.",
|
||||
jobImportStopped: "Importen stoppet på rad {row}. {count} av {total} jobber er bekreftet importert.",
|
||||
jobImportInspectBeforeRetry: "Denne raden kan være delvis lagret. Kontroller jobblisten og selskapslisten før du importerer på nytt; gjentatt import kan opprette duplikater.",
|
||||
exportJson: "Eksporter JSON",
|
||||
exportCsv: "Eksporter CSV",
|
||||
importJson: "Importer JSON",
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import ImportExportJobs from "./components/ImportExportJobs";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import { ToastProvider } from "./toast";
|
||||
import { api } from "./api";
|
||||
import { getSessionContext, isSessionInvalidated, SESSION_INVALIDATED_EVENT } from "./sessionBoundary";
|
||||
|
||||
jest.mock("./sessionBoundary", () => ({
|
||||
SESSION_INVALIDATED_EVENT: "session-invalidated",
|
||||
checkSessionCookie: jest.fn(),
|
||||
getSessionContext: jest.fn(() => "owner-a-session"),
|
||||
isSessionInvalidated: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const currentContext = getSessionContext as jest.MockedFunction<typeof getSessionContext>;
|
||||
const invalidated = isSessionInvalidated as jest.MockedFunction<typeof isSessionInvalidated>;
|
||||
const tr = (key: string, language: "en" | "nb" = "en", values: Record<string, string | number> = {}) =>
|
||||
Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), (translations[language] as Record<string, string>)[key]);
|
||||
const row = (jobTitle = "Supported role") => ({ jobTitle, company: { name: "Ærlig AS", location: "Oslo", source: "Manual" } });
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function selectedFile(value: unknown, text?: () => Promise<string>) {
|
||||
const file = new File([JSON.stringify(value)], "jobs.json", { type: "application/json" });
|
||||
Object.defineProperty(file, "text", { value: text ?? jest.fn().mockResolvedValue(JSON.stringify(value)) });
|
||||
return file;
|
||||
}
|
||||
|
||||
function view() {
|
||||
function Harness() {
|
||||
const [shown, setShown] = useState(true);
|
||||
return <><button onClick={() => setShown(value => !value)}>Toggle import panel</button>{shown ? <ImportExportJobs /> : null}</>;
|
||||
}
|
||||
return render(<ToastProvider><I18nProvider><Harness /></I18nProvider></ToastProvider>);
|
||||
}
|
||||
|
||||
function input() { return document.querySelector<HTMLInputElement>('input[accept="application/json"]')!; }
|
||||
async function select(file: File) { await act(async () => { fireEvent.change(input(), { target: { files: [file] } }); }); }
|
||||
function importGroup(language: "en" | "nb" = "en") { return screen.getByRole("group", { name: tr("importExportTitle", language) }); }
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks(); mockedApi.post.mockReset(); mockedApi.get.mockReset();
|
||||
window.localStorage.clear(); currentContext.mockReturnValue("owner-a-session"); invalidated.mockReturnValue(false);
|
||||
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ status: url === "/companies" ? 200 : 201, data: { id: url === "/companies" ? 31 : 44 } }));
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it("[P7-09 import] app PascalCase export recreates every supported create field without owner, history or attachment state", async () => {
|
||||
view();
|
||||
await select(selectedFile([{
|
||||
Id: 99, OwnerUserId: "other-owner", CompanyId: 80, SavedAt: "2020-01-01T00:00:00Z",
|
||||
Company: { Id: 80, OwnerUserId: "other-owner", Name: "Ærlig AS", Location: "Oslo", Source: "Manual", RecruiterEmail: "private@example.test", Jobs: [] },
|
||||
JobTitle: "Exported role", Status: "Custom recorded stage", Location: "Remote", Salary: " negotiable ",
|
||||
SalaryMin: 0, SalaryMax: 850000.5, SalaryCurrency: " nok ", SalaryPeriod: "legacy-period",
|
||||
NextAction: "Contact recruiter", FollowUpAt: "2026-10-20T09:15:00.1234567Z", Notes: "Preserve\nnotes", Description: "Original description",
|
||||
TranslatedDescription: "Oversatt tekst", DescriptionLanguage: "nb", Tags: '["TypeScript"]', Deadline: "2026-11-01",
|
||||
CoverLetterText: "Existing cover letter", JobUrl: "https://example.test/jobs/4", DateApplied: "2026-10-01T12:30:00+02:00",
|
||||
FeedbackRequestedAt: "2026-10-02T09:00:00", Source: "manual", CountryCode: "NO",
|
||||
ResponseReceived: true, ResponseDate: "2026-10-03", HasResume: true, HasCoverLetter: true, IsDeleted: true, DeletedAt: "2026-10-04",
|
||||
Messages: [{ Id: 1 }], Events: [{ Type: "Submitted" }], Attachments: [{ Id: 2 }], TailoredCvDraft: { Id: 3 }, Job: { Id: 4 },
|
||||
}]));
|
||||
expect(mockedApi.post.mock.calls).toEqual([
|
||||
["/companies", { name: "Ærlig AS", location: "Oslo", source: "Manual" }],
|
||||
["/jobapplications", {
|
||||
jobTitle: "Exported role", companyId: 31, status: "Custom recorded stage", location: "Remote", salary: " negotiable ",
|
||||
salaryMin: 0, salaryMax: 850000.5, salaryCurrency: " nok ", salaryPeriod: "legacy-period", nextAction: "Contact recruiter",
|
||||
followUpAt: "2026-10-20T09:15:00.1234567Z", notes: "Preserve\nnotes", description: "Original description", translatedDescription: "Oversatt tekst",
|
||||
descriptionLanguage: "nb", tags: '["TypeScript"]', deadline: "2026-11-01", coverLetterText: "Existing cover letter",
|
||||
jobUrl: "https://example.test/jobs/4", dateApplied: "2026-10-01T12:30:00+02:00", feedbackRequestedAt: "2026-10-02T09:00:00", source: "manual", countryCode: "NO",
|
||||
}],
|
||||
]);
|
||||
expect(await within(importGroup()).findByText(tr("lastImport", "en", { count: 1 }))).toBeVisible();
|
||||
expect(within(importGroup()).getByText(tr("jobImportScope"))).toBeVisible();
|
||||
});
|
||||
|
||||
it("[P7-09 import] camelCase nulls and equivalent known aliases remain explicit while server normalization retains authority", async () => {
|
||||
view();
|
||||
await select(selectedFile([{
|
||||
...row("Camel role"), JobTitle: "Camel role", Company: { Name: "Ærlig AS", Location: "Oslo", Source: "Manual", Id: 777 },
|
||||
status: "unknown-custom-stage", dateApplied: null, DateApplied: null, salary: null, salaryMin: -1, salaryMax: 0,
|
||||
salaryCurrency: null, salaryPeriod: "fortnight", notes: "", coverLetterText: null, followUpAt: null,
|
||||
}]));
|
||||
expect(mockedApi.post.mock.calls).toEqual([
|
||||
["/companies", { name: "Ærlig AS", location: "Oslo", source: "Manual" }],
|
||||
["/jobapplications", { jobTitle: "Camel role", companyId: 31, status: "unknown-custom-stage", dateApplied: null, salary: null,
|
||||
salaryMin: -1, salaryMax: 0, salaryCurrency: null, salaryPeriod: "fortnight", notes: "", coverLetterText: null, followUpAt: null }],
|
||||
]);
|
||||
expect(await within(importGroup()).findByText(tr("lastImport", "en", { count: 1 }))).toBeVisible();
|
||||
});
|
||||
|
||||
it("[P7-09 import] validates the complete array before writing any earlier valid row, including conflicting null aliases", async () => {
|
||||
view();
|
||||
const invalidRows = [null, [], { ...row(), jobTitle: " " }, { ...row(), company: "Company name" }, { ...row(), company: { name: null } },
|
||||
{ ...row(), salaryMin: "800000" }, { ...row(), notes: { text: "invalid" } }, { ...row(), dateApplied: "2026-02-30" },
|
||||
{ ...row(), followUpAt: "not-a-date" }, { ...row(), dateApplied: null, DateApplied: "2026-10-01" },
|
||||
{ ...row(), Company: { Name: "Different company" } }];
|
||||
for (const bad of invalidRows) {
|
||||
await select(selectedFile([row("Earlier valid row"), bad]));
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(within(importGroup()).getByRole("alert")).toHaveTextContent(tr("jobImportInvalidRow", "en", { row: 2 }));
|
||||
expect(screen.getByRole("button", { name: tr("importJson") })).toBeEnabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("[P7-09 import] partial failure reports only confirmed jobs and the stopped row without automatic replay, including after locale changes", async () => {
|
||||
let jobWrites = 0;
|
||||
mockedApi.post.mockImplementation((url: string) => url === "/companies" ? Promise.resolve({ status: 200, data: { id: 31 } })
|
||||
: ++jobWrites === 2 ? Promise.reject({ response: { status: 503, data: {} } }) : Promise.resolve({ status: 201, data: { id: 44 } }));
|
||||
view(); await select(selectedFile([row("First role"), row("Unconfirmed second role"), row("Not started role")]));
|
||||
expect(mockedApi.post.mock.calls.map(([url]) => url)).toEqual(["/companies", "/jobapplications", "/companies", "/jobapplications"]);
|
||||
expect(within(importGroup()).getByRole("alert")).toHaveTextContent(tr("jobImportStopped", "en", { row: 2, count: 1, total: 3 }));
|
||||
expect(screen.queryByText(tr("importFailedJson"))).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("alert").filter(alert => alert.textContent?.includes(tr("jobImportStopped", "en", { row: 2, count: 1, total: 3 })))).toHaveLength(2);
|
||||
expect(within(importGroup()).getByRole("alert")).toHaveTextContent(tr("jobImportInspectBeforeRetry"));
|
||||
expect(within(importGroup()).getByText(tr("lastImport", "en", { count: 1 }))).toBeVisible();
|
||||
expect(within(importGroup()).queryByRole("button", { name: tr("retry") })).not.toBeInTheDocument();
|
||||
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
|
||||
expect(within(importGroup("nb")).getByRole("alert")).toHaveTextContent(tr("jobImportStopped", "nb", { row: 2, count: 1, total: 3 }));
|
||||
expect(within(importGroup("nb")).getByRole("alert")).toHaveTextContent(tr("jobImportInspectBeforeRetry", "nb"));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(4); expect(mockedApi.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("[P7-09 import] synchronously reserves one batch during file parsing and reports current-locale confirmed progress", async () => {
|
||||
const text = deferred<string>(); const saved = deferred<any>(); const firstRead = jest.fn(() => text.promise); const secondRead = jest.fn().mockResolvedValue(JSON.stringify([row("Duplicate role")]));
|
||||
mockedApi.post.mockImplementation((url: string) => url === "/companies" ? Promise.resolve({ data: { id: 31 } }) : saved.promise);
|
||||
view();
|
||||
const first = selectedFile([], firstRead); const second = selectedFile([], secondRead);
|
||||
act(() => { fireEvent.change(input(), { target: { files: [first] } }); fireEvent.change(input(), { target: { files: [second] } }); });
|
||||
expect(firstRead).toHaveBeenCalledTimes(1); expect(secondRead).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: tr("profileUploading") })).toBeDisabled();
|
||||
expect(importGroup()).toHaveAttribute("aria-busy", "true");
|
||||
await act(async () => text.resolve(JSON.stringify([row("Held role")])));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(2);
|
||||
expect(within(importGroup()).getByRole("status")).toHaveTextContent(tr("jobImportProgress", "en", { count: 0, total: 1 }));
|
||||
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
|
||||
expect(within(importGroup("nb")).getByRole("status")).toHaveTextContent(tr("jobImportProgress", "nb", { count: 0, total: 1 }));
|
||||
await act(async () => saved.resolve({ status: 201, data: { id: 44 } }));
|
||||
expect(within(importGroup("nb")).getByText(tr("lastImport", "nb", { count: 1 }))).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: tr("importJson", "nb") })).toBeEnabled();
|
||||
expect(importGroup("nb")).toHaveAttribute("aria-busy", "false");
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("[P7-09 import] unmount during file reading stops the batch and a fresh panel owns its own completion", async () => {
|
||||
const held = deferred<string>(); view(); await select(selectedFile([], () => held.promise));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Toggle import panel" }));
|
||||
await act(async () => held.resolve(JSON.stringify([row("Disposed role")])));
|
||||
expect(mockedApi.post).not.toHaveBeenCalled(); expect(screen.queryByText(tr("importedJobs", "en", { count: 1 }))).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Toggle import panel" }));
|
||||
await select(selectedFile([row("Current role")]));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.post.mock.calls[1][1]).toEqual({ jobTitle: "Current role", companyId: 31 });
|
||||
expect(within(importGroup()).getByText(tr("lastImport", "en", { count: 1 }))).toBeVisible();
|
||||
});
|
||||
|
||||
it("[P7-09 import] an account boundary after a held company request cannot create a job using the next account session", async () => {
|
||||
const company = deferred<any>(); mockedApi.post.mockReturnValue(company.promise);
|
||||
view(); await select(selectedFile([row("Old account role"), row("Never started role")]));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
currentContext.mockReturnValue("owner-b-session"); invalidated.mockReturnValue(true);
|
||||
act(() => window.dispatchEvent(new Event(SESSION_INVALIDATED_EVENT)));
|
||||
await act(async () => company.resolve({ status: 201, data: { id: 31 } }));
|
||||
expect(mockedApi.post.mock.calls.map(([url]) => url)).toEqual(["/companies"]);
|
||||
expect(screen.queryByText(tr("importedJobs", "en", { count: 2 }))).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(tr("lastImport", "en", { count: 2 }))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("[P7-09 import] malformed or non-array JSON fails before writes and leaves file selection available", async () => {
|
||||
view();
|
||||
for (const content of ["{broken", '{"jobs":[]}', "null"]) {
|
||||
await select(selectedFile([], () => Promise.resolve(content)));
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(within(importGroup()).getByRole("alert")).toHaveTextContent(tr("jobImportInvalidFile"));
|
||||
expect(screen.getByRole("button", { name: tr("importJson") })).toBeEnabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("[P7-09 import] salaries outside the decimal create contract reject the whole array without rejecting supported numeric boundaries", async () => {
|
||||
view();
|
||||
// These serialized Number values were rejected by the installed System.Text.Json9 decimal converter.
|
||||
for (const salary of [1e100, -1e100, 2 ** 96, -(2 ** 96)]) {
|
||||
await select(selectedFile([row("Earlier valid role"), { ...row("Overflowing salary"), salaryMin: salary }]));
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(within(importGroup()).getByRole("alert")).toHaveTextContent(tr("jobImportInvalidRow", "en", { row: 2 }));
|
||||
}
|
||||
await select(selectedFile([{ ...row("Supported decimal bounds"), salaryMin: -1, salaryMax: 7.922816251426433e28 }]));
|
||||
expect(mockedApi.post.mock.calls[1][1]).toEqual({ jobTitle: "Supported decimal bounds", companyId: 31, salaryMin: -1, salaryMax: 7.922816251426433e28 });
|
||||
expect(within(importGroup()).getByText(tr("lastImport", "en", { count: 1 }))).toBeVisible();
|
||||
});
|
||||
|
||||
it("[P7-09 import] dates rejected by the DateTime converter cannot leave an earlier row imported and supported dates stay unchanged", async () => {
|
||||
view();
|
||||
const rejected = ["2026-10-17T09:15:00+15:00", "2026-10-17T09:15:00-14:01", "2026-10-17T09:15:00.12345678901234567Z",
|
||||
"0001-01-01T00:00:00+14:00", "9999-12-31T23:59:59-14:00"];
|
||||
for (const date of rejected) {
|
||||
await select(selectedFile([row("Earlier valid role"), { ...row("Unsupported date"), dateApplied: date }]));
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(within(importGroup()).getByRole("alert")).toHaveTextContent(tr("jobImportInvalidRow", "en", { row: 2 }));
|
||||
}
|
||||
await select(selectedFile([{ ...row("Supported DateTime boundaries"), dateApplied: "0001-01-01T00:00:00Z", followUpAt: "9999-12-31T23:59:59Z",
|
||||
deadline: "2026-10-17T09:15+14:00", feedbackRequestedAt: "2026-10-17T09:15:00.1234567890123456Z" },
|
||||
{ ...row("Unspecified DateTime boundaries"), dateApplied: "0001-01-01T00:00", followUpAt: "9999-12-31T23:59" }]));
|
||||
expect(mockedApi.post.mock.calls[1][1]).toEqual({ jobTitle: "Supported DateTime boundaries", companyId: 31, dateApplied: "0001-01-01T00:00:00Z",
|
||||
followUpAt: "9999-12-31T23:59:59Z", deadline: "2026-10-17T09:15+14:00", feedbackRequestedAt: "2026-10-17T09:15:00.1234567890123456Z" });
|
||||
expect(mockedApi.post.mock.calls[3][1]).toEqual({ jobTitle: "Unspecified DateTime boundaries", companyId: 31,
|
||||
dateApplied: "0001-01-01T00:00", followUpAt: "9999-12-31T23:59" });
|
||||
expect(within(importGroup()).getByText(tr("lastImport", "en", { count: 2 }))).toBeVisible();
|
||||
});
|
||||
|
||||
it("[P7-09 import] CSV conversion keeps quoted cells, legacy mapping and generated JSON without server writes", async () => {
|
||||
const created = jest.fn<string, [Blob]>(() => "blob:converted-jobs");
|
||||
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: created });
|
||||
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: jest.fn() });
|
||||
let filename = ""; jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { filename = this.download; });
|
||||
view();
|
||||
const csv = new File([], "jobs.csv", { type: "text/csv" });
|
||||
Object.defineProperty(csv, "text", { value: () => Promise.resolve('Job Title,Company,Location,Source,Date Applied,Status,Notes\n"Senior, engineer",Ærlig AS,Oslo,Manual,3/2/2025,Follow-up Needed,"Line one\nLine two"') });
|
||||
await act(async () => fireEvent.change(document.querySelector('input[accept=".csv,text/csv"]')!, { target: { files: [csv] } }));
|
||||
await waitFor(() => expect(created).toHaveBeenCalledTimes(1));
|
||||
const text = await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsText(created.mock.calls[0][0]); });
|
||||
expect(JSON.parse(text)).toEqual([{ jobTitle: "Senior, engineer", status: "Waiting", dateApplied: "2025-02-03", responseReceived: false,
|
||||
notes: "Line one\nLine two", daysSince: 0, company: { name: "Ærlig AS", location: "Oslo", source: "Manual" }, location: "Oslo", nextAction: "Follow up", isDeleted: false }]);
|
||||
expect(filename).toMatch(/^job-tracker-import-\d{4}-\d{2}-\d{2}\.json$/);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.get).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
import SettingsView from "./components/SettingsView";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import { ToastProvider } from "./toast";
|
||||
import { PromptProvider } from "./prompt";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: { get: jest.fn(), post: jest.fn(), put: jest.fn() },
|
||||
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
|
||||
}));
|
||||
jest.mock("./sessionBoundary", () => ({
|
||||
SESSION_INVALIDATED_EVENT: "session-invalidated",
|
||||
checkSessionCookie: jest.fn(),
|
||||
getSessionContext: jest.fn(() => "owner-a-session"),
|
||||
isSessionInvalidated: jest.fn(() => false),
|
||||
}));
|
||||
jest.mock("./components/AiUsageCard", () => () => <div>Usage auxiliary owner</div>);
|
||||
jest.mock("./components/QuickCaptureCard", () => () => <div>Capture auxiliary owner</div>);
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const savedRules = { id: 1, appliedFollowUpDays: 14, appliedGhostDays: 30, offerFollowUpDays: 7, offerGhostDays: 14, feedbackFollowUpDays: 7, feedbackGhostDays: 14 };
|
||||
const savedPrivacy = { enabled: true, externalProcessingAllowed: false, externalProcessingAvailable: false, effectiveExternalProcessing: false, provider: "local" };
|
||||
const reads = (path: string) => mockedApi.get.mock.calls.filter(([url]) => url === path);
|
||||
const text = (key: "lastImport" | "importedJobs", count: number) => translations.en[key].replace("{count}", String(count));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(yes => { resolve = yes; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function file(rows: unknown[]) {
|
||||
const contents = JSON.stringify(rows);
|
||||
const selected = new File([contents], "jobs.json", { type: "application/json" });
|
||||
Object.defineProperty(selected, "text", { value: jest.fn().mockResolvedValue(contents) });
|
||||
return selected;
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
return render(<MemoryRouter><ToastProvider><I18nProvider><PromptProvider>
|
||||
<SettingsView pageSize={20} onPageSizeChange={jest.fn()}
|
||||
columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={jest.fn()}
|
||||
themeMode="dark" onThemeModeChange={jest.fn()} />
|
||||
</PromptProvider></I18nProvider></ToastProvider></MemoryRouter>);
|
||||
}
|
||||
|
||||
async function selectFile(rows: unknown[]) {
|
||||
const input = document.querySelector<HTMLInputElement>('input[accept="application/json"]');
|
||||
expect(input).not.toBeNull();
|
||||
await act(async () => { fireEvent.change(input!, { target: { files: [file(rows)] } }); });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks(); mockedApi.get.mockReset(); mockedApi.post.mockReset(); mockedApi.put.mockReset();
|
||||
window.localStorage.clear();
|
||||
mockedApi.get.mockImplementation((path: string) => {
|
||||
if (path === "/ai/settings") return Promise.resolve({ data: savedPrivacy } as any);
|
||||
if (path === "/notification-settings") return Promise.resolve({ data: { emailFollowUpRemindersEnabled: true } } as any);
|
||||
if (path === "/rules") return Promise.resolve({ data: savedRules } as any);
|
||||
if (path === "/account-lifecycle/status") return Promise.resolve({ data: { deletionEnabled: false, deletionStatus: "active", requiredConfirmation: "DELETE owner@example.test", request: null } } as any);
|
||||
return Promise.reject(new Error(`Unexpected GET ${path}`));
|
||||
});
|
||||
mockedApi.put.mockResolvedValue({ data: {} } as any);
|
||||
});
|
||||
|
||||
test("[P7-09 lifetime] an accepted import continues once across Notifications and Rules while a real Settings unmount stops queued writes", async () => {
|
||||
const company = deferred<any>();
|
||||
let companies = 0;
|
||||
mockedApi.post.mockImplementation((path: string) => {
|
||||
if (path === "/companies") {
|
||||
companies += 1;
|
||||
return companies === 1 ? company.promise : Promise.resolve({ data: { id: 32 } } as any);
|
||||
}
|
||||
if (path === "/jobapplications") return Promise.resolve({ data: { id: 100 + companies } } as any);
|
||||
return Promise.reject(new Error(`Unexpected POST ${path}`));
|
||||
});
|
||||
const mounted = renderSettings();
|
||||
await screen.findByRole("switch", { name: translations.en.settingsAiEnabled });
|
||||
await selectFile([
|
||||
{ jobTitle: "First preserved role", company: { name: "Ærlig AS" } },
|
||||
{ jobTitle: "Second preserved role", company: { name: "Neste AS" } },
|
||||
]);
|
||||
expect(mockedApi.post.mock.calls).toEqual([["/companies", { name: "Ærlig AS" }]]);
|
||||
expect(screen.getByRole("group", { name: translations.en.importExportTitle })).toHaveAttribute("aria-busy", "true");
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.settingsTabNotifications }));
|
||||
expect(screen.getByRole("tabpanel", { name: translations.en.settingsTabNotifications })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: translations.en.importJson })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.settingsTabFollowUps }));
|
||||
const rule = await screen.findByRole("spinbutton", { name: translations.en.rulesAppliedFollowUpDays });
|
||||
fireEvent.change(rule, { target: { value: "5" } });
|
||||
await act(async () => company.resolve({ data: { id: 31 } }));
|
||||
await waitFor(() => expect(mockedApi.post.mock.calls).toEqual([
|
||||
["/companies", { name: "Ærlig AS" }],
|
||||
["/jobapplications", { jobTitle: "First preserved role", companyId: 31 }],
|
||||
["/companies", { name: "Neste AS" }],
|
||||
["/jobapplications", { jobTitle: "Second preserved role", companyId: 32 }],
|
||||
]));
|
||||
expect(await screen.findByText(text("importedJobs", 2))).toBeVisible();
|
||||
expect(rule).toHaveValue(5);
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.settingsTabGeneral }));
|
||||
const group = screen.getByRole("group", { name: translations.en.importExportTitle });
|
||||
expect(within(group).getByRole("status")).toHaveTextContent(text("lastImport", 2));
|
||||
expect(within(group).getByRole("button", { name: translations.en.importJson })).toBeEnabled();
|
||||
expect(group).toHaveAttribute("aria-busy", "false");
|
||||
expect(reads("/ai/settings")).toHaveLength(1);
|
||||
expect(reads("/account-lifecycle/status")).toHaveLength(0);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
|
||||
const disposedCompany = deferred<any>(); mockedApi.post.mockReturnValueOnce(disposedCompany.promise);
|
||||
await selectFile([{ jobTitle: "Must stop after leaving Settings", company: { name: "Disposed AS" } }]);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(5);
|
||||
mounted.unmount();
|
||||
await act(async () => disposedCompany.resolve({ data: { id: 33 } }));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
test("[P7-09 lifetime] the General privacy owner keeps its draft and read across internal tabs while Backup remains lazy and disposable", async () => {
|
||||
const mounted = renderSettings();
|
||||
const privacy = await screen.findByRole("switch", { name: translations.en.settingsAiEnabled });
|
||||
expect(privacy).toBeChecked(); fireEvent.click(privacy); expect(privacy).not.toBeChecked();
|
||||
expect(reads("/ai/settings")).toHaveLength(1); expect(reads("/account-lifecycle/status")).toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.settingsTabNotifications }));
|
||||
expect(privacy).toBeInTheDocument(); expect(privacy).not.toBeVisible();
|
||||
expect(privacy.closest('[role="tabpanel"]')).toHaveAttribute("hidden");
|
||||
expect(screen.queryByRole("switch", { name: translations.en.settingsAiEnabled })).not.toBeInTheDocument();
|
||||
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabFollowUps }));
|
||||
await screen.findByRole("spinbutton", { name: translations.nb.rulesAppliedFollowUpDays });
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabGeneral }));
|
||||
expect(screen.getByRole("switch", { name: translations.nb.settingsAiEnabled })).not.toBeChecked();
|
||||
expect(screen.getByRole("switch", { name: translations.nb.settingsAiExternalAllowed })).toBeDisabled();
|
||||
expect(reads("/ai/settings")).toHaveLength(1); expect(reads("/account-lifecycle/status")).toHaveLength(0);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabBackup }));
|
||||
await screen.findByText(translations.nb.accountDeleteDisabled);
|
||||
expect(reads("/account-lifecycle/status")).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabGeneral }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabBackup }));
|
||||
await waitFor(() => expect(reads("/account-lifecycle/status")).toHaveLength(2));
|
||||
expect(reads("/ai/settings")).toHaveLength(1); expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
mounted.unmount(); renderSettings();
|
||||
expect(await screen.findByRole("switch", { name: translations.nb.settingsAiEnabled })).toBeChecked();
|
||||
expect(reads("/ai/settings")).toHaveLength(2);
|
||||
expect(reads("/account-lifecycle/status")).toHaveLength(2);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Exercise the actual workflow's narrow corruption guard, without starting CI."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
WORKFLOW = Path(__file__).resolve().parents[1] / '.gitea/workflows/ci-deploy.yml'
|
||||
BASH = str(Path('C:/Program Files/Git/bin/bash.exe')) if os.name == 'nt' else shutil.which('bash')
|
||||
|
||||
|
||||
class BrowserRuntimeRetryTests(unittest.TestCase):
|
||||
def permits_retry(self, log):
|
||||
text = WORKFLOW.read_text(encoding='utf-8')
|
||||
end = text.index(' echo "Browser runtime source appears corrupted')
|
||||
start = text.rfind('\n if ', 0, end) + 1
|
||||
guard = text[start:end]
|
||||
self.assertTrue(guard.strip().startswith('if '))
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
fixture = Path(folder) / 'smoke.log'
|
||||
fixture.write_text(log, encoding='utf-8')
|
||||
result = subprocess.run([BASH, '-c', guard + '\nexit 0\nelse\nexit 1\nfi'],
|
||||
env={**os.environ, 'smoke_log': fixture.as_posix()}, capture_output=True, text=True)
|
||||
self.assertIn(result.returncode, [0, 1], result.stderr)
|
||||
return result.returncode == 0
|
||||
|
||||
def test_captured_playwright_source_corruption(self):
|
||||
self.assertTrue(self.permits_retry('/workspace/node_modules/playwright-core/lib/utilsBundle.js:35841\n'
|
||||
'const { items }$= this;\nSyntaxError: Missing initializer in destructuring declaration'))
|
||||
|
||||
def test_existing_axe_source_corruption(self):
|
||||
self.assertTrue(self.permits_retry('AxeBuilder.analyze\npage.evaluate: ReferenceError: invalid runtime source'))
|
||||
|
||||
def test_application_assertion_stays_terminal(self):
|
||||
self.assertFalse(self.permits_retry('expect(region).not.toBeVisible() failed'))
|
||||
|
||||
def test_browser_crash_alone_stays_terminal(self):
|
||||
self.assertFalse(self.permits_retry('Error: page.evaluate: Target crashed'))
|
||||
|
||||
def test_user_javascript_syntax_error_stays_terminal(self):
|
||||
self.assertFalse(self.permits_retry('src/example.js:1\nconst { items }$= this;\nSyntaxError: Missing initializer in destructuring declaration'))
|
||||
|
||||
def test_incomplete_signature_stays_terminal(self):
|
||||
self.assertFalse(self.permits_retry('node_modules/playwright-core/lib/utilsBundle.js:35841\nSyntaxError: Missing initializer in destructuring declaration'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -646,6 +646,8 @@ def _ollama_generate(prompt: str, *, json_mode: bool, temperature: float, timeou
|
||||
"stream": False,
|
||||
"options": {"temperature": temperature},
|
||||
}
|
||||
if OLLAMA_MODEL.split(":", 1)[0] == "qwen3.5":
|
||||
payload["think"] = False
|
||||
if json_mode:
|
||||
payload["format"] = "json"
|
||||
body = _http_post_json(f"{OLLAMA_BASE_URL}/api/generate", payload, {}, timeout)
|
||||
|
||||
@@ -8,6 +8,7 @@ import textwrap
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from docx import Document
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
@@ -253,6 +254,55 @@ def test_provider_defaults_to_ollama_and_is_unchanged(monkeypatch):
|
||||
assert captured["body"]["options"]["temperature"] == 0.1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ollama_model", ["qwen3.5:9b-q4_K_M", "qwen3.5"])
|
||||
@pytest.mark.parametrize("json_mode", [False, True])
|
||||
def test_qwen35_requests_nonthinking_output_without_consuming_thinking(monkeypatch, ollama_model, json_mode):
|
||||
module = load_app_module(monkeypatch, ollama_model=ollama_model)
|
||||
captured = {}
|
||||
output = '{"score": 7}' if json_mode else "Rewritten CV text"
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["body"] = json.loads(req.data.decode("utf-8"))
|
||||
captured["timeout"] = timeout
|
||||
captured["url"] = req.full_url
|
||||
return _FakeResponse({
|
||||
"response": output if captured["body"].get("think") is False else "",
|
||||
"thinking": "Private reasoning must never become candidate text.",
|
||||
})
|
||||
|
||||
monkeypatch.setattr(module.urllib_request, "urlopen", fake_urlopen)
|
||||
result = module._ollama_generate_json("synthetic prompt") if json_mode else module._ollama_generate_text("synthetic prompt")
|
||||
|
||||
assert result == ({"score": 7} if json_mode else output)
|
||||
expected = {
|
||||
"model": ollama_model,
|
||||
"prompt": "synthetic prompt",
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.1 if json_mode else 0.2},
|
||||
"think": False,
|
||||
}
|
||||
if json_mode:
|
||||
expected["format"] = "json"
|
||||
assert captured["body"] == expected
|
||||
assert captured["url"] == f"{module.OLLAMA_BASE_URL}/api/generate"
|
||||
assert captured["timeout"] == (120 if json_mode else 180)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ollama_model", ["qwen2.5:7b", "qwen3:8b", "qwen3.5-plus:9b"])
|
||||
def test_other_ollama_model_payloads_remain_unchanged(monkeypatch, ollama_model):
|
||||
module = load_app_module(monkeypatch, ollama_model=ollama_model)
|
||||
captured = {}
|
||||
_install_fake_urlopen(monkeypatch, module, {"response": " Candidate text ", "thinking": "Reasoning"}, captured)
|
||||
|
||||
assert module._ollama_generate_text("synthetic prompt") == "Candidate text"
|
||||
assert captured["body"] == {
|
||||
"model": ollama_model,
|
||||
"prompt": "synthetic prompt",
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.2},
|
||||
}
|
||||
|
||||
|
||||
def test_local_success_wins_even_when_external_fallback_is_permitted(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
|
||||
Reference in New Issue
Block a user