fix: preserve large Unicode drafts with bounded keepalive transport
This commit is contained in:
@@ -2163,3 +2163,14 @@ O editor invalida a prévia/corpo de confirmação e consome os parâmetros de e
|
||||
Dois casos novos: um reproduziu a lacuna no código anterior e o caso de gravação recusada já passava. Na candidata passaram ambos e os treze casos existentes, total de quinze testes frontend dirigidos. Verificados consulta repetida sem regravação, prévia consumida, conservação do hash do URL, ausência de envio do rascunho antigo em pagehide, valores recuperados e uso da nova versão na comparação seguinte. Tipos, lint, formatação e compilação QA passaram. Provas aceites reutilizadas; recibo/hashes em `context_saved_recovery_increment` de `local-validation-20261029.json`.
|
||||
|
||||
**Visual continua pendente pela equipa:** na candidata QA, conferir gravação normal e recuperação após falha apenas da leitura, verificando mensagem, bloqueio temporário e recuperação sem nova gravação; distinguir de gravação recusada. Usar ensaio de falha controlado em QA, sem criar dados sintéticos no activo. Registar revisão, perfil, percurso, esperado/observado e evidência nesta matriz. Sem instalação ou avanço de main; o requisito transversal permanece parcial.
|
||||
|
||||
|
||||
### 29/10/2026 — rascunhos: transporte por bytes, concorrência e contexto extenso
|
||||
|
||||
Continuação sobre `fa2ece10`, nos requisitos existentes V2-07/V2-10 e na orientação adaptativa da Tempo de 23/10. A escolha de keepalive usava caracteres em vez de bytes UTF-8; pedidos concorrentes não partilhavam orçamento local; ao sair, um rascunho com 60000 caracteres ou mais era simplesmente ignorado. Três regressões reproduzidas contra o código anterior: texto multibyte, concorrência e saída com cem oportunidades.
|
||||
|
||||
O transporte existente serializa uma vez, conserva o corpo integral e decide por bytes UTF-8, incluindo os pedidos keepalive de contexto ainda em curso. A reserva é libertada em sucesso ou erro. O envio automático e a saída delegam essa decisão no transporte; corpos maiores seguem pelo fetch normal, sem truncagem nem repetição automática de gravações. Os 60000 bytes são uma margem técnica local abaixo dos 64KiB agregados previstos na [especificação Fetch](https://fetch.spec.whatwg.org/#http-network-or-cache-fetch), não um novo limite funcional do dossier. Autenticação, métodos e tratamento dos conflitos HTTP mantidos.
|
||||
|
||||
Cinco testes novos e quinze existentes passaram, total de vinte testes frontend dirigidos. Cobertura: corpo Unicode integral, caracteres versus bytes, concorrência/libertação de reserva, falha sem repetição de escrita, GET/POST/CSRF/conflito e rascunho de cem oportunidades entregue integralmente ao transporte ao sair. Tipos, lint, formatação e compilação QA passaram; provas históricas aceites reutilizadas. Recibo/hashes em `context_draft_transport_increment` de `local-validation-20261029.json`.
|
||||
|
||||
**Limite da prova:** fetch normal após a saída continua a ser tentativa de melhor esforço; o orçamento local não reserva capacidade usada por outro código no navegador. Não se declara gravação garantida ao fechar imediatamente a página. Mantêm-se o envio automático normal, o estado do rascunho e a tentativa explícita após falha. Conferência visual pela equipa permanece pendente: na candidata QA, preparar texto acentuado e selecção extensa, aguardar «Rascunho guardado», sair/regressar e conferir valores e oportunidades recuperados. Registar revisão, perfil, percurso e evidência nesta matriz. Sem instalação, avanço de main ou escrita no activo; backend, schema, permissões e cálculo preservados.
|
||||
|
||||
@@ -1376,5 +1376,63 @@
|
||||
"preservation": "Frontend recovery only. Backend confirmation/idempotency, data/schema, permissions and fiscal rules unchanged. No active writes or deployment. Historical accepted suites reused.",
|
||||
"visual_acceptance": "Pending team execution and recording; mocked asynchronous tests do not constitute browser-session acceptance.",
|
||||
"installation": "Not triggered; main and active release unchanged."
|
||||
},
|
||||
"context_draft_transport_increment": {
|
||||
"recorded_at": "2026-10-29T21:08:08.809170+00:00",
|
||||
"base_commit": "fa2ece10deb0570b50e3ae9bdc8349a54f8bf055",
|
||||
"requirements": [
|
||||
"V2-07",
|
||||
"V2-10",
|
||||
"Tempo adaptive journey decision 2026-10-23: retain choices on refresh and navigation"
|
||||
],
|
||||
"defects": [
|
||||
"Character count was used instead of UTF-8 byte length when selecting keepalive.",
|
||||
"Concurrent context keepalive requests did not share a local byte budget.",
|
||||
"Page exit silently skipped drafts at or above 60000 characters."
|
||||
],
|
||||
"behavior": "Serialise once and preserve the entire body; use keepalive only when UTF-8 body plus contextApi keepalive bytes in flight is below 60000. Larger/concurrent requests fall back to ordinary fetch without truncation or automatic write retry. Release reservation on success/error. Autosave and pagehide delegate transport eligibility instead of dropping large drafts.",
|
||||
"technical_basis": {
|
||||
"source": "https://fetch.spec.whatwg.org/#http-network-or-cache-fetch",
|
||||
"rule": "Fetch limits aggregate in-flight keepalive request bodies to 64 KiB; 60000-byte local headroom is an implementation choice, not a business rule."
|
||||
},
|
||||
"checks": {
|
||||
"previous_revision_transport": {
|
||||
"passed": 2,
|
||||
"failed": 2
|
||||
},
|
||||
"previous_revision_large_pagehide": {
|
||||
"passed": 0,
|
||||
"failed": 1
|
||||
},
|
||||
"directed_frontend_passed": 20,
|
||||
"new_frontend_cases": 5,
|
||||
"existing_cases_reused": 15,
|
||||
"types": "passed",
|
||||
"lint": "passed",
|
||||
"format": "passed",
|
||||
"qa_build": "passed"
|
||||
},
|
||||
"source_sha256": {
|
||||
"resources/js/lib/investor-context.ts": "ee225034e65caf3547a82aef55f1cef23c2984bb430e375742317754313c1996",
|
||||
"resources/js/pages/investor-path/context.tsx": "43ec8ee55056f1f014fd4ac5d1da2febeb77d565c6baeef89f07d677029af1a7",
|
||||
"tests/frontend/investor-context-api.test.mjs": "c12037c6284762a01a0971010aed9d4df9866aa61b01ddb2be29b3b156e8684d",
|
||||
"tests/frontend/investor-usability-components.test.mjs": "cee14ae2c3e4234b8ab76e7bacd8369cb9cbb8a24ca1aa0c672384f4f192ece1"
|
||||
},
|
||||
"evidence": {
|
||||
"outputs/validation-20261029/context-transport-red.log": "ba5b77dccfa91f4ac415c7bf4787c515f6c68e905144e24fdf601e5365e83e10",
|
||||
"outputs/validation-20261029/context-transport-page-red.log": "c138dfd4c38080f9e497965f6715f0dca1fb4bbcd5dc33a9afc909c11b9ca6c7",
|
||||
"outputs/validation-20261029/context-transport-tests.log": "3d787cc079b28fc72f40aa36f96cef8a84e77e59454f66992a3689d2e60914c4",
|
||||
"outputs/validation-20261029/context-transport-types.log": "82962651e47f564de653ad2883f8fd34e7dae4b4468e4ac459cb3331962a3879",
|
||||
"outputs/validation-20261029/context-transport-lint.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"outputs/validation-20261029/context-transport-component-lint.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"outputs/validation-20261029/context-transport-format-check.log": "4a1bea51045c40a97f34189c93babe919544f4e6fce4d89e876255b5e72d9f42",
|
||||
"outputs/validation-20261029/context-transport-component-format-check.log": "b2d21e443253f5427422f71003397c1e188a181902594e82a1435e8a82342dbe",
|
||||
"outputs/validation-20261029/context-transport-build.log": "94ecfec7f5b8eec6ef417466986d9ca88305f7daa3d131f0e8a864664245831a",
|
||||
"outputs/validation-20261029/context-transport-build-exit.json": "124fd50cb6d223cdd18fedc166d00af7254199b6e6dedf9be82f71db87af6d01"
|
||||
},
|
||||
"limits": "Ordinary fetch after page exit remains best-effort. Local accounting cannot reserve browser-wide capacity used by other code. This does not claim guaranteed persistence on immediate tab close; normal autosave and its status/retry remain essential.",
|
||||
"preservation": "No payload truncation, field/rule restrictions, backend/schema/permissions/tax changes or active writes. Authentication, methods and HTTP conflict behavior preserved. Historical accepted suites reused.",
|
||||
"visual_acceptance": "Pending team execution and recording; mocked transport/component tests do not constitute browser acceptance.",
|
||||
"installation": "Not triggered; main and active release unchanged."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7700,3 +7700,14 @@ O editor invalida a prévia/corpo de confirmação e consome os parâmetros de e
|
||||
Dois casos novos: um reproduziu a lacuna no código anterior e o caso de gravação recusada já passava. Na candidata passaram ambos e os treze casos existentes, total de quinze testes frontend dirigidos. Verificados consulta repetida sem regravação, prévia consumida, conservação do hash do URL, ausência de envio do rascunho antigo em pagehide, valores recuperados e uso da nova versão na comparação seguinte. Tipos, lint, formatação e compilação QA passaram. Provas aceites reutilizadas; recibo/hashes em `context_saved_recovery_increment` de `local-validation-20261029.json`.
|
||||
|
||||
**Visual continua pendente pela equipa:** na candidata QA, conferir gravação normal e recuperação após falha apenas da leitura, verificando mensagem, bloqueio temporário e recuperação sem nova gravação; distinguir de gravação recusada. Usar ensaio de falha controlado em QA, sem criar dados sintéticos no activo. Registar revisão, perfil, percurso, esperado/observado e evidência nesta matriz. Sem instalação ou avanço de main; o requisito transversal permanece parcial.
|
||||
|
||||
|
||||
### 29/10/2026 — rascunhos: transporte por bytes, concorrência e contexto extenso
|
||||
|
||||
Continuação sobre `fa2ece10`, nos requisitos existentes V2-07/V2-10 e na orientação adaptativa da Tempo de 23/10. A escolha de keepalive usava caracteres em vez de bytes UTF-8; pedidos concorrentes não partilhavam orçamento local; ao sair, um rascunho com 60000 caracteres ou mais era simplesmente ignorado. Três regressões reproduzidas contra o código anterior: texto multibyte, concorrência e saída com cem oportunidades.
|
||||
|
||||
O transporte existente serializa uma vez, conserva o corpo integral e decide por bytes UTF-8, incluindo os pedidos keepalive de contexto ainda em curso. A reserva é libertada em sucesso ou erro. O envio automático e a saída delegam essa decisão no transporte; corpos maiores seguem pelo fetch normal, sem truncagem nem repetição automática de gravações. Os 60000 bytes são uma margem técnica local abaixo dos 64KiB agregados previstos na [especificação Fetch](https://fetch.spec.whatwg.org/#http-network-or-cache-fetch), não um novo limite funcional do dossier. Autenticação, métodos e tratamento dos conflitos HTTP mantidos.
|
||||
|
||||
Cinco testes novos e quinze existentes passaram, total de vinte testes frontend dirigidos. Cobertura: corpo Unicode integral, caracteres versus bytes, concorrência/libertação de reserva, falha sem repetição de escrita, GET/POST/CSRF/conflito e rascunho de cem oportunidades entregue integralmente ao transporte ao sair. Tipos, lint, formatação e compilação QA passaram; provas históricas aceites reutilizadas. Recibo/hashes em `context_draft_transport_increment` de `local-validation-20261029.json`.
|
||||
|
||||
**Limite da prova:** fetch normal após a saída continua a ser tentativa de melhor esforço; o orçamento local não reserva capacidade usada por outro código no navegador. Não se declara gravação garantida ao fechar imediatamente a página. Mantêm-se o envio automático normal, o estado do rascunho e a tentativa explícita após falha. Conferência visual pela equipa permanece pendente: na candidata QA, preparar texto acentuado e selecção extensa, aguardar «Rascunho guardado», sair/regressar e conferir valores e oportunidades recuperados. Registar revisão, perfil, percurso e evidência nesta matriz. Sem instalação, avanço de main ou escrita no activo; backend, schema, permissões e cálculo preservados.
|
||||
|
||||
@@ -150,34 +150,50 @@ export type ContextPlan = {
|
||||
documents_preserved: number;
|
||||
notice: string;
|
||||
};
|
||||
// Leave headroom below the browser's shared 64 KiB keepalive budget.
|
||||
const contextKeepaliveBudget = 60000;
|
||||
let contextKeepaliveBytes = 0;
|
||||
|
||||
export async function contextApi<T>(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
method = 'POST',
|
||||
keepalive = false,
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
keepalive,
|
||||
method: body === undefined ? 'GET' : method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(body === undefined
|
||||
? {}
|
||||
: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': csrfToken(),
|
||||
}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
data?.message ||
|
||||
`Não foi possível recuperar ou guardar o contexto (HTTP ${response.status}).`,
|
||||
);
|
||||
return data as T;
|
||||
const serializedBody =
|
||||
body === undefined ? undefined : JSON.stringify(body);
|
||||
const bytes = serializedBody
|
||||
? new TextEncoder().encode(serializedBody).byteLength
|
||||
: 0;
|
||||
const useKeepalive =
|
||||
keepalive && contextKeepaliveBytes + bytes < contextKeepaliveBudget;
|
||||
if (useKeepalive) contextKeepaliveBytes += bytes;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
keepalive: useKeepalive,
|
||||
method: body === undefined ? 'GET' : method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(body === undefined
|
||||
? {}
|
||||
: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': csrfToken(),
|
||||
}),
|
||||
},
|
||||
...(serializedBody === undefined ? {} : { body: serializedBody }),
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
data?.message ||
|
||||
`Não foi possível recuperar ou guardar o contexto (HTTP ${response.status}).`,
|
||||
);
|
||||
return data as T;
|
||||
} finally {
|
||||
if (useKeepalive) contextKeepaliveBytes -= bytes;
|
||||
}
|
||||
}
|
||||
export const contextFieldNames: Record<string, string> = {
|
||||
investment_form: 'Tipo do investimento',
|
||||
|
||||
@@ -299,7 +299,7 @@ export default function ContextEditor({
|
||||
draft_sequence: savedSequence,
|
||||
},
|
||||
'POST',
|
||||
draftBody.length < 60000,
|
||||
true,
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
@@ -318,8 +318,7 @@ export default function ContextEditor({
|
||||
const body = latestDraft.current;
|
||||
if (
|
||||
!awaitingSavedContext.current &&
|
||||
body !== confirmedDraftBody.current &&
|
||||
body.length < 60000
|
||||
body !== confirmedDraftBody.current
|
||||
)
|
||||
void contextApi(
|
||||
'/investimentos/' + initial.case.id + '/contexto/rascunho',
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { contextApi } from '../../resources/js/lib/investor-context.ts';
|
||||
|
||||
const success = { ok: true, json: async () => ({ saved: true }) };
|
||||
async function withTransport(fetch, callback) {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.fetch = fetch;
|
||||
globalThis.document = { cookie: 'XSRF-TOKEN=synthetic%20token' };
|
||||
try {
|
||||
await callback();
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
}
|
||||
void test('context requests preserve Unicode payload and authentication while using byte-based keepalive eligibility', async () => {
|
||||
const requests = [];
|
||||
await withTransport(
|
||||
async (_url, options) => {
|
||||
requests.push(options);
|
||||
return success;
|
||||
},
|
||||
async () => {
|
||||
for (const text of [
|
||||
'Preparação de investimento',
|
||||
'ç'.repeat(33000),
|
||||
'a'.repeat(65000),
|
||||
]) {
|
||||
const body = { declarations: { project_summary: text } };
|
||||
assert.deepEqual(
|
||||
await contextApi('/synthetic', body, 'POST', true),
|
||||
{ saved: true },
|
||||
);
|
||||
const request = requests.at(-1);
|
||||
assert.equal(request.body, JSON.stringify(body));
|
||||
assert.equal(
|
||||
request.keepalive,
|
||||
new TextEncoder().encode(request.body).byteLength < 60000,
|
||||
);
|
||||
assert.equal(request.credentials, 'same-origin');
|
||||
assert.equal(
|
||||
request.headers['X-XSRF-TOKEN'],
|
||||
'synthetic token',
|
||||
);
|
||||
}
|
||||
assert.equal(requests[1].body.length < 60000, true);
|
||||
assert.equal(requests[1].keepalive, false);
|
||||
},
|
||||
);
|
||||
});
|
||||
void test('concurrent context requests share the keepalive budget and release it after completion', async () => {
|
||||
const requests = [];
|
||||
let finish;
|
||||
await withTransport(
|
||||
(_url, options) => {
|
||||
requests.push(options);
|
||||
return requests.length === 1
|
||||
? new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
})
|
||||
: Promise.resolve(success);
|
||||
},
|
||||
async () => {
|
||||
const body = { text: 'a'.repeat(40000) };
|
||||
const first = contextApi('/first', body, 'POST', true);
|
||||
await contextApi('/second', body, 'POST', true);
|
||||
assert.deepEqual(
|
||||
requests.map((request) => request.keepalive),
|
||||
[true, false],
|
||||
);
|
||||
finish(success);
|
||||
await first;
|
||||
await contextApi('/third', body, 'POST', true);
|
||||
assert.equal(requests[2].keepalive, true);
|
||||
},
|
||||
);
|
||||
});
|
||||
void test('failed context transport releases the reserved budget without retrying a write', async () => {
|
||||
const requests = [];
|
||||
await withTransport(
|
||||
async (_url, options) => {
|
||||
requests.push(options);
|
||||
if (requests.length === 1)
|
||||
throw new Error('Synthetic transport error');
|
||||
return success;
|
||||
},
|
||||
async () => {
|
||||
const body = { text: 'a'.repeat(40000) };
|
||||
await assert.rejects(
|
||||
contextApi('/first', body, 'POST', true),
|
||||
/Synthetic transport error/,
|
||||
);
|
||||
await contextApi('/second', body, 'POST', true);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(
|
||||
requests.every((request) => request.keepalive),
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
void test('ordinary context writes and reads retain their existing method and error behavior', async () => {
|
||||
const requests = [];
|
||||
await withTransport(
|
||||
async (_url, options) => {
|
||||
requests.push(options);
|
||||
return requests.length === 3
|
||||
? {
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({
|
||||
message: 'Synthetic version conflict',
|
||||
}),
|
||||
}
|
||||
: success;
|
||||
},
|
||||
async () => {
|
||||
await contextApi('/read');
|
||||
await contextApi('/write', { value: 'ç' });
|
||||
await assert.rejects(
|
||||
contextApi('/conflict', { value: 'kept' }),
|
||||
/Synthetic version conflict/,
|
||||
);
|
||||
assert.equal(requests[0].method, 'GET');
|
||||
assert.equal(requests[0].body, undefined);
|
||||
assert.equal(requests[0].headers['X-XSRF-TOKEN'], undefined);
|
||||
assert.equal(requests[1].method, 'POST');
|
||||
assert.equal(
|
||||
requests.every((request) => request.keepalive === false),
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -581,6 +581,55 @@ const buttonNamed = (tree, label) =>
|
||||
(node) => node.type === 'button' && node.props.children === label,
|
||||
);
|
||||
const settlePreview = () => new Promise((resolve) => setImmediate(resolve));
|
||||
void test('large multi-opportunity draft is offered to the transport on exit without truncation', () => {
|
||||
const previousWindow = globalThis.window;
|
||||
let flush;
|
||||
globalThis.window = {
|
||||
addEventListener(_name, callback) {
|
||||
flush = callback;
|
||||
},
|
||||
removeEventListener() {},
|
||||
};
|
||||
let cleanup;
|
||||
try {
|
||||
const harness = contextPreviewHarness();
|
||||
const initial = harness.render();
|
||||
cleanup = harness.effects
|
||||
.find(
|
||||
(effect) =>
|
||||
effect.dependencies?.length === 1 &&
|
||||
effect.dependencies[0] === 'synthetic',
|
||||
)
|
||||
.callback();
|
||||
const choices = Array.from({ length: 100 }, (_, index) => ({
|
||||
opportunity: {
|
||||
key: `opportunity:${index + 1}`,
|
||||
title: `Synthetic ${index + 1}`,
|
||||
},
|
||||
territory: null,
|
||||
location_reason: 'Descrição '.repeat(80),
|
||||
}));
|
||||
flat(initial)
|
||||
.find(
|
||||
(node) =>
|
||||
Array.isArray(node.props.choices) && node.props.onChange,
|
||||
)
|
||||
.props.onChange(choices);
|
||||
harness.render();
|
||||
flush();
|
||||
assert.equal(harness.pending.length, 1);
|
||||
assert.match(harness.pending[0].url, /\/rascunho$/);
|
||||
assert.equal(harness.pending[0].body.opportunity_choices.length, 100);
|
||||
assert.equal(
|
||||
harness.pending[0].body.opportunity_choices[99].location_reason,
|
||||
choices[99].location_reason,
|
||||
);
|
||||
assert.ok(JSON.stringify(harness.pending[0].body).length > 60000);
|
||||
} finally {
|
||||
cleanup?.();
|
||||
globalThis.window = previousWindow;
|
||||
}
|
||||
});
|
||||
void test('successful save followed by failed recovery offers read-only retry and never restores a stale draft', async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const listeners = new Map();
|
||||
|
||||
Reference in New Issue
Block a user