feat(web): open v0.1.0 web-only investigation workbench
This commit is contained in:
parent
9495e1258a
commit
7053bcc345
29 changed files with 797 additions and 364 deletions
|
|
@ -392,7 +392,7 @@ explicitement autorisée :
|
|||
make web-workspace-local WORKSPACE=/tmp/labfy-local-workspace
|
||||
```
|
||||
|
||||
Après saisie du code éphémère, créer l’enquête puis utiliser **Ajouter des
|
||||
Le workbench s’ouvre directement ; créer l’enquête puis utiliser **Ajouter des
|
||||
preuves** pour sélectionner des EML, PNG ou JPEG (4 Mio par fichier). La
|
||||
réception, la confirmation, le planner, les analyses, le graphe et les rapports
|
||||
restent séparés. La réouverture reprend le même UUID sans exemple ni duplication.
|
||||
|
|
|
|||
|
|
@ -7,15 +7,17 @@
|
|||
## Parcours
|
||||
|
||||
La commande principale lance l’API loopback sur une bibliothèque locale
|
||||
explicitement choisie. Elle affiche l’URL et un code de session éphémère :
|
||||
explicitement choisie, ouvre le navigateur et affiche directement le workbench :
|
||||
|
||||
```sh
|
||||
make web PORT=8081 LIBRARY=/chemin/vers/la-bibliotheque-locale
|
||||
```
|
||||
|
||||
L'instance écoute sur `127.0.0.1:8081` dans cet exemple. Après authentification,
|
||||
la bibliothèque permet de créer une enquête locale par le bridge C ou d'ouvrir
|
||||
une enquête enregistrée. Une seule enquête peut être active par instance ; une
|
||||
L'instance écoute sur `127.0.0.1:8081` dans cet exemple. La navigation racine
|
||||
établit automatiquement une session locale HttpOnly et ouvre directement la
|
||||
bibliothèque, sans formulaire ni code de session. La bibliothèque permet de
|
||||
créer une enquête locale par le bridge C ou d'ouvrir une enquête enregistrée.
|
||||
Une seule enquête peut être active par instance ; une
|
||||
autre ouverture exige le redémarrage du poste local. L'utilisateur sélectionne
|
||||
ensuite l’EML ou l’image dans le graphe et choisit « Analyser les en-têtes » ou
|
||||
« Examiner les métadonnées ». L’intention est persistée avant la réponse HTTP
|
||||
|
|
|
|||
|
|
@ -137,6 +137,29 @@ capabilities par objet, provenance multiple, deux reconnexions SSE, conservation
|
|||
du contexte, doublon, trou et rattrapage snapshot. Les captures sont des preuves
|
||||
visuelles complémentaires ; les assertions d'interaction font foi.
|
||||
|
||||
### Relevé du polish visuel Web V1 du 27 septembre 2026
|
||||
|
||||
La coque Web actuelle est organisée autour de **Agent | Graphe | Activité** et
|
||||
d’un tiroir transversal. Le graphe reste la surface dominante. Les panneaux
|
||||
latéraux et le tiroir sont repliables ; sur desktop, leurs dimensions sont
|
||||
redimensionnables et conservées comme préférences locales par origine. Cette
|
||||
préférence ne contient ni données d’enquête, ni token, ni état métier.
|
||||
|
||||
L’Agent affiche un flux SPECIMEN typé, une demande d’autorisation explicitant
|
||||
cible, provider, données sortantes, action, exposition, budget et raison. Il
|
||||
indique clairement qu’aucun modèle local n’est connecté : cette surface prépare
|
||||
un raccordement futur, elle ne simule aucun appel IA. Activité est un terminal
|
||||
structuré filtrable ; son mode expert ne révèle que des paramètres SPECIMEN non
|
||||
secrets et une provenance bornée.
|
||||
|
||||
test_visual_polish_browser.mjs, inclus dans npm run test:browser, exerce le
|
||||
resize et sa restauration, les panneaux repliés, le prompt Ctrl+Entrée, le
|
||||
filtre/inspecteur d’activité, le drag/pin, le mode graphe prioritaire, les
|
||||
tailles 1440×900, 1280×800, 1024×768 et 700×900, ainsi qu’un diagnostic
|
||||
d’erreur de contrat. Il produit des captures inspectables sous
|
||||
/tmp/labfy-visual-polish-*.png, toutes sur workspace et serveur loopback
|
||||
temporaires SPECIMEN.
|
||||
|
||||
### Relevé du lot J3 local du 26 septembre 2026
|
||||
|
||||
| Validation | Résultat observé |
|
||||
|
|
|
|||
156
prototypes/web-graph/public/app.js
vendored
156
prototypes/web-graph/public/app.js
vendored
|
|
@ -187,18 +187,9 @@ function expireAuthenticatedSession() {
|
|||
byId("library-home").hidden = false;
|
||||
byId("workspace-create").hidden = true;
|
||||
byId("library-list-section").hidden = true;
|
||||
byId("library-title").textContent = "Session locale expirée";
|
||||
byId("library-connection").textContent =
|
||||
"Le travail en cours a été arrêté. Reconnectez-vous pour reprendre ; aucune action refusée n’a été relancée.";
|
||||
let reconnect = byId("session-reconnect");
|
||||
if (!reconnect) {
|
||||
reconnect = document.createElement("a");
|
||||
reconnect.id = "session-reconnect";
|
||||
reconnect.href = "/";
|
||||
reconnect.textContent = "Se reconnecter";
|
||||
byId("library-connection").insertAdjacentElement("afterend", reconnect);
|
||||
}
|
||||
reconnect.focus();
|
||||
// CONTRACT: la reconnexion automatique ne rejoue aucune mutation. La
|
||||
// navigation racine obtient un nouveau cookie HttpOnly local sans code ni URL.
|
||||
window.location.replace("/");
|
||||
}
|
||||
|
||||
async function detectExpiredSession(response) {
|
||||
|
|
@ -282,11 +273,20 @@ function invalidateReportPreview(
|
|||
}
|
||||
|
||||
async function postCommand(path, value = {}) {
|
||||
const response = await workspaceFetch(path, {
|
||||
const request = () => workspaceFetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Labfy-CSRF": csrfToken },
|
||||
body: JSON.stringify(value),
|
||||
});
|
||||
let response = await request();
|
||||
// WHY: deux intentions humaines liées (préparer puis autoriser) peuvent se
|
||||
// succéder plus vite que le garde-fou local anti-burst. Une réponse 429 n'a
|
||||
// admis aucune mutation ; un unique backoff borné rejoue donc la même
|
||||
// intention, avec sa clé d'idempotence lorsqu'elle est requise.
|
||||
if (response.status === 429) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 35));
|
||||
response = await request();
|
||||
}
|
||||
const result = await workspaceJson(response);
|
||||
if (!response.ok) throw new Error(result.message ?? "Commande refusée");
|
||||
return result;
|
||||
|
|
@ -995,6 +995,7 @@ function render({ focusDomNodeId = null } = {}) {
|
|||
offset,
|
||||
focusNodeId: visibleIds.has(activeNodeId) ? activeNodeId : null,
|
||||
});
|
||||
byId("graph-zoom").textContent = `${Math.round(scale * 100)} %`;
|
||||
renderObjectList();
|
||||
renderSelection();
|
||||
}
|
||||
|
|
@ -1025,6 +1026,13 @@ function renderSelection() {
|
|||
details.textContent = "Sélectionnez un objet.";
|
||||
actions.textContent = "Aucune action sans sélection valide.";
|
||||
byId("provenance-mirror").textContent = "Sélectionnez un objet.";
|
||||
byId("drawer-details-summary").textContent =
|
||||
"Sélectionnez un objet du graphe pour conserver son contexte ici.";
|
||||
byId("drawer-provenance-summary").textContent = "Aucune source sélectionnée.";
|
||||
byId("drawer-timeline-summary").textContent =
|
||||
"La chronologie pertinente apparaît avec l’objet sélectionné.";
|
||||
byId("drawer-observations-summary").textContent =
|
||||
"Aucune observation à promouvoir automatiquement.";
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1074,6 +1082,16 @@ function renderSelection() {
|
|||
if (!provenanceMirror.hasChildNodes())
|
||||
provenanceMirror.textContent = "Aucune origine supplémentaire persistée.";
|
||||
renderActions(actions, node);
|
||||
byId("drawer-details-summary").textContent =
|
||||
`${node.label} · ${node.type} · état ${node.state}`;
|
||||
byId("drawer-provenance-summary").textContent =
|
||||
`Provenance disponible pour ${node.label}. Utilisez l’onglet Provenance de l’activité pour parcourir les origines.`;
|
||||
byId("drawer-timeline-summary").textContent =
|
||||
`La timeline filtre les événements attribués à ${node.label} sans inventer de date.`;
|
||||
byId("drawer-observations-summary").textContent =
|
||||
node.object_kind === "observation"
|
||||
? `${node.label} est une observation : revue explicite requise.`
|
||||
: `Aucune observation n’est automatiquement déduite de ${node.label}.`;
|
||||
}
|
||||
|
||||
function renderProvenance(container, nodeId, sectionId) {
|
||||
|
|
@ -1484,6 +1502,75 @@ function updateProjectionControls() {
|
|||
}
|
||||
}
|
||||
|
||||
function configureResizableLayout() {
|
||||
// CONTRACT: les dimensions de panneau sont des préférences locales par
|
||||
// origine. Elles ne modifient ni le snapshot, ni les positions épinglées.
|
||||
const workbench = document.querySelector(".workbench");
|
||||
const dimensions = [
|
||||
{ id: "agent-resizer", property: "--agent-pane", key: "agent", axis: "x",
|
||||
minimum: 208, maximum: 460, direction: 1 },
|
||||
{ id: "inspector-resizer", property: "--inspector-pane", key: "inspector", axis: "x",
|
||||
minimum: 256, maximum: 520, direction: -1 },
|
||||
{ id: "drawer-resizer", property: "--drawer", key: "drawer", axis: "y",
|
||||
minimum: 130, maximum: 440, direction: -1 },
|
||||
];
|
||||
for (const dimension of dimensions) {
|
||||
const stored = Number.parseInt(localStorage.getItem(`labfy-layout:${dimension.key}`), 10);
|
||||
if (Number.isFinite(stored))
|
||||
workbench.style.setProperty(dimension.property, `${stored}px`);
|
||||
const separator = byId(dimension.id);
|
||||
const applyKeyboardDelta = (delta) => {
|
||||
const current = Number.parseInt(
|
||||
getComputedStyle(workbench).getPropertyValue(dimension.property), 10,
|
||||
);
|
||||
const next = Math.max(dimension.minimum, Math.min(dimension.maximum, current + delta));
|
||||
workbench.style.setProperty(dimension.property, `${next}px`);
|
||||
localStorage.setItem(`labfy-layout:${dimension.key}`, String(next));
|
||||
requestAnimationFrame(() => renderer.updateViewport(scale, offset));
|
||||
};
|
||||
separator.addEventListener("keydown", (event) => {
|
||||
const backward = dimension.axis === "x" ? "ArrowLeft" : "ArrowUp";
|
||||
const forward = dimension.axis === "x" ? "ArrowRight" : "ArrowDown";
|
||||
if (![backward, forward].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
applyKeyboardDelta((event.key === forward ? 16 : -16) * dimension.direction);
|
||||
});
|
||||
separator.addEventListener("pointerdown", (event) => {
|
||||
if (matchMedia("(max-width: 900px)").matches) return;
|
||||
const initial = Number.parseInt(
|
||||
getComputedStyle(workbench).getPropertyValue(dimension.property), 10,
|
||||
);
|
||||
const origin = dimension.axis === "x" ? event.clientX : event.clientY;
|
||||
separator.setPointerCapture(event.pointerId);
|
||||
const move = (moveEvent) => {
|
||||
if (moveEvent.pointerId !== event.pointerId) return;
|
||||
const coordinate = dimension.axis === "x" ? moveEvent.clientX : moveEvent.clientY;
|
||||
const next = Math.max(
|
||||
dimension.minimum,
|
||||
Math.min(dimension.maximum, initial + (coordinate - origin) * dimension.direction),
|
||||
);
|
||||
workbench.style.setProperty(dimension.property, `${next}px`);
|
||||
renderer.updateViewport(scale, offset);
|
||||
};
|
||||
const finish = (finishEvent) => {
|
||||
if (finishEvent.pointerId !== event.pointerId) return;
|
||||
if (separator.hasPointerCapture(event.pointerId))
|
||||
separator.releasePointerCapture(event.pointerId);
|
||||
const value = Number.parseInt(
|
||||
getComputedStyle(workbench).getPropertyValue(dimension.property), 10,
|
||||
);
|
||||
localStorage.setItem(`labfy-layout:${dimension.key}`, String(value));
|
||||
separator.removeEventListener("pointermove", move);
|
||||
separator.removeEventListener("pointerup", finish);
|
||||
separator.removeEventListener("pointercancel", finish);
|
||||
};
|
||||
separator.addEventListener("pointermove", move);
|
||||
separator.addEventListener("pointerup", finish);
|
||||
separator.addEventListener("pointercancel", finish);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function configureControls() {
|
||||
// CONTRACT: la disposition est une préférence locale par origine ; elle ne
|
||||
// transporte ni l'enquête, ni un secret, ni un état métier vers le navigateur.
|
||||
|
|
@ -1590,6 +1677,26 @@ function configureControls() {
|
|||
offset = { x: 0, y: 0 };
|
||||
render({ focusDomNodeId: selectedNodeId });
|
||||
});
|
||||
byId("graph-search-focus").addEventListener("click", () => byId("search").focus());
|
||||
byId("graph-home").addEventListener("click", resetGlobalView);
|
||||
byId("graph-reset-layout").addEventListener("click", () => byId("reset-layout").click());
|
||||
byId("graph-fullscreen").addEventListener("click", () => {
|
||||
const workbench = document.querySelector(".workbench");
|
||||
const active = workbench.classList.toggle("graph-priority");
|
||||
byId("graph-fullscreen").setAttribute("aria-pressed", String(active));
|
||||
byId("graph-fullscreen").title = active ? "Quitter le mode graphe" : "Agrandir le graphe";
|
||||
requestAnimationFrame(() => renderer.updateViewport(scale, offset));
|
||||
});
|
||||
byId("activity-filter").addEventListener("change", (event) => {
|
||||
for (const entry of document.querySelectorAll("[data-activity-type]"))
|
||||
entry.hidden = Boolean(event.target.value) &&
|
||||
entry.dataset.activityType !== event.target.value;
|
||||
});
|
||||
byId("activity-expert-toggle").addEventListener("click", (event) => {
|
||||
const active = byId("inspector").classList.toggle("expert-mode");
|
||||
event.currentTarget.setAttribute("aria-pressed", String(active));
|
||||
event.currentTarget.textContent = active ? "Mode normal" : "Mode expert";
|
||||
});
|
||||
byId("type-filter").addEventListener("change", (event) => {
|
||||
filters.type = event.target.value;
|
||||
render();
|
||||
|
|
@ -1629,7 +1736,7 @@ function configureGraphInteractions() {
|
|||
// coordonnées épinglées qui restent un état de présentation local.
|
||||
new ResizeObserver(() => requestAnimationFrame(() => {
|
||||
const panel = byId("graph-panel");
|
||||
if (!graph_scale_initialized && snapshot.nodes.length > 0 &&
|
||||
if (coreMode && !graph_scale_initialized && snapshot.nodes.length > 0 &&
|
||||
panel.clientWidth > 100 && panel.clientHeight > 100) {
|
||||
// WHY: le snapshot peut arriver pendant que la coque tri-panneaux passe
|
||||
// de hidden à sa largeur finale. L'auto-fit unique évite que des nœuds
|
||||
|
|
@ -1674,9 +1781,10 @@ function configureGraphInteractions() {
|
|||
event.preventDefault();
|
||||
scale = Math.max(
|
||||
0.25,
|
||||
Math.min(3, scale * (event.deltaY < 0 ? 1.1 : 0.9)),
|
||||
Math.min(3, scale * (event.deltaY < 0 ? 1.2 : 0.9)),
|
||||
);
|
||||
renderer.updateViewport(scale, offset);
|
||||
byId("graph-zoom").textContent = `${Math.round(scale * 100)} %`;
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
|
@ -1719,6 +1827,12 @@ function configureGraphInteractions() {
|
|||
byId("node-context-menu").hidden = true;
|
||||
svg.focus();
|
||||
}
|
||||
if (
|
||||
event.key === "Escape" &&
|
||||
document.querySelector(".workbench").classList.contains("graph-priority")
|
||||
) {
|
||||
byId("graph-fullscreen").click();
|
||||
}
|
||||
});
|
||||
document.addEventListener("pointerdown", (event) => {
|
||||
if (
|
||||
|
|
@ -1834,6 +1948,7 @@ async function activateWorkspace(opened) {
|
|||
// repose pas sur la largeur héritée lorsque le shell était hidden.
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
scale = fitGraphScale();
|
||||
graph_scale_initialized = true;
|
||||
syncTypeFilterOptions();
|
||||
restoreReportDraft();
|
||||
render();
|
||||
|
|
@ -1884,6 +1999,7 @@ async function activateLegacyWorkspace(session) {
|
|||
coreMode ? "Lecture seule — snapshot cœur chargé" : `Connecté · révision ${snapshot.revision}`;
|
||||
scale = coreMode ? fitGraphScale() :
|
||||
Math.min(1, Math.max(0.6, (svg.clientWidth - 160) / 700));
|
||||
graph_scale_initialized = true;
|
||||
syncTypeFilterOptions();
|
||||
restoreReportDraft();
|
||||
render();
|
||||
|
|
@ -1941,12 +2057,13 @@ async function openWorkspace(workspaceId, button) {
|
|||
|
||||
function configureApplication() {
|
||||
configureControls();
|
||||
configureResizableLayout();
|
||||
configureGraphInteractions();
|
||||
configureImport();
|
||||
const drawerCollapsed = localStorage.getItem("labfy-pane:drawer") === "collapsed";
|
||||
if (drawerCollapsed && !byId("work-panel").classList.contains("collapsed"))
|
||||
byId("work-panel-toggle").click();
|
||||
byId("agent-send").addEventListener("click", () => {
|
||||
const submitAgentPrompt = () => {
|
||||
const prompt = byId("agent-prompt").value.trim();
|
||||
if (!prompt) { byId("agent-prompt").focus(); return; }
|
||||
const card = document.createElement("li");
|
||||
|
|
@ -1956,6 +2073,13 @@ function configureApplication() {
|
|||
card.append(label, text);
|
||||
document.querySelector(".agent-conversation").append(card);
|
||||
byId("agent-prompt").value = "";
|
||||
};
|
||||
byId("agent-send").addEventListener("click", submitAgentPrompt);
|
||||
byId("agent-prompt").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
submitAgentPrompt();
|
||||
}
|
||||
});
|
||||
byId("home-button").addEventListener("click", () => void showLibrary({ focus: true }));
|
||||
byId("workspace-create-form").addEventListener("submit", async (event) => {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
placeholder="Rechercher un objet…"
|
||||
/><button id="search-button">Recentrer</button></label
|
||||
>
|
||||
<div id="connection" role="status">Chargement…</div>
|
||||
<div class="topbar-status"><span id="connection" role="status">Chargement…</span><button id="graph-fullscreen" class="icon-button" type="button" aria-pressed="false" title="Agrandir le graphe">⛶<span class="sr-only">Agrandir le graphe</span></button></div>
|
||||
</header>
|
||||
<nav class="projection-bar" aria-label="Projections et contrôles du graphe">
|
||||
<div class="projection-tabs" role="group">
|
||||
|
|
@ -93,19 +93,24 @@
|
|||
<main class="workbench">
|
||||
<aside id="agent-panel" class="agent-panel" aria-label="Agent IA local">
|
||||
<div class="panel-heading"><div><span class="eyebrow">Agent IA · préparation</span><h2>Mission</h2></div><button class="pane-toggle" data-pane="agent-panel" aria-expanded="true">Réduire</button></div>
|
||||
<label class="agent-prompt"><span>Prompt à l’agent</span><textarea id="agent-prompt" maxlength="2000" placeholder="Décrivez l’intention d’enquête…" aria-describedby="agent-prompt-hint"></textarea><small id="agent-prompt-hint">Ctrl+Entrée pour préparer la demande · aucune action n’est lancée.</small><div class="agent-prompt-actions"><button type="button" id="agent-attachment" disabled title="Pièces jointes indisponibles dans cette tranche">Joindre</button><button type="button" id="agent-send">Préparer une demande</button></div></label>
|
||||
<div class="agent-scroll">
|
||||
<div class="agent-status"><span aria-hidden="true">◌</span><span>IA locale non connectée</span><small>Surface prête pour un agent futur</small></div>
|
||||
<p class="agent-mission">Cartographier les relations autour de <strong>example.test</strong> en respectant les autorisations explicites.</p>
|
||||
<ol class="agent-conversation" aria-label="Conversation SPECIMEN">
|
||||
<li class="agent-card user"><b>USER</b><p>Montre les pivots passifs disponibles.</p></li>
|
||||
<li class="agent-card agent"><b>AGENT</b><p>Je prépare un plan explicable. Aucune action réseau ne part sans capability et autorisation.</p></li>
|
||||
<li class="agent-card plan"><b>PLAN</b><p>1. Lire les observations · 2. Proposer les capabilities · 3. Attendre l’accord.</p></li>
|
||||
<li class="agent-card hypothesis"><b>HYPOTHESIS</b><p>Le domaine et l’adresse contact peuvent partager une provenance, sans confirmer une identité.</p></li>
|
||||
<li class="agent-card authorization"><b>AUTHORIZATION</b><p><strong>rdap.lookup_domain</strong><br/>Cible : example.test · Provider : SPECIMEN loopback<br/>Sortie : nom de domaine · Exposition : passive · Budget : 1 requête<br/><button type="button">Examiner l’autorisation</button></p></li>
|
||||
<li class="agent-card authorization"><b>AUTHORIZATION</b><p><strong>rdap.lookup_domain</strong></p><dl><dt>Cible</dt><dd>example.test</dd><dt>Provider</dt><dd>SPECIMEN loopback</dd><dt>Données sortantes</dt><dd>Nom de domaine</dd><dt>Action</dt><dd>Lookup passif</dd><dt>Exposition</dt><dd>Passive</dd><dt>Budget</dt><dd>1 requête</dd><dt>Raison</dt><dd>Vérifier une provenance déclarée</dd></dl><button type="button">Examiner l’autorisation</button></li>
|
||||
<li class="agent-card result"><b>RESULT</b><p>7 observations reçues ; aucune relation n’est promue automatiquement.</p></li>
|
||||
<li class="agent-card warning"><b>WARNING</b><p>Les hypothèses restent distinctes des observations et des décisions humaines.</p></li>
|
||||
</ol>
|
||||
<label class="agent-prompt"><span>Prompt à l’agent</span><textarea id="agent-prompt" maxlength="2000" placeholder="Décrivez l’intention d’enquête…"></textarea><button type="button" id="agent-send">Préparer une demande</button></label>
|
||||
</div>
|
||||
</aside>
|
||||
<div id="agent-resizer" class="pane-resizer pane-resizer-vertical" role="separator" aria-label="Redimensionner le panneau Agent" aria-orientation="vertical" tabindex="0"></div>
|
||||
<section id="graph-panel" aria-label="Graphe interactif">
|
||||
<div class="graph-toolbar" aria-label="Commandes rapides du graphe"><button id="graph-search-focus" type="button" title="Rechercher dans le graphe">⌕<span class="sr-only">Rechercher</span></button><button id="graph-home" type="button" title="Recentrer le graphe">⌂<span class="sr-only">Recentrer</span></button><button id="graph-reset-layout" type="button" title="Réinitialiser les positions épinglées">↺<span class="sr-only">Réinitialiser les positions</span></button><span id="graph-zoom" aria-live="polite">100 %</span></div>
|
||||
<div id="graph-state" role="alert"></div>
|
||||
<div class="graph-legend" aria-label="Légende">
|
||||
<span><i class="shape round"></i>Entité</span
|
||||
|
|
@ -166,10 +171,11 @@
|
|||
</div>
|
||||
<button class="pane-toggle" data-pane="inspector" aria-expanded="true">Réduire</button>
|
||||
</div>
|
||||
<div class="activity-controls"><label>Filtrer <select id="activity-filter"><option value="">Tous les événements</option><option>PLAN</option><option>POLICY</option><option>TOOL</option><option>JOB</option><option>NETWORK</option><option>RESULT</option><option>ERROR</option><option>RESOURCE</option><option>AGENT</option></select></label><button id="activity-expert-toggle" type="button" aria-pressed="false">Mode expert</button></div>
|
||||
<section class="activity-stream" aria-label="Timeline technique SPECIMEN">
|
||||
<div class="activity-entry"><time>15:42:12</time><b>TOOL</b><span>rdap.lookup_domain<br/><code>target = example.test</code></span></div>
|
||||
<div class="activity-entry policy"><time>15:42:12</time><b>POLICY</b><span>Autorisé · budget restant 18/20</span></div>
|
||||
<div class="activity-entry result"><time>15:42:13</time><b>RESULT</b><span>1 artefact · 7 observations</span></div>
|
||||
<details class="activity-entry" data-activity-type="TOOL"><summary><time>15:42:12</time><b>TOOL</b><span>rdap.lookup_domain<br/><code>target = example.test</code></span></summary><pre>provider: specimen-loopback\nlatence: 34 ms\nsecret: [non affiché]</pre></details>
|
||||
<details class="activity-entry policy" data-activity-type="POLICY"><summary><time>15:42:12</time><b>POLICY</b><span>Autorisé · budget restant 18/20</span></summary><pre>risk_class: PASSIVE\nauthorization: accord explicite</pre></details>
|
||||
<details class="activity-entry result" data-activity-type="RESULT"><summary><time>15:42:13</time><b>RESULT</b><span>1 artefact · 7 observations</span></summary><pre>provenance: tool-result/specimen\nstdout: borné</pre></details>
|
||||
</section>
|
||||
<details class="system-card"><summary>Système · ressources réelles</summary><dl><dt>CPU</dt><dd id="system-cpu">Disponible via backend</dd><dt>RAM</dt><dd id="system-ram">Disponible via backend</dd><dt>Jobs actifs</dt><dd id="system-jobs">Lecture JobStore</dd><dt>Instance Web</dt><dd id="system-instance">Loopback</dd><dt>Providers</dt><dd id="system-providers">Selon capabilities</dd></dl><p class="future-slots">Prévu : modèle · quantification · GPU AMD · KV cache · réserve RAM · swap · scratch IA.</p></details>
|
||||
<div class="tablist" role="tablist">
|
||||
|
|
@ -211,26 +217,46 @@
|
|||
</table>
|
||||
</section>
|
||||
</aside>
|
||||
<div id="inspector-resizer" class="pane-resizer pane-resizer-vertical" role="separator" aria-label="Redimensionner le panneau Activité" aria-orientation="vertical" tabindex="0"></div>
|
||||
<section id="work-panel" class="work-panel" aria-label="Travail local">
|
||||
<div class="work-panel-header">
|
||||
<div class="tablist" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
data-work-panel="tasks-panel"
|
||||
aria-selected="true"
|
||||
>
|
||||
Tâches</button
|
||||
<button role="tab" data-work-panel="details-drawer-panel">Détails</button
|
||||
><button role="tab" data-work-panel="provenance-drawer-panel">Provenance</button
|
||||
><button role="tab" data-work-panel="timeline-drawer-panel">Timeline</button
|
||||
><button role="tab" data-work-panel="research-panel">Recherches</button
|
||||
><button role="tab" data-work-panel="observations-drawer-panel">Observations</button
|
||||
><button role="tab" data-work-panel="finance-drawer-panel">Finance</button
|
||||
><button role="tab" data-work-panel="reports-panel">Rapports</button
|
||||
><button role="tab" data-work-panel="tasks-panel" aria-selected="true">Tâches</button
|
||||
><button role="tab" data-work-panel="next-panel">
|
||||
Prochaines actions</button
|
||||
><button role="tab" data-work-panel="plans-panel">Plans</button
|
||||
><button role="tab" data-work-panel="research-panel">Recherches</button
|
||||
><button role="tab" data-work-panel="reports-panel">
|
||||
Rapports
|
||||
</button>
|
||||
</div>
|
||||
<button id="work-panel-toggle" aria-expanded="true">Réduire</button>
|
||||
</div>
|
||||
<div class="work-panel-body">
|
||||
<section id="details-drawer-panel" class="work-tab-panel" hidden>
|
||||
<h3>Détails de la sélection</h3>
|
||||
<p id="drawer-details-summary">Sélectionnez un objet du graphe pour conserver son contexte ici.</p>
|
||||
</section>
|
||||
<section id="provenance-drawer-panel" class="work-tab-panel" hidden>
|
||||
<h3>Chaîne de provenance</h3>
|
||||
<p id="drawer-provenance-summary">Aucune source sélectionnée.</p>
|
||||
<p class="empty-state">Les dérivés et transformations sont affichés sans confondre observation et hypothèse.</p>
|
||||
</section>
|
||||
<section id="timeline-drawer-panel" class="work-tab-panel" hidden>
|
||||
<h3>Timeline</h3>
|
||||
<p id="drawer-timeline-summary">La chronologie pertinente apparaît avec l’objet sélectionné.</p>
|
||||
</section>
|
||||
<section id="observations-drawer-panel" class="work-tab-panel" hidden>
|
||||
<h3>Observations</h3>
|
||||
<p id="drawer-observations-summary">Aucune observation à promouvoir automatiquement.</p>
|
||||
</section>
|
||||
<section id="finance-drawer-panel" class="work-tab-panel" hidden>
|
||||
<h3>Finance</h3>
|
||||
<p class="empty-state">Aucune donnée financière n’est simulée dans cette vue SPECIMEN.</p>
|
||||
</section>
|
||||
<section id="tasks-panel" class="work-tab-panel">
|
||||
<h3>Jobs locaux</h3>
|
||||
<p id="jobs-note">Chargement…</p>
|
||||
|
|
@ -331,6 +357,7 @@
|
|||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<div id="drawer-resizer" class="pane-resizer pane-resizer-horizontal" role="separator" aria-label="Redimensionner le tiroir inférieur" aria-orientation="horizontal" tabindex="0"></div>
|
||||
</main>
|
||||
<dialog id="evidence-dialog" aria-labelledby="evidence-title">
|
||||
<form method="dialog" class="dialog-close"><button>Fermer</button></form>
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Labfy — session locale</title><link rel="stylesheet" href="styles.css"></head>
|
||||
<body class="login"><main><section class="login-card"><h1>Labfy · poste local</h1>
|
||||
<p>Ce poste fonctionne uniquement en local. Entrez le code éphémère affiché dans le terminal qui a lancé Labfy.</p>
|
||||
<p class="login-privacy">Le code n’est ni placé dans l’URL, ni enregistré dans le navigateur, ni affiché après saisie.</p>
|
||||
<form id="login-form"><label for="bootstrap-code">Code de session</label><input id="bootstrap-code" type="password" autocomplete="one-time-code" required autofocus>
|
||||
<button type="submit">Ouvrir la session</button></form><p id="login-status" role="alert"></p>
|
||||
</section></main><script type="module" src="login.js"></script></body></html>
|
||||
28
prototypes/web-graph/public/login.js
vendored
28
prototypes/web-graph/public/login.js
vendored
|
|
@ -1,28 +0,0 @@
|
|||
const form = document.getElementById("login-form");
|
||||
const status = document.getElementById("login-status");
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const input = document.getElementById("bootstrap-code");
|
||||
const button = form.querySelector("button");
|
||||
button.disabled = true;
|
||||
status.textContent = "Ouverture de la session locale…";
|
||||
try {
|
||||
const response = await fetch("/api/v1/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bootstrap_code: input.value }),
|
||||
});
|
||||
// INVARIANT: le secret saisi ne rejoint ni l'URL, ni le stockage Web,
|
||||
// ni un message d'erreur rendu à l'écran.
|
||||
input.value = "";
|
||||
if (!response.ok) throw new Error("Code refusé ou session indisponible.");
|
||||
location.replace("/");
|
||||
} catch (error) {
|
||||
input.value = "";
|
||||
status.textContent = error.message === "Failed to fetch"
|
||||
? "Le service local ne répond pas. Vérifiez qu’il est toujours lancé."
|
||||
: error.message;
|
||||
button.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
:root {
|
||||
--base: #1e1e2e;
|
||||
--mantle: #181825;
|
||||
--crust: #11111b;
|
||||
--panel: #181825;
|
||||
--surface0: #313244;
|
||||
--surface1: #45475a;
|
||||
--raised: #313244;
|
||||
--border: #45475a;
|
||||
--accent: #b4befe;
|
||||
|
|
@ -12,7 +16,7 @@
|
|||
--yellow: #f9e2af;
|
||||
--top: 3.6rem;
|
||||
--nav: 3.3rem;
|
||||
--drawer: 15rem;
|
||||
--drawer: 240px;
|
||||
}
|
||||
|
||||
.modal-card { position: fixed; inset: 50% auto auto 50%; transform: translate(-50%,-50%);
|
||||
|
|
@ -465,6 +469,7 @@ aside h4,
|
|||
}
|
||||
.work-panel.collapsed {
|
||||
grid-template-rows: auto 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.work-panel-header {
|
||||
display: flex;
|
||||
|
|
@ -656,7 +661,7 @@ body.login .workbench {
|
|||
display: none;
|
||||
}
|
||||
.workbench.drawer-collapsed {
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
grid-template-rows: minmax(0, 1fr) 2.7rem;
|
||||
}
|
||||
|
||||
#evidence-dialog {
|
||||
|
|
@ -734,14 +739,39 @@ body.login .workbench {
|
|||
}
|
||||
/* CONTRACT: la coque finale garde le graphe dominant ; les trois surfaces
|
||||
* sont des vues de la même enquête, jamais des stores concurrents. */
|
||||
.workbench { grid-template-columns: minmax(15rem,20rem) minmax(0,1fr) minmax(18rem,23rem); grid-template-areas: "agent graph inspector" "work work work"; }
|
||||
.agent-panel { grid-area: agent; min-width:0; min-height:0; display:grid; grid-template-rows:auto minmax(0,1fr); background:linear-gradient(180deg,#181825,#11111b); border-right:1px solid var(--border); }
|
||||
.agent-scroll { min-height:0; overflow:auto; padding:.75rem; }.agent-mission { margin:0 0 .8rem; color:var(--muted); line-height:1.45; }.agent-conversation { display:grid; gap:.55rem; margin:0; padding:0; list-style:none; }.agent-card { border:1px solid var(--border); border-left:3px solid var(--blue); border-radius:.65rem; padding:.6rem .7rem; background:#1e1e2ecc; }.agent-card b { font-size:.68rem; letter-spacing:.08em; color:var(--muted); }.agent-card p { margin:.3rem 0 0; line-height:1.4; }.agent-card.plan { border-left-color:var(--accent); }.agent-card.hypothesis { border-left-color:var(--yellow); }.agent-card.authorization { border-left-color:#cba6f7; }.agent-card.result { border-left-color:var(--green); }.agent-prompt { display:grid; gap:.4rem; margin-top:.8rem; }.agent-prompt textarea { min-height:5.5rem; resize:vertical; }.pane-toggle { padding:.28rem .48rem; font-size:.78rem; }
|
||||
.activity-stream { display:grid; gap:.35rem; margin-bottom:.75rem; padding-bottom:.75rem; border-bottom:1px solid var(--border); }.activity-entry { display:grid; grid-template-columns:4.4rem 4.2rem minmax(0,1fr); gap:.3rem; padding:.42rem; border-radius:.42rem; background:#11111b; font-size:.78rem; }.activity-entry time { color:var(--muted); }.activity-entry b { color:var(--blue); font-size:.7rem; }.activity-entry.policy b { color:var(--yellow); }.activity-entry.result b { color:var(--green); }.activity-entry code { color:var(--muted); overflow-wrap:anywhere; }.system-card { margin-bottom:.75rem; padding:.55rem; border:1px solid var(--border); border-radius:.55rem; background:#11111b; }.system-card summary { color:var(--accent); cursor:pointer; }.system-card dl { display:grid; grid-template-columns:1fr auto; gap:.3rem .6rem; margin:.7rem 0; font-size:.8rem; }.system-card dd { margin:0; color:var(--muted); text-align:right; }.future-slots { margin:0; color:var(--muted); font-size:.75rem; line-height:1.35; }
|
||||
.agent-panel.pane-collapsed { grid-template-rows: auto; }.agent-panel.pane-collapsed .agent-scroll { display:none; }.agent-panel.pane-collapsed { min-width: 5.5rem; }.agent-panel.pane-collapsed h2 { display:none; }#inspector.pane-collapsed > :not(.panel-heading) { display:none; }#inspector.pane-collapsed { min-width:5.5rem; }#inspector.pane-collapsed h2 { display:none; }
|
||||
.topbar-status { display: flex; align-items: center; justify-content: end; gap: .45rem; min-width: 0; }
|
||||
.icon-button, .graph-toolbar button { display: inline-grid; place-items: center; min-width: 2.1rem; min-height: 2.1rem; padding: .25rem; }
|
||||
.workbench { position: relative; --agent-pane: 320px; --inspector-pane: 368px; grid-template-columns: minmax(13rem, var(--agent-pane)) minmax(0, 1fr) minmax(16rem, var(--inspector-pane)); grid-template-areas: "agent graph inspector" "work work work"; }
|
||||
.agent-panel { grid-area: agent; min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); background: linear-gradient(180deg, var(--panel), var(--crust)); border-right: 1px solid var(--border); }
|
||||
.panel-heading { display: flex; align-items: start; justify-content: space-between; gap: .5rem; padding: .75rem .75rem 0; }
|
||||
.agent-scroll { min-height: 0; overflow: auto; padding: .75rem; }
|
||||
.agent-status { display: grid; grid-template-columns: auto 1fr; gap: .15rem .45rem; align-items: center; padding: .5rem .6rem; margin-bottom: .7rem; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); border-radius: .65rem; color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--crust)); }
|
||||
.agent-status small { grid-column: 2; color: var(--muted); }
|
||||
.agent-mission { margin: 0 0 .8rem; color: var(--muted); line-height: 1.45; }
|
||||
.agent-conversation { display: grid; gap: .55rem; margin: 0; padding: 0; list-style: none; }
|
||||
.agent-card { border: 1px solid var(--border); border-left: 3px solid var(--blue); border-radius: .65rem; padding: .6rem .7rem; background: color-mix(in srgb, var(--base) 88%, var(--surface0)); box-shadow: 0 .3rem .8rem rgb(0 0 0 / 12%); }
|
||||
.agent-card b { font-size: .68rem; letter-spacing: .09em; color: var(--muted); }
|
||||
.agent-card p { margin: .3rem 0 0; line-height: 1.4; }
|
||||
.agent-card.agent { border-left-color: var(--blue); }.agent-card.plan { border-left-color: var(--accent); }.agent-card.hypothesis { border-left-color: var(--yellow); }.agent-card.authorization { border-left-color: #cba6f7; }.agent-card.result { border-left-color: var(--green); }.agent-card.warning { border-left-color: var(--red); }
|
||||
.agent-card.authorization dl { display: grid; grid-template-columns: minmax(5.5rem, auto) 1fr; gap: .2rem .5rem; margin: .55rem 0; font-size: .78rem; }.agent-card.authorization dt { color: var(--muted); }.agent-card.authorization dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.agent-prompt { z-index: 1; display: grid; gap: .4rem; margin: .5rem .75rem .35rem; padding: .55rem; background: var(--mantle); border: 1px solid var(--border); border-radius: .65rem; }.agent-prompt textarea { min-height: 5.5rem; max-height: 9rem; resize: vertical; }.agent-prompt small { color: var(--muted); }.agent-prompt-actions { display: flex; justify-content: space-between; gap: .4rem; }.pane-toggle { padding: .28rem .48rem; font-size: .78rem; }
|
||||
.graph-toolbar { position: absolute; z-index: 4; left: .65rem; bottom: 2.35rem; display: flex; align-items: center; gap: .3rem; padding: .3rem; border: 1px solid var(--border); border-radius: .65rem; background: rgb(17 17 27 / 88%); box-shadow: 0 .5rem 1.5rem rgb(0 0 0 / 22%); }.graph-toolbar button { background: var(--surface0); }.graph-toolbar span { min-width: 3.6rem; color: var(--muted); font-size: .75rem; text-align: center; }
|
||||
.activity-controls { display: flex; align-items: center; justify-content: space-between; gap: .45rem; margin: 0 0 .55rem; font-size: .78rem; }.activity-controls label { display: flex; align-items: center; gap: .3rem; min-width: 0; }.activity-controls select { min-width: 0; max-width: 10.6rem; padding: .28rem; }.activity-controls button { padding: .3rem .42rem; white-space: nowrap; }
|
||||
.activity-stream { display: grid; gap: .35rem; margin-bottom: .75rem; padding-bottom: .75rem; border-bottom: 1px solid var(--border); }.activity-entry { padding: .42rem; border: 1px solid transparent; border-radius: .48rem; background: var(--crust); font-size: .78rem; }.activity-entry summary { display: grid; grid-template-columns: 4.4rem 4.2rem minmax(0, 1fr); gap: .3rem; cursor: pointer; list-style: none; }.activity-entry summary::-webkit-details-marker { display: none; }.activity-entry time { color: var(--muted); }.activity-entry b { color: var(--blue); font-size: .7rem; }.activity-entry.policy b { color: var(--yellow); }.activity-entry.result b { color: var(--green); }.activity-entry code { color: var(--muted); overflow-wrap: anywhere; }.activity-entry pre { display: none; margin: .5rem 0 0; padding: .45rem; overflow: auto; color: var(--muted); background: var(--mantle); border-radius: .35rem; white-space: pre-wrap; }.expert-mode .activity-entry[open] { border-color: var(--surface1); }.expert-mode .activity-entry[open] pre { display: block; }
|
||||
.system-card { margin-bottom: .75rem; padding: .55rem; border: 1px solid var(--border); border-radius: .55rem; background: var(--crust); }.system-card summary { color: var(--accent); cursor: pointer; }.system-card dl { display: grid; grid-template-columns: 1fr auto; gap: .3rem .6rem; margin: .7rem 0; font-size: .8rem; }.system-card dd { margin: 0; color: var(--muted); text-align: right; }.future-slots { margin: 0; color: var(--muted); font-size: .75rem; line-height: 1.35; }
|
||||
.agent-panel.pane-collapsed { grid-template-rows: auto; min-width: 5.5rem; }.agent-panel.pane-collapsed .agent-scroll, .agent-panel.pane-collapsed .agent-prompt, #inspector.pane-collapsed > :not(.panel-heading) { display: none; }.agent-panel.pane-collapsed h2, #inspector.pane-collapsed h2 { display: none; }#inspector.pane-collapsed { min-width: 5.5rem; }
|
||||
.pane-resizer { position: absolute; z-index: 7; border: 0; background: transparent; }.pane-resizer::after { content: ""; position: absolute; background: var(--accent); opacity: 0; transition: opacity .15s; }.pane-resizer:hover::after, .pane-resizer:focus-visible::after { opacity: .78; }.pane-resizer-vertical { top: 0; bottom: var(--drawer); width: .75rem; cursor: col-resize; }.pane-resizer-vertical::after { inset: 18% calc(50% - 1px); width: 2px; border-radius: 2px; }.pane-resizer-horizontal { left: 0; right: 0; bottom: calc(var(--drawer) - .375rem); height: .75rem; cursor: row-resize; }.pane-resizer-horizontal::after { left: 38%; right: 38%; top: calc(50% - 1px); height: 2px; border-radius: 2px; }#agent-resizer { left: calc(var(--agent-pane) - .375rem); }#inspector-resizer { right: calc(var(--inspector-pane) - .375rem); }.workbench.drawer-collapsed .pane-resizer-horizontal { display: none; }.workbench.drawer-collapsed .pane-resizer-vertical { bottom: 2.6rem; }
|
||||
@media (max-width: 900px) {
|
||||
.workbench { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(18rem,1fr) auto var(--drawer); grid-template-areas:"agent" "graph" "inspector" "work"; }.agent-panel { max-height:19rem; border-right:0; border-bottom:1px solid var(--border); }
|
||||
.pane-resizer { display: none; }
|
||||
.workbench.drawer-collapsed {
|
||||
grid-template-rows: minmax(18rem, 1fr) auto auto;
|
||||
grid-template-rows: auto minmax(18rem, 1fr) auto auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.topbar-status { max-width: 42vw; }.topbar-status #connection { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.activity-controls { align-items: stretch; flex-direction: column; }.activity-controls select { max-width: none; width: 100%; }
|
||||
.agent-panel { max-height: 17rem; }.graph-toolbar { bottom: .7rem; }.work-panel-header .tablist { overflow-x: auto; }
|
||||
}
|
||||
.workbench.graph-priority { grid-template-columns: 0 minmax(0, 1fr) 0; grid-template-rows: minmax(0, 1fr) 0; }.workbench.graph-priority #agent-panel, .workbench.graph-priority #inspector, .workbench.graph-priority #work-panel, .workbench.graph-priority .pane-resizer { display: none; }.workbench.graph-priority #graph-panel { grid-column: 1 / -1; grid-row: 1; }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } }
|
||||
|
|
|
|||
97
prototypes/web-graph/tests/browser_automatic_session.mjs
Normal file
97
prototypes/web-graph/tests/browser_automatic_session.mjs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import assert from "node:assert/strict";
|
||||
|
||||
// CONTRACT: les scénarios Firefox utilisent le parcours produit complet. Ce
|
||||
// helper ne connaît ni code de bootstrap ni cookie synthétique : le serveur
|
||||
// établit le cookie HttpOnly lors de GET / et l'application lit le CSRF.
|
||||
export async function openAutomaticSession(page, origin, label = "session") {
|
||||
await page.goto(origin, { waitUntil: "domcontentloaded" });
|
||||
return assertAutomaticSessionReady(page, new URL(origin).origin, label);
|
||||
}
|
||||
|
||||
// CONTRACT: Firefox BiDi peut ne jamais résoudre page.goto() lorsqu'un serveur
|
||||
// possédé redémarre sur la même origine. Cette primitive conserve la navigation
|
||||
// réelle (GET / et ses redirections) sans faire de cette promesse BiDi la preuve
|
||||
// de disponibilité. Le même onglet est indispensable aux brouillons
|
||||
// sessionStorage du scénario A/B.
|
||||
export async function resumeAutomaticSessionAfterOwnedRestart(page, origin, label = "restart") {
|
||||
const expectedOrigin = new URL(origin).origin;
|
||||
assert.equal(page.url(), "about:blank", `${label}: page détachée attendue`);
|
||||
// WHY: le document racine peut être mis en cache par Firefox après l'arrêt
|
||||
// de l'instance possédée. Désactiver ce cache pour cette reprise impose le
|
||||
// GET / réel qui crée le nouveau cookie HttpOnly via la redirection 303.
|
||||
await page.setCacheEnabled(false);
|
||||
await replaceLocation(page, origin);
|
||||
await waitForResumedDocument(page, expectedOrigin);
|
||||
try {
|
||||
return await assertAutomaticSessionReady(page, expectedOrigin, label);
|
||||
} catch (error) {
|
||||
if (!new Set(["LABFY_WORKBENCH_UNAVAILABLE", "LABFY_SESSION_UNAVAILABLE"])
|
||||
.has(error?.code)) throw error;
|
||||
// INVARIANT: cette seule relance ne s'applique qu'après une navigation
|
||||
// racine valide et une session API 200 ; elle ne peut pas masquer un 401,
|
||||
// un CSRF absent, une mauvaise origine ou une page login/bootstrap.
|
||||
if (error.code === "LABFY_WORKBENCH_UNAVAILABLE") await assertSessionApi(page, label);
|
||||
await replaceLocation(page, origin);
|
||||
await waitForResumedDocument(page, expectedOrigin);
|
||||
return assertAutomaticSessionReady(page, expectedOrigin, label);
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceLocation(page, origin) {
|
||||
await page.evaluate((nextOrigin) => {
|
||||
// INVARIANT: le timer laisse l'évaluation se terminer avant le changement
|
||||
// de document, qui détruirait autrement son ExecutionContext.
|
||||
window.setTimeout(() => window.location.replace(nextOrigin), 0);
|
||||
}, origin);
|
||||
}
|
||||
|
||||
async function waitForResumedDocument(page, expectedOrigin) {
|
||||
await page.waitForFunction((expectedOrigin) =>
|
||||
location.origin === expectedOrigin &&
|
||||
(document.readyState === "interactive" || document.readyState === "complete"),
|
||||
{}, expectedOrigin);
|
||||
}
|
||||
|
||||
async function assertAutomaticSessionReady(page, origin, label) {
|
||||
assert.equal(await page.evaluate(() => location.origin), origin,
|
||||
`${label}: origine de reprise incorrecte`);
|
||||
try {
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector("#library-home:not([hidden]), #workbench-shell:not([hidden])") !== null,
|
||||
{ timeout: 15000 });
|
||||
} catch (error) {
|
||||
const diagnostic = await page.evaluate(() => ({
|
||||
href: location.href,
|
||||
readyState: document.readyState,
|
||||
connection: document.querySelector("#library-connection")?.textContent ?? "",
|
||||
state: window.__LABFY_TEST__?.getState?.() ?? null,
|
||||
body: document.body?.textContent?.slice(0, 500) ?? "",
|
||||
})).catch((diagnosticError) => ({ evaluationError: String(diagnosticError) }));
|
||||
const unavailable = new Error(
|
||||
`${label}: workbench indisponible après reprise ${JSON.stringify(diagnostic)}`,
|
||||
{ cause: error });
|
||||
unavailable.code = "LABFY_WORKBENCH_UNAVAILABLE";
|
||||
throw unavailable;
|
||||
}
|
||||
assert.equal(await page.$("#bootstrap-code"), null);
|
||||
assert.equal(await page.$("#login-form"), null);
|
||||
assert.doesNotMatch(await page.$eval("body", (body) => body.textContent),
|
||||
/Code de session éphémère/);
|
||||
const session = await assertSessionApi(page, label);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function assertSessionApi(page, label) {
|
||||
const session = await page.evaluate(async () => {
|
||||
const response = await fetch("/api/v1/session", { cache: "no-store" });
|
||||
return { status: response.status, body: await response.json() };
|
||||
});
|
||||
if (session.status !== 200) {
|
||||
const unavailable = new Error(`${label}: session API indisponible (${session.status})`);
|
||||
unavailable.code = "LABFY_SESSION_UNAVAILABLE";
|
||||
throw unavailable;
|
||||
}
|
||||
assert.equal(typeof session.body.csrf, "string", `${label}: CSRF absent`);
|
||||
assert.ok(session.body.csrf.length > 0, `${label}: CSRF vide`);
|
||||
return session.body;
|
||||
}
|
||||
|
|
@ -10,6 +10,9 @@ import {
|
|||
// CONTRACT: this ordered list is the complete browser-validation suite.
|
||||
export const scenarios = Object.freeze([
|
||||
"test_browser.mjs",
|
||||
// CONTRACT: le point d'entrée utilisateur ouvre directement le workbench,
|
||||
// établit le cookie local et refuse toute origine de mutation étrangère.
|
||||
"test_direct_session_browser.mjs",
|
||||
"test_core_browser.mjs",
|
||||
"test_eml_browser.mjs",
|
||||
"test_local_toolkit_browser.mjs",
|
||||
|
|
@ -19,6 +22,9 @@ export const scenarios = Object.freeze([
|
|||
"test_j9_browser.mjs",
|
||||
"test_workbench_browser.mjs",
|
||||
"test_workbench_populated_browser.mjs",
|
||||
// CONTRACT: cette vérification Firefox inspecte les tailles cibles, le
|
||||
// tiroir, les panneaux persistants et la surface Agent uniquement sur fixture.
|
||||
"test_visual_polish_browser.mjs",
|
||||
"test_local_workspace_import_browser.mjs",
|
||||
// Le scénario import/rapport précédent et ce cycle du vrai launcher forment
|
||||
// la preuve E2E : données SPECIMEN, rapport, bibliothèque, redémarrages et A/B.
|
||||
|
|
|
|||
89
prototypes/web-graph/tests/test_direct_session_browser.mjs
Normal file
89
prototypes/web-graph/tests/test_direct_session_browser.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url);
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
|
||||
function startLauncher(library, runtime, state) {
|
||||
const result = spawnSync("./tools/labfy", ["--library", library, "--port", "0"], {
|
||||
cwd: REPOSITORY,
|
||||
env: { ...process.env, XDG_RUNTIME_DIR: runtime, XDG_STATE_HOME: state },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const match = result.stdout.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//);
|
||||
assert.ok(match, `origine du lanceur absente: ${result.stdout}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function stopLauncher(runtime, state) {
|
||||
const result = spawnSync("python3", ["web_app.py", "stop"], {
|
||||
cwd: PROTOTYPE,
|
||||
env: { ...process.env, XDG_RUNTIME_DIR: runtime, XDG_STATE_HOME: state },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
}
|
||||
|
||||
function requestEvilOrigin(origin, cookie, csrf) {
|
||||
const body = JSON.stringify({ title: "Refusé SPECIMEN",
|
||||
idempotency_key: "87000000-0000-4000-8000-000000000002" });
|
||||
return new Promise((resolve, reject) => {
|
||||
const target = new URL("/api/v1/library/workspaces", origin);
|
||||
const request = http.request(target, { method: "POST", headers: {
|
||||
Cookie: `${cookie.name}=${cookie.value}`, Origin: "http://evil.example",
|
||||
"Content-Type": "application/json", "Content-Length": Buffer.byteLength(body),
|
||||
"X-Labfy-CSRF": csrf } }, (response) => {
|
||||
response.resume();
|
||||
response.once("end", () => resolve(response.statusCode));
|
||||
});
|
||||
request.once("error", reject);
|
||||
request.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
const library = await mkdtemp(join(tmpdir(), "labfy-direct-library-SPECIMEN-"));
|
||||
const profile = await mkdtemp(join(tmpdir(), "labfy-direct-firefox-SPECIMEN-"));
|
||||
const runtime = await mkdtemp(join(tmpdir(), "labfy-direct-runtime-SPECIMEN-"));
|
||||
const state = await mkdtemp(join(tmpdir(), "labfy-direct-state-SPECIMEN-"));
|
||||
let browser;
|
||||
let started = false;
|
||||
try {
|
||||
const origin = startLauncher(library, runtime, state);
|
||||
started = true;
|
||||
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
|
||||
headless: true, userDataDir: profile, args: ["--no-remote"] });
|
||||
const page = await browser.newPage();
|
||||
const session = await openAutomaticSession(page, origin);
|
||||
assert.equal(await page.$("#login-form"), null);
|
||||
assert.equal(await page.$("#bootstrap-code"), null);
|
||||
assert.equal((await page.goto(`${origin}/login.html`)).status(), 404);
|
||||
assert.equal((await page.goto(`${origin}/login.js`)).status(), 404);
|
||||
await page.goto(origin, { waitUntil: "domcontentloaded" });
|
||||
const valid = await page.evaluate(async ({ csrf }) => {
|
||||
const valid = await fetch("/api/v1/library/workspaces", {
|
||||
method: "POST", headers: { "Content-Type": "application/json",
|
||||
"X-Labfy-CSRF": csrf },
|
||||
body: JSON.stringify({ title: "Direct SPECIMEN",
|
||||
idempotency_key: "87000000-0000-4000-8000-000000000001" }),
|
||||
});
|
||||
return valid.status;
|
||||
}, session);
|
||||
assert.equal(valid, 201);
|
||||
const cookie = (await page.cookies()).find((item) => item.name.startsWith("labfy_session_"));
|
||||
assert.ok(cookie);
|
||||
assert.equal(await requestEvilOrigin(origin, cookie, session.csrf), 403);
|
||||
console.log("PASS session directe Firefox : lanceur, workbench, CSRF, Origin et arrêt");
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (started) stopLauncher(runtime, state);
|
||||
await Promise.all([library, profile, runtime, state].map((path) =>
|
||||
rm(path, { recursive: true, force: true })));
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
const REPOSITORY = new URL("../../..", import.meta.url),
|
||||
PROTOTYPE = new URL("..", import.meta.url);
|
||||
const FIREFOX_PATH = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
|
|
@ -16,11 +17,10 @@ function ready(server) {
|
|||
);
|
||||
server.stdout.on("data", (chunk) => {
|
||||
text += chunk;
|
||||
const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//),
|
||||
c = text.match(/Code de session éphémère : ([^\s]+)/);
|
||||
if (u && c) {
|
||||
const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
|
||||
if (u) {
|
||||
clearTimeout(timer);
|
||||
resolve({ url: u[0], code: c[1] });
|
||||
resolve({ url: u[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) =>
|
||||
|
|
@ -51,14 +51,6 @@ async function stop(server) {
|
|||
server.kill("SIGINT");
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
async function login(page, session) {
|
||||
await page.goto(session.url, { waitUntil: "domcontentloaded" });
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button"),
|
||||
]);
|
||||
}
|
||||
const workspace = await mkdtemp(join(tmpdir(), "labfy-j8-browser-")),
|
||||
profile = await mkdtemp(join(tmpdir(), "labfy-j8-firefox-"));
|
||||
let browser, server;
|
||||
|
|
@ -97,7 +89,7 @@ try {
|
|||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await login(page, session);
|
||||
await openAutomaticSession(page, session.url);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelectorAll("#planner-list input:not(:disabled)").length >=
|
||||
|
|
@ -182,7 +174,7 @@ try {
|
|||
server = null;
|
||||
server = start(workspace);
|
||||
const restarted = await ready(server);
|
||||
await login(page, restarted);
|
||||
await openAutomaticSession(page, restarted.url);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@ import {tmpdir} from "node:os";
|
|||
import {join} from "node:path";
|
||||
import {spawn, spawnSync} from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY=new URL("../../..",import.meta.url),PROTOTYPE=new URL("..",import.meta.url);
|
||||
const FIREFOX_PATH=process.env.FIREFOX_PATH??"/usr/bin/firefox";
|
||||
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
function ready(server){return new Promise((resolve,reject)=>{let output="";const timer=setTimeout(()=>reject(new Error("démarrage runtime J8 expiré")),8000);server.stdout.on("data",chunk=>{output+=chunk;const url=output.match(/http:\/\/127\.0\.0\.1:(\d+)\//),code=output.match(/Code de session éphémère : ([^\s]+)/);if(url&&code){clearTimeout(timer);resolve({url:url[0],code:code[1]});}});});}
|
||||
function ready(server){return new Promise((resolve,reject)=>{let output="";const timer=setTimeout(()=>reject(new Error("démarrage runtime J8 expiré")),8000);server.stdout.on("data",chunk=>{output+=chunk;const url=output.match(/http:\/\/127\.0\.0\.1:(\d+)\//);if(url){clearTimeout(timer);resolve({url:url[0]});}});});}
|
||||
async function waitFile(path){for(let i=0;i<200;i++){try{await access(path);return;}catch{}await delay(10);}throw new Error(`barrière absente: ${path}`);}
|
||||
async function login(page,session){await page.goto(session.url,{waitUntil:"domcontentloaded"});await page.type("#bootstrap-code",session.code);await Promise.all([page.waitForNavigation({waitUntil:"domcontentloaded"}),page.click("#login-form button")]);}
|
||||
async function post(page,path,body){return page.evaluate(async({path,body})=>{const session=await fetch("/api/v1/session").then(r=>r.json());const response=await fetch(path,{method:"POST",headers:{"Content-Type":"application/json","X-Labfy-CSRF":session.csrf},body:JSON.stringify(body)});return {status:response.status,body:await response.json()};},{path,body});}
|
||||
async function scenario(kind){const workspace=await mkdtemp(join(tmpdir(),`labfy-j8-${kind}-`)),profile=await mkdtemp(join(tmpdir(),`labfy-j8-${kind}-firefox-`));let browser,server;try{
|
||||
assert.equal(spawnSync("./tools/local-jobs",["init-j7-specimen","--workspace",workspace],{cwd:REPOSITORY,encoding:"utf8"}).status,0);spawnSync("./tools/local-jobs",["export","--workspace",workspace],{cwd:REPOSITORY});
|
||||
const maximum=kind==="limit"?"100":"5000";server=spawn("python3",["workspace_server.py","--workspace",workspace,"--bridge","../../tools/local-jobs-test","--port","0"],{cwd:PROTOTYPE,env:{...process.env,PYTHONUNBUFFERED:"1",LABFY_TEST_PLAN_ACTIVE_MS:maximum,LABFY_TEST_TOOL_DELAY_MS:"2000"},stdio:["ignore","pipe","pipe"]});const session=await ready(server);
|
||||
browser=await puppeteer.launch({browser:"firefox",executablePath:FIREFOX_PATH,headless:true,userDataDir:profile,args:["--no-remote"]});const page=await browser.newPage();await login(page,session);const planner=JSON.parse(await readFile(join(workspace,"planner-snapshot.json"))),ids=planner.recommendations.filter(x=>x.kind==="ANALYSIS"&&x.available).slice(0,kind==="limit"?2:1).map(x=>x.id);const response=await post(page,"/api/v1/plans",{recommendation_ids:ids,input_revision:planner.input_revision,profile_id:"SPECIMEN_SMALL",idempotency_key:kind==="limit"?"89100000-0000-4000-8000-000000000001":"89200000-0000-4000-8000-000000000001"});assert.equal(response.status,202);const marker=join(workspace,".labfy/runtime/test-tool-started");await waitFile(marker);
|
||||
browser=await puppeteer.launch({browser:"firefox",executablePath:FIREFOX_PATH,headless:true,userDataDir:profile,args:["--no-remote"]});const page=await browser.newPage();await openAutomaticSession(page,session.url);const planner=JSON.parse(await readFile(join(workspace,"planner-snapshot.json"))),ids=planner.recommendations.filter(x=>x.kind==="ANALYSIS"&&x.available).slice(0,kind==="limit"?2:1).map(x=>x.id);const response=await post(page,"/api/v1/plans",{recommendation_ids:ids,input_revision:planner.input_revision,profile_id:"SPECIMEN_SMALL",idempotency_key:kind==="limit"?"89100000-0000-4000-8000-000000000001":"89200000-0000-4000-8000-000000000001"});assert.equal(response.status,202);const marker=join(workspace,".labfy/runtime/test-tool-started");await waitFile(marker);
|
||||
if(kind==="cancel"){const jobId=spawnSync("sqlite3",[join(workspace,".labfy/runtime/jobs.sqlite"),"SELECT job_id FROM jobs WHERE state='RUNNING';"],{encoding:"utf8"}).stdout.trim();assert.ok(jobId);const pid=Number((await readFile(marker,"utf8")).trim());assert.equal((await post(page,`/api/v1/jobs/${jobId}/cancel`,{})).status,200);await page.waitForFunction(()=>[...document.querySelectorAll("#jobs-list li")].some(x=>x.dataset.jobState==="CANCELLED"),{timeout:10000});assert.throws(()=>process.kill(pid,0));}
|
||||
else {await page.waitForFunction(()=>document.querySelector("#plans-list")?.textContent.includes("LIMIT_REACHED"),{timeout:10000});const snapshot=JSON.parse(await readFile(join(workspace,"jobs-snapshot.json")));assert.equal(snapshot.jobs.filter(x=>x.state==="BLOCKED").length,1);assert.equal(snapshot.jobs.filter(x=>x.state==="QUEUED").length,1);assert.equal(snapshot.plans[0].remaining_active_ms,0);}
|
||||
}finally{if(browser)await browser.close();if(server){server.kill("SIGINT");await new Promise(resolve=>server.once("exit",resolve));}await rm(profile,{recursive:true,force:true});await rm(workspace,{recursive:true,force:true});}}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
const REPOSITORY = new URL("../../..", import.meta.url),
|
||||
PROTOTYPE = new URL("..", import.meta.url),
|
||||
FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
|
|
@ -17,11 +18,10 @@ function ready(server) {
|
|||
);
|
||||
server.stdout.on("data", (chunk) => {
|
||||
text += chunk;
|
||||
const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//),
|
||||
c = text.match(/Code de session éphémère : ([^\s]+)/);
|
||||
if (u && c) {
|
||||
const u = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
|
||||
if (u) {
|
||||
clearTimeout(timer);
|
||||
resolve({ url: u[0], code: c[1] });
|
||||
resolve({ url: u[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) =>
|
||||
|
|
@ -52,14 +52,6 @@ async function stop(server) {
|
|||
server.kill("SIGINT");
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
async function login(page, session) {
|
||||
await page.goto(session.url, { waitUntil: "domcontentloaded" });
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button"),
|
||||
]);
|
||||
}
|
||||
const workspace = await mkdtemp(join(tmpdir(), "labfy-j9-browser-")),
|
||||
profile = await mkdtemp(join(tmpdir(), "labfy-j9-firefox-"));
|
||||
let browser, server;
|
||||
|
|
@ -96,7 +88,7 @@ try {
|
|||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await login(page, session);
|
||||
await openAutomaticSession(page, session.url);
|
||||
await page.waitForSelector("#view-evidence");
|
||||
await page.click("#view-evidence");
|
||||
await page.click("#view-timeline");
|
||||
|
|
|
|||
|
|
@ -29,8 +29,7 @@ class LocalWorkspaceImportTest(unittest.TestCase):
|
|||
self.workspace = Path(self.temporary.name) / "workspace"
|
||||
self.workspace.mkdir(mode=0o700)
|
||||
self.server = WorkspaceServer(("127.0.0.1", 0), Handler,
|
||||
workspace=self.workspace, bridge=REPOSITORY / "tools" / "local-jobs",
|
||||
bootstrap="SPECIMEN-bootstrap")
|
||||
workspace=self.workspace, bridge=REPOSITORY / "tools" / "local-jobs")
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.cookie = None
|
||||
|
|
@ -55,9 +54,8 @@ class LocalWorkspaceImportTest(unittest.TestCase):
|
|||
return response.status, json.loads(data) if data else None
|
||||
|
||||
def login_create(self):
|
||||
status, _ = self.request("POST", "/api/v1/session",
|
||||
{"bootstrap_code":"SPECIMEN-bootstrap"}, csrf=False)
|
||||
self.assertEqual(status, 200)
|
||||
status, _ = self.request("GET", "/", csrf=False)
|
||||
self.assertEqual(status, 303)
|
||||
status, value = self.request("POST", "/api/v1/workspace",
|
||||
{"title":"Dossier synthétique Ω"})
|
||||
self.assertEqual(status, 201); return value
|
||||
|
|
@ -67,20 +65,19 @@ class LocalWorkspaceImportTest(unittest.TestCase):
|
|||
"init-specimen", "--workspace", str(self.workspace)],
|
||||
cwd=REPOSITORY, text=True, capture_output=True, check=False)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
status, _ = self.request("POST", "/api/v1/session",
|
||||
{"bootstrap_code":"SPECIMEN-bootstrap"}, csrf=False)
|
||||
self.assertEqual(status, 200)
|
||||
status, _ = self.request("GET", "/", csrf=False)
|
||||
self.assertEqual(status, 303)
|
||||
return json.loads((self.workspace / ".labfy/runtime/specimen.json").read_text())
|
||||
|
||||
def restart_server(self):
|
||||
self.server.shutdown(); self.server.server_close(); self.thread.join(3)
|
||||
self.server = WorkspaceServer(("127.0.0.1", 0), Handler,
|
||||
workspace=self.workspace, bridge=REPOSITORY / "tools" / "local-jobs",
|
||||
bootstrap="SPECIMEN-bootstrap")
|
||||
workspace=self.workspace, bridge=REPOSITORY / "tools" / "local-jobs")
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start(); self.cookie = None
|
||||
status, _ = self.request("POST", "/api/v1/session",
|
||||
{"bootstrap_code":"SPECIMEN-bootstrap"}, csrf=False)
|
||||
status, _ = self.request("GET", "/", csrf=False)
|
||||
self.assertEqual(status, 303)
|
||||
status, _ = self.request("GET", "/api/v1/session", csrf=False)
|
||||
self.assertEqual(status, 200)
|
||||
|
||||
def prepare(self, name, data, declared="application/octet-stream"):
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const PROTOTYPE=new URL("..",import.meta.url),FIREFOX=process.env.FIREFOX_PATH??"/usr/bin/firefox";
|
||||
function ready(server){return new Promise((resolve,reject)=>{let out="";const timer=setTimeout(()=>reject(new Error("démarrage import expiré")),10000);server.stdout.on("data",chunk=>{out+=chunk;const u=out.match(/http:\/\/127\.0\.0\.1:\d+\//),c=out.match(/Code de session éphémère : ([^\s]+)/);if(u&&c){clearTimeout(timer);resolve({url:u[0],code:c[1]});}});server.once("exit",code=>reject(new Error(`serveur import arrêté (${code})`)));});}
|
||||
async function login(page,session){await page.goto(session.url,{waitUntil:"domcontentloaded"});await page.type("#bootstrap-code",session.code);await Promise.all([page.waitForNavigation({waitUntil:"domcontentloaded"}),page.click("#login-form button")]);}
|
||||
function ready(server){return new Promise((resolve,reject)=>{let out="";const timer=setTimeout(()=>reject(new Error("démarrage import expiré")),10000);server.stdout.on("data",chunk=>{out+=chunk;const u=out.match(/http:\/\/127\.0\.0\.1:\d+\//);if(u){clearTimeout(timer);resolve({url:u[0]});}});server.once("exit",code=>reject(new Error(`serveur import arrêté (${code})`)));});}
|
||||
async function waitForDownload(path){let size=-1,stable=0;for(let attempt=0;attempt<120&&stable<3;attempt++){await new Promise(resolve=>setTimeout(resolve,50));try{const current=(await stat(path)).size;stable=current>0&¤t===size?stable+1:0;size=current;}catch{stable=0;}}assert.ok(stable>=3,`téléchargement Firefox terminé ${path}`);return size;}
|
||||
|
||||
const root=await mkdtemp(join(tmpdir(),"labfy-local-import-browser-")),workspace=join(root,"workspace"),fixtures=join(root,"SPECIMEN_INPUT"),profile=join(root,"firefox"),downloads=join(root,"downloads");
|
||||
|
|
@ -23,7 +23,7 @@ let server,browser;
|
|||
try{
|
||||
server=spawn("python3",["workspace_server.py","--workspace",workspace,"--bridge","../../tools/local-jobs","--port","0"],{cwd:PROTOTYPE,env:{...process.env,PYTHONUNBUFFERED:"1"},stdio:["ignore","pipe","pipe"]});
|
||||
const session=await ready(server);browser=await puppeteer.launch({browser:"firefox",executablePath:FIREFOX,headless:true,userDataDir:profile,args:["--no-remote"],extraPrefsFirefox:{"browser.download.folderList":2,"browser.download.dir":downloads,"browser.download.useDownloadDir":true,"browser.helperApps.neverAsk.saveToDisk":"text/html,application/json,application/pdf,text/plain"}});
|
||||
const page=await browser.newPage(),errors=[];page.on("pageerror",e=>errors.push(String(e)));await page.setViewport({width:1440,height:900});await login(page,session);
|
||||
const page=await browser.newPage(),errors=[];page.on("pageerror",e=>errors.push(String(e)));await page.setViewport({width:1440,height:900});await openAutomaticSession(page,session.url);
|
||||
await page.waitForSelector("#workspace-create:not([hidden])");await page.type("#workspace-title","Enquête Firefox locale Ω");await page.click("#workspace-create-form button");await page.waitForSelector("#workspace-create[hidden]");
|
||||
await page.click("#open-import");const input=await page.$("#import-files");await input.uploadFile(eml,eml2,copy,png,jpg);
|
||||
await page.waitForFunction(()=>document.querySelectorAll("#import-list li").length===5&&[...document.querySelectorAll("#import-list li")].every(x=>x.textContent.includes("préparé")),{timeout:20000});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url);
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
|
|
@ -17,10 +18,9 @@ function ready(server) {
|
|||
server.stdout.on("data", (chunk) => {
|
||||
text += chunk;
|
||||
const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
|
||||
const code = text.match(/Code de session éphémère : ([^\s]+)/);
|
||||
if (url && code) {
|
||||
if (url) {
|
||||
clearTimeout(timer);
|
||||
resolve({ url: url[0], code: code[1] });
|
||||
resolve({ url: url[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) => reject(new Error(`serveur arrêté (${code})`)));
|
||||
|
|
@ -42,12 +42,6 @@ async function stop(server) {
|
|||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
|
||||
async function login(page, session) {
|
||||
await page.goto(session.url, { waitUntil: "domcontentloaded" });
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button")]);
|
||||
}
|
||||
|
||||
function init(workspace) {
|
||||
const result = spawnSync("./tools/local-jobs",
|
||||
|
|
@ -81,7 +75,7 @@ try {
|
|||
headless: true, userDataDir: profile, args: ["--no-remote"] });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 900 });
|
||||
await login(page, sessionA);
|
||||
await openAutomaticSession(page, sessionA.url);
|
||||
await page.waitForSelector("#object-list button");
|
||||
await page.click("#object-list button");
|
||||
await page.click('[data-work-panel="research-panel"]');
|
||||
|
|
@ -108,7 +102,7 @@ try {
|
|||
|
||||
await stop(server); server = start(workspaceB, authority);
|
||||
const sessionB = await ready(server); const pageB = await browser.newPage();
|
||||
await login(pageB, sessionB);
|
||||
await openAutomaticSession(pageB, sessionB.url);
|
||||
await pageB.click('[data-work-panel="research-panel"]');
|
||||
assert.equal(await pageB.$$eval(".research-card", (nodes) => nodes.length), 0);
|
||||
assert.match(await pageB.$eval("#research-note", (node) => node.textContent),
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ class ResearchWebTest(unittest.TestCase):
|
|||
subprocess.run([str(cls.bridge), "export", "--workspace", str(workspace)],
|
||||
cwd=cls.repository, check=True, timeout=15)
|
||||
server = WorkspaceServer(("127.0.0.1", 0), Handler,
|
||||
workspace=workspace, bridge=cls.bridge, bootstrap="SPECIMEN-RESEARCH",
|
||||
workspace=workspace, bridge=cls.bridge,
|
||||
research_fixture_authority=f"127.0.0.1:{cls.provider.server_port}")
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start(); cls.instances.append((server, thread))
|
||||
|
|
@ -87,10 +87,9 @@ class ResearchWebTest(unittest.TestCase):
|
|||
result = response.status, dict(response.getheaders()), data
|
||||
connection.close(); return result
|
||||
|
||||
def login(self, server):
|
||||
status, headers, _ = self.request(server, "POST", "/api/v1/session",
|
||||
{"bootstrap_code": "SPECIMEN-RESEARCH"}, origin=server.origin)
|
||||
self.assertEqual(status, 200)
|
||||
def open_automatic_session(self, server):
|
||||
status, headers, _ = self.request(server, "GET", "/")
|
||||
self.assertEqual(status, 303)
|
||||
cookie = headers["Set-Cookie"].split(";", 1)[0]
|
||||
status, _, body = self.request(server, "GET", "/api/v1/session",
|
||||
cookie=cookie)
|
||||
|
|
@ -105,7 +104,7 @@ class ResearchWebTest(unittest.TestCase):
|
|||
def test_two_waves_refusal_never_contacts_and_workspace_b_isolated(self):
|
||||
Provider.requests.clear()
|
||||
workspace_a, server_a = self.start_workspace("A")
|
||||
cookie_a, csrf_a = self.login(server_a)
|
||||
cookie_a, csrf_a = self.open_automatic_session(server_a)
|
||||
graph = json.loads((workspace_a / "core-snapshot.json").read_text())
|
||||
seed = graph["nodes"][0]["id"]
|
||||
prepared = {"selection_ids": [seed],
|
||||
|
|
@ -191,7 +190,7 @@ class ResearchWebTest(unittest.TestCase):
|
|||
self.assertEqual(len(list((workspace_a / ".labfy/research").glob("*.txt"))), 2)
|
||||
|
||||
_, server_b = self.start_workspace("B")
|
||||
cookie_b, _ = self.login(server_b)
|
||||
cookie_b, _ = self.open_automatic_session(server_b)
|
||||
status, _, body = self.request(server_b, "GET", "/api/v1/research",
|
||||
cookie=cookie_b)
|
||||
self.assertEqual(status, 200); self.assertEqual(json.loads(body)["state"], "EMPTY")
|
||||
|
|
@ -202,7 +201,7 @@ class ResearchWebTest(unittest.TestCase):
|
|||
def test_result_failure_compensates_final_artifact(self):
|
||||
Provider.requests.clear()
|
||||
workspace, server = self.start_workspace("artifact-failure")
|
||||
cookie, csrf = self.login(server)
|
||||
cookie, csrf = self.open_automatic_session(server)
|
||||
graph = json.loads((workspace / "core-snapshot.json").read_text())
|
||||
prepared = {"selection_ids": [graph["nodes"][0]["id"]],
|
||||
"question": "Faute SQLite SPECIMEN ?", "exclusions": [],
|
||||
|
|
@ -243,7 +242,7 @@ class ResearchWebTest(unittest.TestCase):
|
|||
Provider.bodies["/specimen/wave-1"] = b"x" * 4097
|
||||
try:
|
||||
workspace, server = self.start_workspace("response-limit")
|
||||
cookie, csrf = self.login(server)
|
||||
cookie, csrf = self.open_automatic_session(server)
|
||||
graph = json.loads((workspace / "core-snapshot.json").read_text())
|
||||
prepared = {"selection_ids": [graph["nodes"][0]["id"]],
|
||||
"question": "Borne corps SPECIMEN ?", "exclusions": [],
|
||||
|
|
@ -281,7 +280,7 @@ class ResearchWebTest(unittest.TestCase):
|
|||
def test_crash_after_rename_reconciles_without_recontact(self):
|
||||
Provider.requests.clear()
|
||||
workspace, server = self.start_workspace("crash-reconcile")
|
||||
cookie, csrf = self.login(server)
|
||||
cookie, csrf = self.open_automatic_session(server)
|
||||
graph = json.loads((workspace / "core-snapshot.json").read_text())
|
||||
prepared = {"selection_ids": [graph["nodes"][0]["id"]],
|
||||
"question": "Reprise publication SPECIMEN ?", "exclusions": [],
|
||||
|
|
@ -328,7 +327,7 @@ class ResearchWebTest(unittest.TestCase):
|
|||
|
||||
def test_research_routes_keep_host_origin_csrf_and_shape_guards(self):
|
||||
_, server = self.start_workspace("guards")
|
||||
cookie, csrf = self.login(server)
|
||||
cookie, csrf = self.open_automatic_session(server)
|
||||
self.assertEqual(self.request(server, "GET", "/api/v1/research",
|
||||
cookie=cookie, host="evil.test")[0], 403)
|
||||
value = {"selection_ids": ["x"], "question": "SPECIMEN",
|
||||
|
|
|
|||
|
|
@ -59,10 +59,8 @@ class SessionLifetimeTest(unittest.TestCase):
|
|||
connection.close()
|
||||
|
||||
def login(self):
|
||||
status, headers, body = self.request("POST", "/api/v1/session",
|
||||
body={"bootstrap_code": "code-SPECIMEN-only"})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(json.loads(body)["authenticated"])
|
||||
status, headers, body = self.request("GET", "/")
|
||||
self.assertEqual(status, 303)
|
||||
self.assertIn("HttpOnly", headers["Set-Cookie"])
|
||||
self.assertIn("SameSite=Strict", headers["Set-Cookie"])
|
||||
self.assertIn("Max-Age=3600", headers["Set-Cookie"])
|
||||
|
|
@ -105,19 +103,17 @@ class SessionLifetimeTest(unittest.TestCase):
|
|||
self.assertNotIn(b'id="login-form"', html)
|
||||
self.assertEqual(self.server.session_deadline, self.now + 3600)
|
||||
|
||||
def test_invalid_login_never_extends_or_replaces_a_session(self):
|
||||
def test_bootstrap_route_is_absent_and_never_extends_a_session(self):
|
||||
cookie = self.login()
|
||||
before = (self.server.session, self.server.csrf, self.server.session_deadline)
|
||||
for expired in (False, True):
|
||||
if expired:
|
||||
self.now += 3601
|
||||
for code, origin in (("wrong-SPECIMEN", self.server.origin),
|
||||
("code-SPECIMEN-only", "http://foreign.test")):
|
||||
status, _, _ = self.request("POST", "/api/v1/session",
|
||||
body={"bootstrap_code": code}, origin=origin)
|
||||
self.assertEqual(status, 403)
|
||||
self.assertEqual((self.server.session, self.server.csrf,
|
||||
self.server.session_deadline), before)
|
||||
status, _, _ = self.request("POST", "/api/v1/session",
|
||||
body={}, origin=self.server.origin)
|
||||
self.assertEqual(status, 404)
|
||||
self.assertEqual((self.server.session, self.server.csrf,
|
||||
self.server.session_deadline), before)
|
||||
self.assertEqual(self.request("GET", "/api/v1/session", cookie=cookie)[0],
|
||||
401 if expired else 200)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
|
|
@ -21,11 +22,11 @@ inactive.mkdir(mode=0o700, exist_ok=True)
|
|||
# CONTRACT: expiration réelle et courte, injectée uniquement dans ce processus
|
||||
# de test. Aucune route ne permet de modifier l'horloge ou le TTL.
|
||||
server = WorkspaceServer(("127.0.0.1", 0), Handler, workspace=inactive,
|
||||
bridge=bridge, bootstrap="session-browser-SPECIMEN", library=library,
|
||||
bridge=bridge, library=library,
|
||||
instance_id="session-browser-SPECIMEN", config_id="session-browser-SPECIMEN",
|
||||
session_ttl_seconds=5)
|
||||
session_ttl_seconds=8)
|
||||
print(json.dumps({"origin": server.origin,
|
||||
"code": "session-browser-SPECIMEN"}), flush=True)
|
||||
"contract": "labfy.session-lifetime.fixture.v1"}), flush=True)
|
||||
signal.signal(signal.SIGTERM,
|
||||
lambda _signum, _frame: threading.Thread(target=server.shutdown,
|
||||
daemon=True).start())
|
||||
|
|
@ -71,17 +72,6 @@ async function stopServer(server) {
|
|||
await exited;
|
||||
}
|
||||
|
||||
async function login(page, session) {
|
||||
await page.goto(session.origin, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector("#login-form");
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button"),
|
||||
]);
|
||||
await page.waitForSelector("#library-home:not([hidden])");
|
||||
}
|
||||
|
||||
async function createAndOpenSpecimen(page) {
|
||||
const title = "Enquête session Firefox SPECIMEN";
|
||||
await page.type("#workspace-title", title);
|
||||
|
|
@ -109,9 +99,6 @@ try {
|
|||
const session = await ready(server);
|
||||
assert.notEqual(new URL(session.origin).port, "8081");
|
||||
assert.notEqual(new URL(session.origin).port, "8080");
|
||||
// CONTRACT: le serveur peut être déjà ancien quand Firefox soumet son premier
|
||||
// formulaire. La connexion réussie doit alors commencer une nouvelle durée.
|
||||
await delay(5500);
|
||||
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
|
||||
headless: true, userDataDir: profile, args: ["--no-remote"] });
|
||||
const page = await browser.newPage();
|
||||
|
|
@ -123,7 +110,7 @@ try {
|
|||
unauthorized.push(response.url());
|
||||
});
|
||||
|
||||
await login(page, session);
|
||||
await openAutomaticSession(page, session.origin);
|
||||
const workspaceId = await createAndOpenSpecimen(page);
|
||||
assert.ok(workspaceId);
|
||||
await page.$eval("#report-comment", (input) => {
|
||||
|
|
@ -138,18 +125,14 @@ try {
|
|||
cookie.name.startsWith("labfy_session_"));
|
||||
assert.ok(oldCookie);
|
||||
|
||||
// WHY: un polling réel doit découvrir l'expiration et arrêter ses trois
|
||||
// pairs ; le test n'appelle aucune route d'administration.
|
||||
await page.waitForSelector("#session-reconnect", { timeout: 10000 });
|
||||
const expiredState = await page.evaluate(() => window.__LABFY_TEST__.getState());
|
||||
assert.equal(expiredState.sessionExpired, true);
|
||||
assert.equal(expiredState.refreshTimerCount, 0);
|
||||
assert.equal(expiredState.hasCsrf, false);
|
||||
assert.equal(expiredState.workspaceId, null);
|
||||
assert.equal(await page.$eval("#workbench-shell", (item) => item.hidden), true);
|
||||
assert.match(await page.$eval("#library-connection", (item) => item.textContent),
|
||||
/travail en cours a été arrêté/i);
|
||||
assert.equal(await page.$$eval("#session-reconnect", (items) => items.length), 1);
|
||||
// WHY: le polling réel observe un seul 401 puis la navigation racine détruit
|
||||
// son contexte ; aucune route d'administration ne force l'expiration.
|
||||
const automaticReturn = page.waitForNavigation({ waitUntil: "domcontentloaded",
|
||||
timeout: 10000 });
|
||||
await automaticReturn;
|
||||
await page.waitForSelector("#library-home:not([hidden])", { timeout: 10000 });
|
||||
assert.equal(await page.$("#login-form"), null);
|
||||
assert.equal(await page.$("#bootstrap-code"), null);
|
||||
const requestCount = unauthorized.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1400));
|
||||
assert.equal(unauthorized.length, requestCount,
|
||||
|
|
@ -158,14 +141,10 @@ try {
|
|||
assert.equal(await page.evaluate(() => Object.keys(sessionStorage)
|
||||
.some((key) => key.startsWith("labfy-report-draft:"))), true);
|
||||
|
||||
await page.click("#session-reconnect");
|
||||
await page.waitForSelector("#login-form");
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button"),
|
||||
]);
|
||||
await page.waitForSelector("#library-home:not([hidden])");
|
||||
const renewedCookie = (await page.cookies()).find((cookie) =>
|
||||
cookie.name.startsWith("labfy_session_"));
|
||||
assert.ok(renewedCookie);
|
||||
assert.notEqual(renewedCookie.value, oldCookie.value);
|
||||
const renewed = await page.evaluate(async () => {
|
||||
const response = await fetch("/api/v1/session", { cache: "no-store" });
|
||||
return response.json();
|
||||
|
|
@ -187,7 +166,7 @@ try {
|
|||
body: "{}",
|
||||
})).status, 403);
|
||||
assert.deepEqual(pageErrors, []);
|
||||
console.log("PASS Firefox session : expiration réelle, arrêt unique, reconnexion, " +
|
||||
console.log("PASS Firefox session : expiration réelle, arrêt unique, reconnexion automatique, " +
|
||||
"contexte SPECIMEN et rotation cookie/CSRF");
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
|
|
|
|||
173
prototypes/web-graph/tests/test_visual_polish_browser.mjs
Normal file
173
prototypes/web-graph/tests/test_visual_polish_browser.mjs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url);
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
|
||||
function ready(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
const timer = setTimeout(() => reject(new Error("démarrage expiré")), 10000);
|
||||
server.stdout.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
const url = output.match(/http:\/\/127\.0\.0\.1:\d+\//);
|
||||
if (url) {
|
||||
clearTimeout(timer);
|
||||
resolve({ url: url[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) => reject(new Error(`serveur arrêté (${code})`)));
|
||||
});
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
await page.screenshot({ path: `/tmp/labfy-visual-polish-${name}.png` });
|
||||
}
|
||||
|
||||
async function assertNoHorizontalOverflow(page) {
|
||||
assert.deepEqual(
|
||||
await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const workspace = await mkdtemp(join(tmpdir(), "labfy-visual-polish-"));
|
||||
const profile = await mkdtemp(join(tmpdir(), "labfy-visual-polish-firefox-"));
|
||||
let browser;
|
||||
let server;
|
||||
try {
|
||||
const init = spawnSync(
|
||||
"./tools/local-jobs",
|
||||
["init-j7-specimen", "--workspace", workspace],
|
||||
{ cwd: REPOSITORY, encoding: "utf8", timeout: 30000 },
|
||||
);
|
||||
assert.equal(init.status, 0, init.stderr);
|
||||
spawnSync("./tools/local-jobs", ["export", "--workspace", workspace], {
|
||||
cwd: REPOSITORY, timeout: 10000,
|
||||
});
|
||||
server = spawn("python3", [
|
||||
"workspace_server.py", "--workspace", workspace, "--bridge",
|
||||
"../../tools/local-jobs", "--port", "0",
|
||||
], {
|
||||
cwd: PROTOTYPE,
|
||||
env: { ...process.env, PYTHONUNBUFFERED: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const session = await ready(server);
|
||||
browser = await puppeteer.launch({
|
||||
browser: "firefox", executablePath: FIREFOX, headless: true,
|
||||
userDataDir: profile, args: ["--no-remote"],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await openAutomaticSession(page, session.url);
|
||||
await page.waitForSelector(".node");
|
||||
await assertNoHorizontalOverflow(page);
|
||||
await screenshot(page, "1440-network");
|
||||
|
||||
await page.focus("#agent-prompt");
|
||||
await page.type("#agent-prompt", "Prépare une piste SPECIMEN sans lancer d’action.");
|
||||
await page.keyboard.down("Control");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.up("Control");
|
||||
assert.equal(await page.$eval("#agent-prompt", (element) => element.value), "");
|
||||
assert.match(
|
||||
await page.$eval(".agent-conversation", (element) => element.textContent),
|
||||
/Prépare une piste SPECIMEN/,
|
||||
);
|
||||
await page.type("#agent-prompt", "Brouillon SPECIMEN à conserver pendant le défilement.");
|
||||
await page.$eval(".agent-scroll", (element) => { element.scrollTop = element.scrollHeight; });
|
||||
assert.deepEqual(
|
||||
await page.$eval("#agent-prompt", (element) => ({
|
||||
value: element.value,
|
||||
visible: element.getBoundingClientRect().bottom > 0,
|
||||
})),
|
||||
{ value: "Brouillon SPECIMEN à conserver pendant le défilement.", visible: true },
|
||||
);
|
||||
await page.click('[data-pane="agent-panel"]');
|
||||
await page.click('[data-pane="agent-panel"]');
|
||||
assert.equal(
|
||||
await page.$eval("#agent-prompt", (element) => element.value),
|
||||
"Brouillon SPECIMEN à conserver pendant le défilement.",
|
||||
);
|
||||
await screenshot(page, "1440-agent");
|
||||
|
||||
await page.click(".agent-card.authorization button");
|
||||
await screenshot(page, "1440-authorization");
|
||||
await page.select("#activity-filter", "TOOL");
|
||||
assert.equal(await page.$$eval("[data-activity-type]:not([hidden])", (items) => items.length), 1);
|
||||
await page.click("#activity-expert-toggle");
|
||||
await page.click('[data-activity-type="TOOL"] summary');
|
||||
await screenshot(page, "1440-activity");
|
||||
|
||||
await page.click(".node");
|
||||
await page.click('[data-work-panel="provenance-drawer-panel"]');
|
||||
assert.match(
|
||||
await page.$eval("#drawer-provenance-summary", (element) => element.textContent),
|
||||
/Provenance disponible/,
|
||||
);
|
||||
await screenshot(page, "1440-provenance");
|
||||
|
||||
const firstNode = await page.$(".node");
|
||||
const box = await firstNode.boundingBox();
|
||||
assert.ok(box);
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 54, box.y + box.height / 2 + 28, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
assert.ok(Object.keys(await page.evaluate(() => window.__LABFY_TEST__.getState().pinned)).length > 0);
|
||||
await screenshot(page, "1440-pinned-selection");
|
||||
|
||||
const agentSeparator = await page.$("#agent-resizer");
|
||||
const separatorBox = await agentSeparator.boundingBox();
|
||||
assert.ok(separatorBox);
|
||||
await page.mouse.move(separatorBox.x + 2, separatorBox.y + 80);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(separatorBox.x + 42, separatorBox.y + 80, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
const layoutBeforeReload = await page.evaluate(() => localStorage.getItem("labfy-layout:agent"));
|
||||
assert.ok(Number(layoutBeforeReload) >= 208);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector(".node");
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem("labfy-layout:agent")), layoutBeforeReload);
|
||||
|
||||
await page.click("#graph-fullscreen");
|
||||
assert.equal(await page.$eval(".workbench", (element) => element.classList.contains("graph-priority")), true);
|
||||
await screenshot(page, "1440-graph-priority");
|
||||
await page.keyboard.press("Escape");
|
||||
assert.equal(await page.$eval(".workbench", (element) => element.classList.contains("graph-priority")), false);
|
||||
|
||||
for (const [width, height, name] of [[1280, 800, "1280"], [1024, 768, "1024"], [700, 900, "700"]]) {
|
||||
await page.setViewport({ width, height });
|
||||
await page.waitForFunction(() => document.querySelector("#graph-panel").getBoundingClientRect().height > 0);
|
||||
await assertNoHorizontalOverflow(page);
|
||||
await screenshot(page, name);
|
||||
}
|
||||
await page.click("#work-panel-toggle");
|
||||
await screenshot(page, "700-drawer-collapsed");
|
||||
await page.click("#work-panel-toggle");
|
||||
|
||||
await page.evaluate(() => window.__LABFY_TEST__.processEventData("{invalid"));
|
||||
assert.match(await page.$eval("#graph-state", (element) => element.textContent), /JSON illisible/);
|
||||
await screenshot(page, "700-error");
|
||||
assert.deepEqual(errors, []);
|
||||
console.log("PASS visual polish Firefox : 11 captures, panels, persistance, prompt, graphe et erreur");
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) {
|
||||
server.kill("SIGINT");
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
await rm(profile, { recursive: true, force: true });
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -78,31 +78,27 @@ class LauncherTest(SyntheticEnvironment):
|
|||
self.assertEqual(parser.parse_args(["serve", "--workspace",
|
||||
str(self.root / "w"), "--port", "0"]).port, 0)
|
||||
|
||||
def test_xdg_start_status_code_double_start_and_stop(self):
|
||||
def test_xdg_start_status_auto_session_double_start_and_stop(self):
|
||||
workspace = self.root / "workspace"
|
||||
started = self.command("start", "--workspace", str(workspace), "--port", "0",
|
||||
"--bridge", str(self.bridge))
|
||||
self.assertEqual(started.returncode, 0, started.stderr)
|
||||
self.assertIn("Code de session éphémère : ", started.stdout)
|
||||
bootstrap = started.stdout.split("Code de session éphémère : ", 1)[1].strip()
|
||||
self.assertIn("Labfy Web : http://127.0.0.1:", started.stdout)
|
||||
self.assertNotIn("Code de session", started.stdout)
|
||||
instance = (self.runtime / web_app.APP_DIRECTORY / "instance.json")
|
||||
self.assertTrue(instance.is_file())
|
||||
self.assertNotIn(bootstrap, instance.read_text(encoding="utf-8"))
|
||||
self.assertIn('"automatic_session":true', instance.read_text(encoding="utf-8"))
|
||||
self.assertEqual(instance.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual((self.runtime / web_app.APP_DIRECTORY).stat().st_mode & 0o777,
|
||||
0o700)
|
||||
config = self.state / web_app.APP_DIRECTORY / "config.json"
|
||||
self.assertEqual(config.stat().st_mode & 0o777, 0o600)
|
||||
self.assertNotIn(bootstrap, config.read_text(encoding="utf-8"))
|
||||
status = self.command("status")
|
||||
self.assertEqual(status.returncode, 0, status.stderr)
|
||||
self.assertIn("actif", status.stdout)
|
||||
code = self.command("code")
|
||||
self.assertEqual(code.returncode, 0, code.stderr)
|
||||
self.assertEqual(code.stdout.strip(), bootstrap)
|
||||
duplicate = self.command("start", "--workspace", str(workspace), "--port", "0",
|
||||
"--bridge", str(self.bridge))
|
||||
self.assertNotEqual(duplicate.returncode, 0)
|
||||
self.assertEqual(duplicate.returncode, 0, duplicate.stderr)
|
||||
self.assertIn("déjà active", duplicate.stderr)
|
||||
stopped = self.command("stop")
|
||||
self.assertEqual(stopped.returncode, 0, stopped.stderr)
|
||||
|
|
@ -266,15 +262,13 @@ class LibraryTest(SyntheticEnvironment):
|
|||
data = response.read()
|
||||
if response.getheader("Set-Cookie"):
|
||||
cookie = response.getheader("Set-Cookie").split(";", 1)[0]
|
||||
result = response.status, json.loads(data)
|
||||
result = response.status, (json.loads(data) if data else {})
|
||||
connection.close()
|
||||
return result
|
||||
|
||||
try:
|
||||
self.assertEqual(request("GET", "/api/v1/library", authenticated=False)[0], 401)
|
||||
self.assertEqual(request("POST", "/api/v1/session",
|
||||
{"bootstrap_code": "SPECIMEN-bootstrap"}, authenticated=False,
|
||||
csrf=False)[0], 200)
|
||||
self.assertEqual(request("GET", "/", authenticated=False)[0], 303)
|
||||
self.assertEqual(request("POST", "/api/v1/library/workspaces",
|
||||
{"title": "Sans CSRF", "idempotency_key": "x"}, csrf=False)[0], 403)
|
||||
status, empty = request("GET", "/api/v1/library")
|
||||
|
|
|
|||
|
|
@ -5,56 +5,67 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession, resumeAutomaticSessionAfterOwnedRestart } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url);
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
|
||||
|
||||
function startServer(library, runtime, state, port = 0) {
|
||||
return spawn("python3", ["web_app.py", "serve", "--library", library,
|
||||
"--bridge", "../../tools/local-jobs", "--port", String(port)], {
|
||||
cwd: PROTOTYPE,
|
||||
const result = spawnSync("./tools/labfy", ["--library", library,
|
||||
"--port", String(port)], {
|
||||
cwd: REPOSITORY,
|
||||
env: { ...process.env, PYTHONUNBUFFERED: "1", XDG_RUNTIME_DIR: runtime,
|
||||
XDG_STATE_HOME: state },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr.toString());
|
||||
return { output: result.stdout.toString(), runtime, state };
|
||||
}
|
||||
|
||||
function ready(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
const timer = setTimeout(() => reject(new Error("démarrage Web expiré")), 10000);
|
||||
server.stdout.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
const origin = output.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//);
|
||||
const code = output.match(/Code de session éphémère : ([^\s]+)/);
|
||||
if (origin && code) {
|
||||
clearTimeout(timer);
|
||||
resolve({ origin: origin[1], code: code[1] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) => reject(new Error(`serveur arrêté (${code})`)));
|
||||
output += server.output;
|
||||
const origin = output.match(/Labfy Web : (http:\/\/127\.0\.0\.1:\d+)\//);
|
||||
if (origin) {
|
||||
clearTimeout(timer);
|
||||
resolve({ origin: origin[1] });
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`lanceur Web sans origine: ${output}`));
|
||||
});
|
||||
}
|
||||
|
||||
async function stopServer(server) {
|
||||
if (!server || server.exitCode !== null) return;
|
||||
const exited = new Promise((resolve) => server.once("exit", resolve));
|
||||
server.kill("SIGTERM");
|
||||
await exited;
|
||||
if (!server) return;
|
||||
const result = spawnSync("python3", ["web_app.py", "stop"], {
|
||||
cwd: PROTOTYPE,
|
||||
env: { ...process.env, XDG_RUNTIME_DIR: server.runtime,
|
||||
XDG_STATE_HOME: server.state },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
}
|
||||
|
||||
async function login(page, session) {
|
||||
try {
|
||||
await page.evaluate((origin) => window.location.assign(origin), session.origin);
|
||||
} catch (error) {
|
||||
if (!String(error).includes("Execution context was destroyed")) throw error;
|
||||
}
|
||||
await page.waitForSelector("#login-form");
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await page.click("#login-form button");
|
||||
await page.waitForSelector("#library-home:not([hidden])");
|
||||
assert.equal(page.url().includes(session.code), false);
|
||||
assert.equal((await page.content()).includes(session.code), false);
|
||||
async function assertServerReady(server, expectedOrigin) {
|
||||
const origin = (await ready(server)).origin;
|
||||
assert.equal(origin, expectedOrigin);
|
||||
const health = await fetch(`${origin}/healthz`, { cache: "no-store" });
|
||||
assert.equal(health.status, 200);
|
||||
const body = await health.json();
|
||||
assert.equal(body.contract, "labfy.workspace.health.v1");
|
||||
return origin;
|
||||
}
|
||||
|
||||
async function detachPageFromOwnedServer(page) {
|
||||
// CONTRACT: avant d'arrêter un backend possédé, le client de test quitte son
|
||||
// EventSource afin que Firefox ne réutilise pas une navigation interrompue
|
||||
// lorsque la même origine est relancée.
|
||||
await page.evaluate(() => window.location.replace("about:blank"));
|
||||
await page.waitForFunction(() => location.href === "about:blank");
|
||||
}
|
||||
|
||||
async function createWorkspace(page, title) {
|
||||
|
|
@ -129,7 +140,7 @@ try {
|
|||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
await page.setViewport({ width: 700, height: 900 });
|
||||
await login(page, session);
|
||||
await openAutomaticSession(page, session.origin, "A");
|
||||
|
||||
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 0);
|
||||
assert.match(await page.$eval("#library-empty", (item) => item.textContent), /Aucune enquête/);
|
||||
|
|
@ -296,11 +307,11 @@ try {
|
|||
|
||||
// Le port initial est attribué par le système. Sa réutilisation conserve le
|
||||
// même origin et permet de vérifier les brouillons session après redémarrage.
|
||||
await detachPageFromOwnedServer(page);
|
||||
await stopServer(server);
|
||||
server = startServer(library, runtime, state, assignedPort);
|
||||
const restartedForB = await ready(server);
|
||||
assert.equal(restartedForB.origin, session.origin);
|
||||
await login(page, restartedForB);
|
||||
const restartedForB = await assertServerReady(server, session.origin);
|
||||
await resumeAutomaticSessionAfterOwnedRestart(page, restartedForB, "B");
|
||||
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 1);
|
||||
assert.equal(await page.$eval(".workspace-card h3", (item) => item.textContent), workspaceATitle);
|
||||
assert.match(await page.$eval(".workspace-card p", (item) => item.textContent), /Prête à ouvrir/);
|
||||
|
|
@ -332,11 +343,11 @@ try {
|
|||
|
||||
await page.click("#home-button");
|
||||
await page.waitForSelector("#library-home:not([hidden])");
|
||||
await detachPageFromOwnedServer(page);
|
||||
await stopServer(server);
|
||||
server = startServer(library, runtime, state, assignedPort);
|
||||
const restartedForA = await ready(server);
|
||||
assert.equal(restartedForA.origin, session.origin);
|
||||
await login(page, restartedForA);
|
||||
const restartedForA = await assertServerReady(server, session.origin);
|
||||
await resumeAutomaticSessionAfterOwnedRestart(page, restartedForA, "C");
|
||||
assert.equal(await page.$eval("#workspace-list", (item) => item.children.length), 2);
|
||||
await openWorkspace(page, workspaceAId);
|
||||
assert.equal(await page.$eval("#investigation-title", (item) => item.textContent),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url),
|
||||
PROTOTYPE = new URL("..", import.meta.url);
|
||||
|
|
@ -14,11 +15,10 @@ function ready(server) {
|
|||
const timer = setTimeout(() => reject(new Error("démarrage expiré")), 8000);
|
||||
server.stdout.on("data", (chunk) => {
|
||||
text += chunk;
|
||||
const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//),
|
||||
code = text.match(/Code de session éphémère : ([^\s]+)/);
|
||||
if (url && code) {
|
||||
const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
|
||||
if (url) {
|
||||
clearTimeout(timer);
|
||||
resolve({ url: url[0], code: code[1] });
|
||||
resolve({ url: url[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (value) =>
|
||||
|
|
@ -26,14 +26,6 @@ function ready(server) {
|
|||
);
|
||||
});
|
||||
}
|
||||
async function login(page, session) {
|
||||
await page.goto(session.url, { waitUntil: "domcontentloaded" });
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button"),
|
||||
]);
|
||||
}
|
||||
|
||||
const workspace = await mkdtemp(join(tmpdir(), "labfy-workbench-")),
|
||||
profile = await mkdtemp(join(tmpdir(), "labfy-workbench-firefox-"));
|
||||
|
|
@ -81,7 +73,7 @@ try {
|
|||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
const start = Date.now();
|
||||
await login(page, session);
|
||||
await openAutomaticSession(page, session.url);
|
||||
await page.waitForSelector(".node");
|
||||
const metrics = await page.evaluate(() => ({
|
||||
nodes: document.querySelectorAll(".node").length,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url);
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
|
|
@ -19,10 +20,9 @@ function ready(server) {
|
|||
server.stdout.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
const url = output.match(/http:\/\/127\.0\.0\.1:\d+\//);
|
||||
const code = output.match(/Code de session éphémère : ([^\s]+)/);
|
||||
if (url && code) {
|
||||
if (url) {
|
||||
clearTimeout(timer);
|
||||
resolve({ url: url[0], code: code[1] });
|
||||
resolve({ url: url[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) =>
|
||||
|
|
@ -31,14 +31,6 @@ function ready(server) {
|
|||
});
|
||||
}
|
||||
|
||||
async function login(page, session) {
|
||||
await page.goto(session.url, { waitUntil: "domcontentloaded" });
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button"),
|
||||
]);
|
||||
}
|
||||
|
||||
const workspace = await mkdtemp(join(tmpdir(), "labfy-workbench-populated-"));
|
||||
const profile = await mkdtemp(
|
||||
|
|
@ -89,7 +81,7 @@ try {
|
|||
await page.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
const authenticationStarted = performance.now();
|
||||
await login(page, session);
|
||||
await openAutomaticSession(page, session.url);
|
||||
await page.waitForSelector(".node");
|
||||
const firstRenderMs = Math.round(performance.now() - authenticationStarted);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { openAutomaticSession } from "./browser_automatic_session.mjs";
|
||||
|
||||
const REPOSITORY = new URL("../../..", import.meta.url);
|
||||
const PROTOTYPE = new URL("..", import.meta.url);
|
||||
|
|
@ -16,10 +17,9 @@ function waitForServer(server) {
|
|||
server.stdout.on("data", (chunk) => {
|
||||
output += chunk.toString();
|
||||
const url = output.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
|
||||
const code = output.match(/Code de session éphémère : ([^\s]+)/);
|
||||
if (url && code) {
|
||||
if (url) {
|
||||
clearTimeout(timeout);
|
||||
resolve({ url: url[0], code: code[1] });
|
||||
resolve({ url: url[0] });
|
||||
}
|
||||
});
|
||||
server.once("exit", (code) => reject(new Error(`serveur J6 arrêté (${code})`)));
|
||||
|
|
@ -62,11 +62,7 @@ try {
|
|||
headless: true, userDataDir: profile, args: ["--no-remote"] });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(session.url, { waitUntil: "domcontentloaded" });
|
||||
assert.ok(await page.$("#login-form"));
|
||||
await page.type("#bootstrap-code", session.code);
|
||||
await Promise.all([page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.click("#login-form button")]);
|
||||
await openAutomaticSession(page, session.url);
|
||||
await page.waitForSelector(`[data-id="evidence:${manifest.eml_id}"]`);
|
||||
assert.match(await page.$eval("#mode-badge", (item) => item.textContent), /Poste local/);
|
||||
|
||||
|
|
|
|||
|
|
@ -35,11 +35,8 @@ class WorkspaceServerTest(unittest.TestCase):
|
|||
bridge=cls.bridge, bootstrap="SPECIMEN-CODE")
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
status, headers, _ = cls.request_raw(
|
||||
"POST", "/api/v1/session", {"bootstrap_code": "SPECIMEN-CODE"},
|
||||
origin=cls.server.origin,
|
||||
)
|
||||
if status != 200:
|
||||
status, headers, _ = cls.request_raw("GET", "/")
|
||||
if status != 303:
|
||||
raise RuntimeError("session J6 impossible")
|
||||
cls.cookie = headers["Set-Cookie"].split(";", 1)[0]
|
||||
status, _, body = cls.request_raw("GET", "/api/v1/session", cookie=cls.cookie)
|
||||
|
|
@ -85,8 +82,7 @@ class WorkspaceServerTest(unittest.TestCase):
|
|||
|
||||
def test_authentication_origin_csrf_and_input_rejections(self):
|
||||
self.assertEqual(self.request_raw(
|
||||
"POST", "/api/v1/session", {"bootstrap_code": ["SPECIMEN-CODE"]},
|
||||
origin=self.server.origin)[0], 400)
|
||||
"POST", "/api/v1/session", {}, origin=self.server.origin)[0], 404)
|
||||
self.assertEqual(self.request_raw("GET", "/api/v1/jobs")[0], 401)
|
||||
self.assertEqual(self.request_raw("GET", "/api/v1/jobs", host="evil.test")[0], 403)
|
||||
self.assertEqual(self.request_raw("POST", "/api/v1/queue/pause", {},
|
||||
|
|
|
|||
|
|
@ -133,7 +133,6 @@ class InstanceFiles:
|
|||
self.runtime = runtime_directory()
|
||||
self.state_root = state_directory()
|
||||
self.instance = self.runtime / "instance.json"
|
||||
self.secret = self.runtime / "bootstrap.code"
|
||||
self.config = self.state_root / "config.json"
|
||||
self.lifecycle = self.runtime / "lifecycle.lock"
|
||||
self.owner = self.runtime / "instance.lock"
|
||||
|
|
@ -144,7 +143,7 @@ class InstanceFiles:
|
|||
return descriptor
|
||||
|
||||
def clear_stale(self):
|
||||
for path in (self.instance, self.secret):
|
||||
for path in (self.instance,):
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
|
|
@ -177,7 +176,6 @@ def serve(args):
|
|||
selection = _selection(args)
|
||||
config, config_id = _config(selection, args.bridge)
|
||||
instance_id = secrets.token_urlsafe(24)
|
||||
bootstrap = secrets.token_urlsafe(18)
|
||||
library = None
|
||||
if selection[0] == "library":
|
||||
library = WebLibrary(selection[1], args.bridge)
|
||||
|
|
@ -188,7 +186,7 @@ def serve(args):
|
|||
workspace.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
try:
|
||||
server = WorkspaceServer(("127.0.0.1", args.port), Handler,
|
||||
workspace=workspace, bridge=args.bridge, bootstrap=bootstrap,
|
||||
workspace=workspace, bridge=args.bridge, bootstrap="",
|
||||
library=library, instance_id=instance_id, config_id=config_id,
|
||||
automatic_session=args.automatic_session)
|
||||
except OSError as error:
|
||||
|
|
@ -196,28 +194,21 @@ def serve(args):
|
|||
"error": f"Port indisponible : {error}"})
|
||||
os.close(owner)
|
||||
return 1
|
||||
state = {"contract": "labfy.web_instance.v1", "pid": os.getpid(),
|
||||
state = {"contract": "labfy.web_instance.v2", "pid": os.getpid(),
|
||||
"proc_start": _proc_start(os.getpid()), "instance_id": instance_id,
|
||||
"config_id": config_id, "port": server.server_port, "config": config}
|
||||
"config_id": config_id, "port": server.server_port, "config": config,
|
||||
"automatic_session": True}
|
||||
try:
|
||||
# CONTRACT: la configuration durable ne contient jamais le secret
|
||||
# d'amorçage, qui reste exclusivement dans le runtime privé.
|
||||
_atomic_json(files.config, {"contract": "labfy.web_config.v1",
|
||||
"config_id": config_id, **config})
|
||||
_atomic_json(files.instance, state)
|
||||
descriptor = os.open(files.secret, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW,
|
||||
0o600)
|
||||
try:
|
||||
os.write(descriptor, (bootstrap + "\n").encode())
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
answer = {"ok": True, "origin": server.origin,
|
||||
"instance_id": instance_id}
|
||||
_handshake(args.handshake_fd, answer)
|
||||
if args.handshake_fd is None:
|
||||
print(f"Labfy Web : {server.origin}/", flush=True)
|
||||
if not args.automatic_session:
|
||||
print(f"Code de session éphémère : {bootstrap}", flush=True)
|
||||
# INVARIANT: SIGTERM n'arrête que cette instance déjà authentifiée
|
||||
# par le lanceur ; shutdown s'exécute hors du gestionnaire de signal.
|
||||
def request_shutdown(_signum, _frame):
|
||||
|
|
@ -247,17 +238,31 @@ def start(args):
|
|||
current = None
|
||||
if current is not None and _identity_valid(current):
|
||||
origin = f"http://127.0.0.1:{current['port']}/"
|
||||
if args.open_browser:
|
||||
webbrowser.open(origin)
|
||||
print(f"Instance déjà active sur {origin}", file=sys.stderr)
|
||||
return 0
|
||||
if (current.get("automatic_session") is True and
|
||||
current.get("contract") == "labfy.web_instance.v2"):
|
||||
if args.open_browser:
|
||||
webbrowser.open(origin)
|
||||
print(f"Instance déjà active sur {origin}", file=sys.stderr)
|
||||
return 0
|
||||
# CONTRACT: une instance authentifiée mais d'ancien profil ne peut
|
||||
# jamais être rejointe : elle ramènerait le parcours code retiré.
|
||||
print(f"Instance incompatible sur {origin} ; remplacement contrôlé",
|
||||
file=sys.stderr)
|
||||
os.kill(current["pid"], signal.SIGTERM)
|
||||
deadline = time.monotonic() + 5
|
||||
while (time.monotonic() < deadline and
|
||||
_proc_start(current["pid"]) == current["proc_start"]):
|
||||
time.sleep(0.05)
|
||||
if _proc_start(current["pid"]) == current["proc_start"]:
|
||||
print("Arrêt de l'instance incompatible expiré", file=sys.stderr)
|
||||
return 1
|
||||
files.clear_stale()
|
||||
files.clear_stale()
|
||||
read_fd, write_fd = os.pipe()
|
||||
command = [sys.executable, str(Path(__file__).resolve()), "serve",
|
||||
"--port", str(args.port), "--bridge", str(args.bridge),
|
||||
"--handshake-fd", str(write_fd)]
|
||||
if args.automatic_session:
|
||||
command.append("--automatic-session")
|
||||
command.append("--automatic-session")
|
||||
mode, path = _selection(args)
|
||||
command.extend((f"--{mode}", str(path)))
|
||||
process = subprocess.Popen(command, stdin=subprocess.DEVNULL,
|
||||
|
|
@ -329,21 +334,6 @@ def stop(_args):
|
|||
os.close(lifecycle)
|
||||
|
||||
|
||||
def code(_args):
|
||||
files = InstanceFiles()
|
||||
try:
|
||||
state = _read_private_json(files.instance)
|
||||
if not _identity_valid(state):
|
||||
raise RuntimeError("Instance inactive")
|
||||
if files.secret.is_symlink() or not files.secret.is_file():
|
||||
raise RuntimeError("Code indisponible")
|
||||
print(files.secret.read_text(encoding="utf-8").strip())
|
||||
return 0
|
||||
except (OSError, RuntimeError, json.JSONDecodeError) as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(description="Poste Web local Labfy")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
|
@ -363,7 +353,7 @@ def build_parser():
|
|||
if name == "serve":
|
||||
command.add_argument("--handshake-fd", type=int, help=argparse.SUPPRESS)
|
||||
command.set_defaults(function=function)
|
||||
for name, function in (("status", status), ("stop", stop), ("code", code)):
|
||||
for name, function in (("status", status), ("stop", stop)):
|
||||
command = subparsers.add_parser(name)
|
||||
command.set_defaults(function=function)
|
||||
help_command = subparsers.add_parser("help")
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class WorkspaceServer(ThreadingHTTPServer):
|
|||
block_on_close = False
|
||||
|
||||
def __init__(self, address, handler, *, workspace: Path, bridge: Path,
|
||||
bootstrap: str, library=None, instance_id=None, config_id=None,
|
||||
bootstrap: str = "", library=None, instance_id=None, config_id=None,
|
||||
session_ttl_seconds=SESSION_TTL_SECONDS,
|
||||
research_fixture_authority=None, automatic_session=False):
|
||||
if (not isinstance(session_ttl_seconds, int) or
|
||||
|
|
@ -59,10 +59,10 @@ class WorkspaceServer(ThreadingHTTPServer):
|
|||
self.bridge = bridge.resolve()
|
||||
self.authority = f"127.0.0.1:{self.server_port}"
|
||||
self.origin = f"http://{self.authority}"
|
||||
self.bootstrap = bootstrap
|
||||
# CONTRACT: ce mode est activé seulement par le lanceur possédé. Il
|
||||
# émet un cookie HttpOnly local mais ne relâche jamais Origin/CSRF.
|
||||
self.automatic_session = automatic_session
|
||||
# CONTRACT: le poste Web ne possède plus de parcours code. Toute
|
||||
# première navigation loopback établit seulement un cookie HttpOnly ;
|
||||
# Host, Origin et CSRF restent exigés pour chaque mutation.
|
||||
self.automatic_session = True
|
||||
self.library = library
|
||||
self.active_workspace_id = None
|
||||
self.library_generation = (library.snapshot(None)["generation"]
|
||||
|
|
@ -561,13 +561,20 @@ class Handler(BaseHTTPRequestHandler):
|
|||
"status": "ready", "instance_id": self.server.instance_id,
|
||||
"config_id": self.server.config_id})
|
||||
return
|
||||
if path == "/favicon.ico":
|
||||
# CONTRACT: les navigateurs demandent implicitement cette icône ;
|
||||
# l'absence d'asset ne doit pas transformer un parcours sain en 404.
|
||||
self.send_response(HTTPStatus.NO_CONTENT)
|
||||
self._headers()
|
||||
self.end_headers()
|
||||
return
|
||||
if path in {"/login.html", "/login.js"}:
|
||||
self._error(HTTPStatus.NOT_FOUND, "route_unknown", "Route inconnue")
|
||||
return
|
||||
if not self._authenticated():
|
||||
if path == "/" and self.server.automatic_session:
|
||||
if path == "/":
|
||||
self._open_automatic_session()
|
||||
return
|
||||
if path in {"/", "/login.js", "/styles.css"}:
|
||||
self._serve_static("/login.html" if path == "/" else path)
|
||||
return
|
||||
self._error(HTTPStatus.UNAUTHORIZED, "session_required",
|
||||
"Session locale requise")
|
||||
return
|
||||
|
|
@ -670,7 +677,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/api/v1/session":
|
||||
self._open_session()
|
||||
self._error(HTTPStatus.NOT_FOUND, "route_unknown", "Route inconnue")
|
||||
return
|
||||
if not self._mutation_allowed():
|
||||
self._error(HTTPStatus.FORBIDDEN, "mutation_rejected",
|
||||
|
|
@ -1118,31 +1125,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||
except FileNotFoundError:pass
|
||||
self._json(HTTPStatus.OK,{"contract":"labfy.local_upload.v1","state":"CANCELLED"})
|
||||
|
||||
def _open_session(self):
|
||||
if self.headers.get("Origin") != self.server.origin:
|
||||
self._error(HTTPStatus.FORBIDDEN, "origin_rejected", "Origin exact obligatoire")
|
||||
return
|
||||
try:
|
||||
value = self._body({"bootstrap_code"})
|
||||
except (TypeError, OverflowError) as error:
|
||||
self._error(HTTPStatus.BAD_REQUEST, "invalid_request", str(error))
|
||||
return
|
||||
if not hmac.compare_digest(value["bootstrap_code"], self.server.bootstrap):
|
||||
self._error(HTTPStatus.FORBIDDEN, "bootstrap_rejected", "Code éphémère invalide")
|
||||
return
|
||||
with self.server.session_lock:
|
||||
# Une reconnexion renouvelle les secrets ; les anciens cookies
|
||||
# expirés ne doivent pas redevenir valables.
|
||||
self.server.session = secrets.token_urlsafe(32)
|
||||
self.server.csrf = secrets.token_urlsafe(32)
|
||||
self.server.session_deadline = (time.monotonic() +
|
||||
self.server.session_ttl_seconds)
|
||||
cookie = (f"{self.server.cookie_name}={self.server.session}; "
|
||||
"HttpOnly; SameSite=Strict; "
|
||||
f"Path=/; Max-Age={self.server.session_ttl_seconds}")
|
||||
self._json(HTTPStatus.OK, {"contract": "labfy.workspace.session.v1",
|
||||
"authenticated": True}, cookie=cookie)
|
||||
|
||||
def _open_automatic_session(self):
|
||||
with self.server.session_lock:
|
||||
self.server.session = secrets.token_urlsafe(32)
|
||||
|
|
@ -1213,12 +1195,10 @@ def main():
|
|||
return parsed
|
||||
parser.add_argument("--port", type=port, default=8081)
|
||||
args = parser.parse_args()
|
||||
bootstrap = secrets.token_urlsafe(12)
|
||||
server = WorkspaceServer(("127.0.0.1", args.port), Handler,
|
||||
workspace=args.workspace, bridge=args.bridge,
|
||||
bootstrap=bootstrap)
|
||||
bootstrap="")
|
||||
print(f"Labfy J6 : {server.origin}/", flush=True)
|
||||
print(f"Code de session éphémère : {bootstrap}", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
|
|
|
|||
Loading…
Reference in a new issue