From 10fa0367a27e949f8ed35722933fd1af81680f84 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sat, 17 Oct 2026 21:07:38 +0100 Subject: [PATCH 01/16] feat: retain resource-owned editor authority across views Preserve guarded Undo/Redo and pending input through native lifecycle boundaries. Source and tests only; qualification evidence remains in the local verification branch. Releases remain held. --- public/js/alternatives-panel.js | 204 ++++- public/js/blocks.js | 242 ++++- public/js/chrome.js | 19 +- public/js/editor.js | 380 +++++++- public/js/lab.js | 8 +- public/js/overflow.js | 47 +- public/js/rolegraph-client.js | 11 +- public/js/selection-menu.js | 14 +- public/js/trim.js | 63 ++ src/document.rs | 855 ++++++++++++++++++ src/host.rs | 12 + src/lib.rs | 14 +- tests/browser/clipboard-user-flow.cjs | 68 ++ tests/browser/corpus-rolegraph.html | 230 +++++ tests/browser/delivery-rolegraph.html | 133 +++ tests/browser/distribution-publish.test.py | 487 ++++++++++ tests/browser/editor-startup.test.cjs | 64 ++ tests/browser/ghost-history.test.cjs | 333 +++++++ ...-authority-controller.integration.test.cjs | 362 ++++++++ tests/browser/nested-build-imports.cjs | 101 +++ tests/browser/nested_build_paths_test.py | 274 ++++++ tests/browser/offline_icon_readiness_test.py | 93 ++ tests/browser/offline_runner_engine_test.py | 184 ++++ tests/browser/performance-rolegraph-worker.js | 23 + tests/browser/performance-rolegraph.html | 276 ++++++ tests/browser/rolegraph-build.test.cjs | 199 ++++ tests/browser/rolegraph-client.test.cjs | 150 +++ tests/browser/rolegraph-corpus-runner.test.py | 68 ++ tests/browser/rolegraph-editor.test.cjs | 224 +++++ tests/browser/rolegraph-quota-runner.test.py | 125 +++ ...olegraph-safari-performance-runner.test.py | 235 +++++ tests/browser/rolegraph-safari-runner.test.py | 131 +++ tests/browser/rolegraph-storage.test.cjs | 31 + tests/browser/rolegraph-worker.test.cjs | 21 + tests/browser/run-synonym-sharing.cjs | 43 + tests/browser/runtime-assets-staging.test.cjs | 36 + tests/browser/runtime-assets.test.cjs | 74 ++ tests/browser/runtime_assets_check_test.py | 49 + tests/browser/safari_offline_check_test.py | 138 +++ .../safari_offline_diagnostics_test.py | 399 ++++++++ tests/browser/synonym-sharing.cjs | 23 +- tests/browser/trim-protection.test.cjs | 97 ++ .../typing_benchmark_diagnostics_test.py | 217 +++++ tests/browser/verify-wasm-binaries.py | 90 ++ tests/browser/worker-rolegraph.html | 53 ++ tests/fixtures/native-save-regression.md | 47 + tests/fixtures/native-trim-done-regression.md | 15 + tests/ghost_transaction_native.rs | 145 +++ tests/history_checkpoints_native.rs | 177 ++++ tests/kg_role.rs | 67 ++ tests/trim_done_runtime_diagnostic.rs | 217 +++++ tests/web_chrome.rs | 38 +- tests/web_embed.rs | 41 + tests/web_ghost_heading.rs | 80 ++ tests/web_ghost_transaction.rs | 181 ++++ tests/web_headings.rs | 98 +- tests/web_kg_role.rs | 101 +++ tests/web_overflow_layout.rs | 12 +- tests/web_text_boundaries.rs | 187 ++++ tests/web_trim_done.rs | 271 ++++-- 60 files changed, 8463 insertions(+), 114 deletions(-) create mode 100644 tests/browser/clipboard-user-flow.cjs create mode 100644 tests/browser/corpus-rolegraph.html create mode 100644 tests/browser/delivery-rolegraph.html create mode 100644 tests/browser/distribution-publish.test.py create mode 100644 tests/browser/editor-startup.test.cjs create mode 100644 tests/browser/ghost-history.test.cjs create mode 100644 tests/browser/native-authority-controller.integration.test.cjs create mode 100644 tests/browser/nested-build-imports.cjs create mode 100644 tests/browser/nested_build_paths_test.py create mode 100644 tests/browser/offline_icon_readiness_test.py create mode 100644 tests/browser/offline_runner_engine_test.py create mode 100644 tests/browser/performance-rolegraph-worker.js create mode 100644 tests/browser/performance-rolegraph.html create mode 100644 tests/browser/rolegraph-build.test.cjs create mode 100644 tests/browser/rolegraph-client.test.cjs create mode 100644 tests/browser/rolegraph-corpus-runner.test.py create mode 100644 tests/browser/rolegraph-editor.test.cjs create mode 100644 tests/browser/rolegraph-quota-runner.test.py create mode 100644 tests/browser/rolegraph-safari-performance-runner.test.py create mode 100644 tests/browser/rolegraph-safari-runner.test.py create mode 100644 tests/browser/rolegraph-storage.test.cjs create mode 100644 tests/browser/rolegraph-worker.test.cjs create mode 100644 tests/browser/run-synonym-sharing.cjs create mode 100644 tests/browser/runtime-assets-staging.test.cjs create mode 100644 tests/browser/runtime-assets.test.cjs create mode 100644 tests/browser/runtime_assets_check_test.py create mode 100644 tests/browser/safari_offline_check_test.py create mode 100644 tests/browser/safari_offline_diagnostics_test.py create mode 100644 tests/browser/trim-protection.test.cjs create mode 100644 tests/browser/typing_benchmark_diagnostics_test.py create mode 100644 tests/browser/verify-wasm-binaries.py create mode 100644 tests/browser/worker-rolegraph.html create mode 100644 tests/fixtures/native-save-regression.md create mode 100644 tests/fixtures/native-trim-done-regression.md create mode 100644 tests/ghost_transaction_native.rs create mode 100644 tests/history_checkpoints_native.rs create mode 100644 tests/kg_role.rs create mode 100644 tests/trim_done_runtime_diagnostic.rs create mode 100644 tests/web_ghost_heading.rs create mode 100644 tests/web_ghost_transaction.rs create mode 100644 tests/web_kg_role.rs create mode 100644 tests/web_text_boundaries.rs diff --git a/public/js/alternatives-panel.js b/public/js/alternatives-panel.js index 817f7a0..0600ff7 100644 --- a/public/js/alternatives-panel.js +++ b/public/js/alternatives-panel.js @@ -343,7 +343,7 @@ class TeAlternativesPanel { ensureWriteOn() { if (this.isWriteOn()) return; const chrome = this.editor.chrome; - if (chrome && typeof chrome.setMode === 'function') chrome.setMode('write-on'); + if (chrome && typeof chrome.setMode === 'function') return chrome.setMode('write-on'); } toggle(options = {}) { @@ -362,7 +362,13 @@ class TeAlternativesPanel { */ open(where, options = {}) { if (this.destroyed || !this.modelAvailable()) return false; - this.ensureWriteOn(); + const beforeMode=this.surface.getText(),entering=this.ensureWriteOn(); + if(this.editor.authority && entering && typeof entering.then==='function')return entering.then(()=>{ + if(where && Number.isInteger(where.start) && beforeMode!==this.surface.getText()) { + this.setStatus('Text changed while saving the block; select the range again.',true);return false; + } + return this.open(where,options); + }).catch(error=>{this.setStatus(`Alternatives not opened: ${error?.message||error}`,true);return false;}); const wasOpen = this.opened; if (!wasOpen) this.savedSelection = this.surface.getSelectionOffsets(); if (!options.fromControl) { @@ -394,6 +400,7 @@ class TeAlternativesPanel { } close({ restoreFocus = true } = {}) { + if(this.editor.authority && !this.authorityLocal) return this.authorityClose({restoreFocus}); if (!this.opened) return; // Typed text is never silently lost: the focused line is committed // (Escape, Ctrl+Shift+A, the chrome control, leaving Write_On mode), and @@ -802,7 +809,12 @@ class TeAlternativesPanel { /** Run `fn`, reporting a refusal in the status line instead of throwing. */ attempt(fn) { try { - return fn(); + const result = fn(); + if (result && typeof result.then === 'function') return result.catch(err => { + this.setStatus(`Not changed: ${err && err.message ? err.message : err}`,true); + this.render(); return null; + }); + return result; } catch (err) { this.setStatus(`Not changed: ${err && err.message ? err.message : err}`, true); this.render(); @@ -816,6 +828,7 @@ class TeAlternativesPanel { * operation removed, or no change, falls back to a refresh. */ after(change) { + if (change && typeof change.then === 'function') return change.then(value => this.after(value)); if (this.frame) { cancelAnimationFrame(this.frame); this.frame = 0; @@ -842,6 +855,7 @@ class TeAlternativesPanel { if (!t.spanId) return null; return this.attempt(() => { const change = this.editor.alternativeOp('add', t.spanId, value, source); + if(change && typeof change.then==='function') return change.then(result=>{if(!result)this.setStatus(`“${value}” is already in the list.`,true);return this.after(result);}); if (!change) this.setStatus(`“${value}” is already in the list.`, true); return this.after(change); }); @@ -874,6 +888,7 @@ class TeAlternativesPanel { if (index === this.span.active) return null; return this.attempt(() => { const change = this.activate(t.spanId, index); + if(change && typeof change.then==='function') return change.then(result=>{this.refresh();return result;}); this.refresh(); return change; }); @@ -968,6 +983,10 @@ class TeAlternativesPanel { } onLineInput(e) { + if(this.editor.authority) { + if(this.destroyed||!this.opened||this.rendering||e.isComposing||!(e.target instanceof HTMLInputElement)||e.target.readOnly)return; + this.authorityInput(e.target);return; + } if (this.destroyed || !this.opened || this.rendering || e.isComposing) return; const input = e.target; if (!(input instanceof HTMLInputElement) || input.readOnly) return; @@ -978,6 +997,118 @@ class TeAlternativesPanel { if (this.live && this.lineIndexOf(input) === this.live.index) this.updateLive(input); } + authorityLineTask(work) { + const authorityGeneration=this.editor.authorityQueueGeneration; + const pending=(this.authorityLineQueue||Promise.resolve()).then(()=> { + if(authorityGeneration!==this.editor.authorityQueueGeneration)throw new Error('Alternative intent belongs to a recovered authority'); + if(this.destroyed)throw new Error('Alternative panel disposed; draft retained'); + return work(); + }); + this.authorityLineQueue=pending.catch(error=>this.setStatus(`Not changed: ${error?.message||error}`,true)); + return pending; + } + + authorityInput(input) { + const value=input.value,caret=[input.selectionStart,input.selectionEnd]; + const isNew=input.classList.contains('te-alt-input--new'); + const index=this.lineIndexOf(input); + const liveInput=isNew||this.authorityLiveStarting||(this.live&&index===this.live.index); + const group=this.authorityInputGroup||0; + const key=liveInput?`alternative-live-${group}`:`alternative-${index}`; + const sequence=this.editor.authorityReportDraft(key,{text:value,index,isNew,caret}); + if(!liveInput)return; + if(!this.live)this.authorityLiveStarting=true; + this.authorityLineTask(async()=> { + if(this.authorityCancelledGroups?.has(group)) {this.editor.authorityClearDraft(key,sequence);return;} + if(!this.live) { + if(!value.trim()){this.authorityLiveStarting=false;this.editor.authorityClearDraft(key,sequence);return;} + const limit=this.liftLimit(),base=this.historyTop(); + this.live={spanId:null,index:-1,base,refused:null,limit,group}; + try { + const change=await this.addAlternative(value); + if(!change||!this.span) {this.endLive();return;} + const actualIndex=Number.isInteger(change.index)?change.index:this.span.alts.findIndex((alt,i)=>i>0&&alt.text===value); + if(actualIndex<1)throw new Error('Native alternative index is unavailable'); + this.live.spanId=this.span.id;this.live.index=actualIndex; + await this.activate(this.span.id,actualIndex); + } catch(error) {if(this.live&&this.stepsSince(base)===0)this.endLive();throw error;} + finally {this.authorityLiveStarting=false;} + } else if(!value.trim()) { + await this.authorityCancelAccepted({keep:value,caret}); + } else { + const unchanged=this.lines()[this.live.index]?.text===value; + const change=unchanged?true:await this.editAlternative(this.live.index,value); + this.live.refused=change?null:value; + } + if(!this.live || this.live.refused===null)this.editor.authorityClearDraft(key,sequence); + const currentGroup=this.authorityInputGroup||0; + const later=this.editor.authorityDrafts.get(`alternative-live-${currentGroup}`); + const display=later?.input.text??(group===currentGroup?null:''); + this.whileBusy(()=>this.restoreLine(group===currentGroup&&this.live?this.live.index:this.lines().length,display,later?.input.caret||caret)); + }).catch(error=>this.setStatus(`Not accepted: ${error?.message||error}`,true)); + } + + async flushAuthorityInputs() { + await (this.authorityLineQueue||Promise.resolve()); + const input=document.activeElement; + if(input instanceof HTMLInputElement && this.list?.contains(input) && !input.readOnly && !this.live && input.value!==(input.dataset.committed||'')) { + await this.authorityCommitInput(input); + } + await (this.authorityLineQueue||Promise.resolve()); + } + + authorityCommitInput(input) { + if(!input||input.readOnly)return Promise.resolve(false); + if(input.classList.contains('te-alt-input--new')) {this.authorityInput(input);return this.authorityLineQueue.then(()=>true);} + const index=this.lineIndexOf(input),value=input.value,key=`alternative-${index}`; + const sequence=this.editor.authorityReportDraft(key,{text:value,index,isNew:false}); + return this.authorityLineTask(async()=> { + if(value===(this.lines()[index]?.text||'')){this.editor.authorityClearDraft(key,sequence);return false;} + const result=await this.editAlternative(index,value); + if(result)this.editor.authorityClearDraft(key,sequence); + return !!result; + }); + } + + async authorityCancelAccepted({keep='',caret=null}={}) { + if(!this.live)return; + const busy=this.liveBusy;this.liveBusy=true; + try { + const {base}=this.live;this.endLive(); + const steps=this.foldSince(base); + if(steps>=1) {await this.surface.undo();this.surface.forgetRedo();} + this.surface.trimHistory();await this.editor.authorityHistoryChanged(); + if(this.opened){this.refresh();this.restoreLine(this.lines().length,keep,caret);} + } finally {this.liveBusy=busy;} + } + + authorityCancelLive(options={}) { + const group=this.authorityInputGroup||0; + this.authorityInputGroup=group+1; + if(!this.authorityCancelledGroups)this.authorityCancelledGroups=new Set(); + this.authorityCancelledGroups.add(group); + return this.authorityLineTask(async()=> { + if(this.live?.group===group)await this.authorityCancelAccepted(options); + else if(this.opened)this.whileBusy(()=>this.restoreLine(this.lines().length,options.keep||'',options.caret||null)); + this.authorityLiveStarting=false; + const key=`alternative-live-${group}`,draft=this.editor.authorityDrafts.get(key); + if(draft)this.editor.authorityClearDraft(key,draft.sequence); + this.authorityCancelledGroups.delete(group); + }); + } + + async authorityClose(options) { + await this.flushAuthorityInputs();await this.finishLive(); + this.authorityLocal=true; + try {return this.close(options);}finally{this.authorityLocal=false;} + } + + async authoritySetTab(kind,options) { + await this.flushAuthorityInputs();await this.finishLive(); + this.authorityLocal=true; + try {return this.setTab(kind,options);}finally{this.authorityLocal=false;} + } + startLive(input) { const value = input.value; const t = this.target || {}; @@ -1032,6 +1163,11 @@ class TeAlternativesPanel { * the status line says so. */ finishLive() { + if(this.editor.authority && !this.authorityLocal) return this.authorityLineTask(async()=> { + if(this.live?.refused!==null && this.live)throw new Error('Alternative text was refused; correct it or press Escape'); + this.authorityLocal=true;try {this.finishLive();}finally{this.authorityLocal=false;} + await this.editor.authorityHistoryChanged(); + }); if (!this.live) return; const { base, index, refused } = this.live; this.endLive(); @@ -1069,6 +1205,7 @@ class TeAlternativesPanel { * in the add line, which gets focus with `caret`. */ cancelLive({ keep = '', caret = null } = {}) { + if(this.editor.authority && !this.authorityLocal) return this.authorityCancelLive({keep,caret}); if (!this.live) return; const { base } = this.live; this.endLive(); @@ -1134,6 +1271,13 @@ class TeAlternativesPanel { // --------------------------------------------------------------------- onLineBlur(e) { + if(this.editor.authority) { + if(this.destroyed||!this.opened||this.rendering||this.liveBusy||this.editor.authorityPainting||!(e.target instanceof HTMLInputElement)||!e.target.isConnected)return; + const input=e.target; + if(this.live||this.authorityLiveStarting)void this.finishLive().catch(error=>this.setStatus(String(error),true)); + else if(input.value!==(input.dataset.committed||''))void this.authorityCommitInput(input).catch(error=>this.setStatus(String(error),true)); + return; + } if (this.destroyed || !this.opened || this.rendering) return; const input = e.target; if (!(input instanceof HTMLInputElement) || !input.isConnected) return; @@ -1157,11 +1301,13 @@ class TeAlternativesPanel { const del = e.target.closest && e.target.closest('.te-alt-delete'); if (!del) return; const index = Number(del.closest('.te-alt-line').dataset.index); + if(this.editor.authority){void this.removeAlternative(index).then(()=>this.focusLine(Math.min(index,this.lineInputs().length-1)));return;} this.removeAlternative(index); this.focusLine(Math.min(index, this.lineInputs().length - 1)); } onLineKeyDown(e) { + if(this.editor.authority) {this.authorityLineKeyDown(e);return;} const input = e.target; if (!(input instanceof HTMLInputElement) || e.isComposing) return; const isNew = input.classList.contains('te-alt-input--new'); @@ -1214,6 +1360,7 @@ class TeAlternativesPanel { } onPanelKeyDown(e) { + if(this.editor.authority) {this.authorityPanelKeyDown(e);return;} if (this.destroyed || e.isComposing) return; if (e.key === 'Escape') { e.preventDefault(); @@ -1249,6 +1396,51 @@ class TeAlternativesPanel { this.focusLine(index === -1 ? this.activeLineIndex() : Math.min(index, this.lines().length)); } + authorityLineKeyDown(e) { + const input=e.target; + if(!(input instanceof HTMLInputElement)||e.isComposing)return; + const plain=!e.ctrlKey&&!e.metaKey&&!e.shiftKey; + if(!plain||!['Enter','ArrowUp','ArrowDown'].includes(e.key))return; + e.preventDefault(); + if(e.key==='Enter' && this.live && this.live.refused!==null){this.setStatus('Alternative text was refused; correct it or press Escape.',true);return;} + const index=this.lineIndexOf(input),isNew=input.classList.contains('te-alt-input--new'); + const finishing=(this.live||this.authorityLiveStarting)?this.finishLive():input.value!==(input.dataset.committed||'')?this.authorityCommitInput(input):Promise.resolve(); + if(e.key==='Enter') { + this.authorityInputGroup=(this.authorityInputGroup||0)+1; + this.whileBusy(()=>{const fresh=this.lineInputs().at(-1);if(fresh?.classList.contains('te-alt-input--new')){fresh.value='';fresh.focus();}}); + } + const run=async()=> { + await finishing; + if(e.key==='Enter'){ + const pending=this.editor.authorityDrafts.get(`alternative-live-${this.authorityInputGroup||0}`); + this.whileBusy(()=>this.restoreLine(this.lines().length,pending?.input.text??null,pending?.input.caret));return; + } + const next=index+(e.key==='ArrowUp'?-1:1); + if(e.altKey) {if(!isNew&&index>0&&next>0&&next=0&&nextthis.setStatus(`Not accepted: ${error?.message||error}`,true)); + } + + authorityPanelKeyDown(e) { + if(this.destroyed||e.isComposing)return; + const key=(e.key||'').toLowerCase(),mod=e.ctrlKey||e.metaKey; + if(e.key==='Escape') { + e.preventDefault();e.stopPropagation(); + const pending=this.editor.authorityDrafts.has(`alternative-live-${this.authorityInputGroup||0}`); + const cancel=(this.live||this.authorityLiveStarting||pending)?this.authorityCancelLive():Promise.resolve(); + void cancel.then(()=>this.authorityClose({restoreFocus:true})).catch(error=>this.setStatus(String(error),true));return; + } + if(mod&&typeof teShortcutMatches==='function'&&teShortcutMatches(e,'ctrl+shift+a')){e.preventDefault();void this.authorityClose({restoreFocus:true});return;} + const undo=mod&&key==='z'&&!e.shiftKey&&!e.altKey,redo=mod&&((key==='z'&&e.shiftKey&&!e.altKey)||(key==='y'&&!e.shiftKey&&!e.altKey)); + if(!undo&&!redo)return; + const input=e.target instanceof HTMLInputElement?e.target:null; + if(input&&!this.live&&!this.authorityLiveStarting&&input.value!==(input.dataset.committed||''))return; + e.preventDefault();e.stopPropagation(); + void this.finishLive().then(()=>undo?this.surface.undo():this.surface.redo()).then(()=>this.flush()).catch(error=>this.setStatus(String(error),true)); + } + onTabKeyDown(e) { const order = TE_ALT_KINDS; let i = order.indexOf(this.tab); @@ -1267,6 +1459,7 @@ class TeAlternativesPanel { /** Switch granularity (R-4.2) around the reference point. */ setTab(kind, { focusTab = false } = {}) { + if(this.editor.authority && !this.authorityLocal) return this.authoritySetTab(kind,{focusTab}); if (!TE_ALT_KINDS.includes(kind)) throw new Error(`Unknown tab ${kind}`); this.finishLive(); this.commitFocused(); @@ -1291,8 +1484,9 @@ class TeAlternativesPanel { */ destroy() { if (this.destroyed) return []; - // A live addition is already in the document: keep it as one step. - this.finishLive(); + // Native close/backup settles the opt-in authority before teardown. + // Teardown itself only reports drafts and never starts a new writer. + if(!this.editor.authority)this.finishLive(); const drafts = this.pendingDrafts(); if (drafts.length && this.surface.root) { this.surface.root.dispatchEvent(new CustomEvent('te:alt-drafts', { diff --git a/public/js/blocks.js b/public/js/blocks.js index 5c491cd..b470da8 100644 --- a/public/js/blocks.js +++ b/public/js/blocks.js @@ -177,6 +177,7 @@ class BlocksView { // Drafts kept after an outside change overlapped an open block editor. this.drafts = []; this.nextDraftId = 1; + this.authorityDraftId = 0; // Blocks was showing when Write_On mode, or hiding the plain-mode tools, // took over. this.resumeAfterWriteOn = false; @@ -660,6 +661,7 @@ class BlocksView { * the caret goes to the edited position or the start of the focused block. */ setView(view, { persist = true, focus = true } = {}) { + if(this.authorityEnabled())return this.authoritySetView(view,{persist,focus}); if (this.destroyed) return; const next = view === 'blocks' ? 'blocks' : 'text'; if (next === 'blocks' && this.writeOnActive()) return; @@ -674,7 +676,7 @@ class BlocksView { this.container.hidden = false; this.setCurrent(this.focusIndex, focus); } else { - let target = null; + let target = this.authorityViewSelection || null; if (this.editing) { target = this.commitEdit({ rerender: false }); } @@ -746,6 +748,7 @@ class BlocksView { } onSurfaceChange(change) { + if(this.authorityApplying){this.authorityChanges.push(change);this.dirty=true;return;} const edit = change && change.edit; const whole = !!edit && BlocksView.replacesWhole(change); // Kept drafts follow every change, in either view. @@ -863,6 +866,7 @@ class BlocksView { // the whole document was replaced. anchor: whole ? null : edit ? edit.start : ed.start, reason: reason || (whole ? 'replaced' : 'changed'), + ...(ed.authorityKey ? {authorityKey:ed.authorityKey,authoritySequence:ed.authoritySequence} : {}), }; this.drafts.push(draft); this.renderDrafts(); @@ -974,6 +978,7 @@ class BlocksView { * 'replace' or 'insert', or null when there is no such draft. */ applyDraft(id) { + if(this.authorityEnabled())return this.authorityApplyDraft(id); const draft = this.drafts.find((d) => d.id === id); if (!draft) return null; if (this.editing) this.commitEdit({ rerender: false }); @@ -1008,6 +1013,9 @@ class BlocksView { /** Drop a kept draft. */ discardDraft(id) { + const draft=this.drafts.find(d=>d.id===id); + if(this.authorityEnabled()&&draft?.authorityApplying)return this.authorityRun(()=>this.discardDraft(id)); + if(this.editor.authority&&draft?.authoritySequence)this.editor.authorityClearDraft(draft.authorityKey,draft.authoritySequence); const before = this.drafts.length; this.drafts = this.drafts.filter((d) => d.id !== id); this.renderDrafts(); @@ -1020,6 +1028,12 @@ class BlocksView { * one blank line. Returns the offset where the paragraph starts. */ insertParagraph(at, value) { + if(this.editor.authority&&!this.editor.authorityPainting) { + const text=this.surface.getText(),before=text.slice(0,at),after=text.slice(at); + const pre=at===0||before.endsWith("\n\n")?"":before.endsWith("\n")?"\n":"\n\n"; + const post=after.trim()===""||after.startsWith("\n\n")?"":after.startsWith("\n")?"\n":"\n\n"; + return this.surface.replaceRange(at,at,pre+value+post,{source:"blocks",selectStart:at+pre.length}).then(()=>at+pre.length); + } const text = this.surface.getText(); const before = text.slice(0, at); const after = text.slice(at); @@ -1035,6 +1049,7 @@ class BlocksView { /** Open the inline editor for block `index`. Returns the textarea. */ editBlock(index) { + if(this.authorityEnabled())return this.authorityOpenEditor(index); if (!this.isActive()) return null; if (this.editing) this.commitEdit(); const cards = this.cards(); @@ -1059,6 +1074,7 @@ class BlocksView { this.editing = { ...edit, card, textarea: ta, selection: null }; this.listen(ta, 'input', () => { ta.rows = Math.max(2, ta.value.split('\n').length + 1); + this.reportAuthorityDraft(this.editing); }); // While the textarea has focus its selection is the document selection, // which an outside surface edit moves; remember it so render() can put @@ -1087,6 +1103,7 @@ class BlocksView { * when nothing was being edited. */ commitEdit({ rerender = true, focus } = {}) { + if(this.authorityEnabled())return this.commitEditAuthority({rerender,focus}); const edit = this.editing; if (!edit) return null; this.editing = null; @@ -1142,6 +1159,10 @@ class BlocksView { * the kept draft, or null when nothing was typed. */ cancelEdit() { + if(this.authorityEnabled()&&this.editing?.authorityCommit) { + const pending=this.editing.authorityCommit; + return this.authorityRun(async()=>{await pending;return this.cancelEdit();}); + } const edit = this.editing; if (!edit) return null; this.editing = null; @@ -1153,6 +1174,7 @@ class BlocksView { /** Open an editor for a new paragraph after block `index` (-1: at the top). */ insertBlockAfter(index) { + if(this.authorityEnabled())return this.authorityOpenEditor(index,true); if (!this.isActive()) return null; if (this.editing) this.commitEdit(); const blocks = this.model.blocks; @@ -1176,6 +1198,7 @@ class BlocksView { /** Delete block `index` together with one adjoining separator. */ deleteBlock(index) { + if(this.authorityEnabled())return this.authorityDeleteBlock(index); const blocks = this.model.blocks; const b = blocks[index]; if (!b) return; @@ -1240,6 +1263,7 @@ class BlocksView { * puts focus on that action button of the moved card instead of the card. */ moveBlock(index, direction, { focusAction = null } = {}) { + if(this.authorityEnabled())return this.authorityMoveBlock(index,direction,{focusAction}); if (this.destroyed) return false; const dir = direction === 'up' || direction < 0 ? -1 : 1; let i = index; @@ -1360,6 +1384,7 @@ class BlocksView { } apply(from, to, insert) { + if(this.authorityEnabled())return this.authorityApply(from,to,insert); if (this.editing) this.commitEdit({ rerender: false }); this.applying = true; try { @@ -1371,6 +1396,7 @@ class BlocksView { } undo() { + if(this.authorityEnabled())return this.authorityHistory(false); if (this.editing) this.commitEdit({ rerender: false }); this.applying = true; let done; @@ -1384,6 +1410,7 @@ class BlocksView { } redo() { + if(this.authorityEnabled())return this.authorityHistory(true); if (this.editing) this.commitEdit({ rerender: false }); this.applying = true; let done; @@ -1493,6 +1520,219 @@ class BlocksView { } } + authorityEnabled() { return !!this.editor.authority && !this.authorityLocal; } + + authorityRun(work) { + this.authorityPendingActions=(this.authorityPendingActions||0)+1; + const pending=(this.authorityQueue||Promise.resolve()).then(()=> { + if(this.destroyed)throw new Error('Blocks view disposed; draft retained'); + return work(); + }).finally(()=>{this.authorityPendingActions-=1;}); + this.authorityQueue=pending.catch(error=>this.showNotice(`Block not accepted: ${error?.message||error}`)); + return pending; + } + + reportAuthorityDraft(edit) { + if(!this.editor.authority||!edit)return; + if(!edit.authorityKey)edit.authorityKey=`block:${++this.authorityDraftId}`; + const value=window.EditorSurface.normaliseNewlines(edit.textarea.value); + if(edit.isNew ? !value.trim() : value===edit.original) { + if(edit.authoritySequence)this.editor.authorityClearDraft(edit.authorityKey,edit.authoritySequence); + return; + } + edit.authoritySequence=this.editor.authorityReportDraft(edit.authorityKey,{ + value,originalText:edit.original,anchor:edit.start,index:edit.index,isNew:edit.isNew, + }); + } + + async settledAuthority() { + if(!this.editor.authority)return; + let queued; + do { + queued=this.authorityQueue; + if(queued)await queued; + await this.flushAuthorityEditor(); + } while(queued!==this.authorityQueue); + } + + async flushAuthorityEditor(options={}) { + let selection=null; + while(this.editing)selection=await this.commitEditAuthority(options); + return selection; + } + + async authorityMutation(work) { + const expectedBody=this.surface.getText(); + // Earlier accepted operations must map their events normally; only this + // mutation's tentative events are deferred until its real native finish. + await this.editor.authorityQueue; + if(expectedBody!==this.surface.getText())throw new Error("Document changed before block operation; draft retained"); + this.authorityChanges=[];this.authorityApplying=true;this.applying=true; + let accepted=false; + try {const result=await work();accepted=true;return result;} + finally { + const changes=this.authorityChanges;this.authorityChanges=null; + this.authorityApplying=false;this.applying=false; + if(accepted)for(const change of changes) { + const edit=change?.edit; + if(edit&&this.drafts.length)this.mapDrafts(edit,BlocksView.replacesWhole(change)); + } + } + } + + commitEditAuthority({rerender=true,focus}={}) { + const edit=this.editing; + if(!edit)return Promise.resolve(null); + if(edit.authorityCommit)return edit.authorityCommit; + const run=async()=> { + const ta=edit.textarea,value=window.EditorSurface.normaliseNewlines(ta.value); + const selStart=Math.min(ta.selectionStart,value.length),selEnd=Math.min(ta.selectionEnd,value.length); + let base=edit.start; + this.reportAuthorityDraft(edit); + const sequence=edit.authoritySequence; + try { + await this.authorityMutation(async()=> { + const text=this.surface.getText(); + const stale=edit.isNew?edit.start>text.length:text.slice(edit.start,edit.start+edit.original.length)!==edit.original; + if(stale)throw new Error('Block changed before this operation; your draft is retained'); + if(edit.isNew) { + if(value.trim())base=await this.insertParagraph(edit.start,value); + } else if(value!==edit.original) { + const diff=window.EditorSurface.diff(edit.original,value),from=edit.start+diff.start; + await this.surface.replaceRange(from,from+diff.deletedLength,diff.insertedText,{source:'blocks',selectStart:edit.start+selStart,selectEnd:edit.start+selEnd}); + } + }); + if(sequence)this.editor.authorityClearDraft(edit.authorityKey,sequence); + if(this.editing===edit) { + if(window.EditorSurface.normaliseNewlines(ta.value)===value)this.editing=null; + else {edit.original=value;edit.start=base;edit.isNew=false;this.reportAuthorityDraft(edit);} + } + if(rerender&&this.isActive()) { + const hadFocus=focus===undefined?this.container.contains(document.activeElement)||document.activeElement===document.body:focus; + this.render(false);this.focusIndex=this.blockIndexAt(base+selStart); + if(!this.editing)this.setCurrent(this.focusIndex,hadFocus); + } + return {start:base+selStart,end:base+selEnd}; + } catch(error) {this.showNotice(`Block not accepted: ${error?.message||error}`);throw error;} + finally {delete edit.authorityCommit;} + }; + edit.authorityCommit=run(); + // Event callers may ignore the Promise; direct callers still get rejection. + edit.authorityCommit.catch(()=>{}); + return edit.authorityCommit; + } + + authoritySetView(view,options) { + const next=view==='blocks'?'blocks':'text'; + if(next===this.view)return Promise.resolve(); + return this.authorityRun(async()=> { + const target=await this.flushAuthorityEditor({rerender:false,focus:false}); + this.authorityViewSelection=target;this.authorityLocal=true; + try{return this.setView(view,options);}finally{this.authorityLocal=false;this.authorityViewSelection=null;} + }); + } + + authorityOpenEditor(index,insert=false) { + return this.authorityRun(async()=> { + await this.flushAuthorityEditor(); + this.authorityLocal=true; + try{return insert?this.insertBlockAfter(index):this.editBlock(index);}finally{this.authorityLocal=false;} + }); + } + + authorityApplyDraft(id) { + return this.authorityRun(async()=> { + await this.flushAuthorityEditor({rerender:false}); + const draft=this.drafts.find(d=>d.id===id);if(!draft)return null; + const target=this.draftTarget(draft);let caret; + draft.authorityApplying=true; + try {await this.authorityMutation(async()=> { + if(target.mode==='replace') { + const b=target.block,diff=window.EditorSurface.diff(b.text,draft.value);caret=b.start; + if(diff)await this.surface.replaceRange(b.start+diff.start,b.start+diff.start+diff.deletedLength,diff.insertedText,{source:'blocks',selectStart:b.start}); + } else caret=await this.insertParagraph(target.at,draft.value); + });} finally {draft.authorityApplying=false;} + this.drafts=this.drafts.filter(d=>d!==draft); + if(draft.authoritySequence)this.editor.authorityClearDraft(draft.authorityKey,draft.authoritySequence); + this.renderDrafts(); + if(this.isActive()){this.render(false);this.setCurrent(this.blockIndexAt(caret),true);} + return target.mode; + }); + } + + authorityDeleteBlock(index) { + return this.authorityRun(async()=> { + await this.flushAuthorityEditor({rerender:false}); + this.model=BlocksView.parse(this.surface.getText()); + const blocks=this.model.blocks,b=blocks[index];if(!b)return; + let from=b.start,to=b.end+b.sep.length; + if(index===blocks.length-1){to=b.end;if(index>0)from=blocks[index-1].end;} + await this.authorityMutation(()=>this.surface.replaceRange(from,to,'',{source:'blocks',selectStart:from})); + this.render(false);this.setCurrent(Math.max(0,Math.min(index,this.model.blocks.length-1)),true); + }); + } + + authorityApply(from,to,insert) { + return this.authorityRun(async()=> { + await this.flushAuthorityEditor({rerender:false}); + await this.authorityMutation(()=>this.surface.replaceRange(from,to,insert,{source:'blocks',selectStart:from})); + this.render(false); + }); + } + + authorityHistory(redo) { + return this.authorityRun(async()=> { + await this.flushAuthorityEditor({rerender:false}); + const done=await this.authorityMutation(()=>redo?this.surface.redo():this.surface.undo()); + if(done)this.afterHistory();return done; + }); + } + + authorityMoveBlock(index,direction,{focusAction=null}={}) { + return this.authorityRun(async()=> { + const dir=direction==='up'||direction<0?-1:1;let at=index; + if(this.editing) { + const ed=this.editing,before=BlocksView.parse(this.surface.getText()).blocks.length; + await this.flushAuthorityEditor({rerender:false}); + const after=BlocksView.parse(this.surface.getText()).blocks.length; + if(ed.indexend?body.slice(end,to)+piece:piece+body.slice(to,start); + let intermediate=body.slice(0,lo)+inserted+body.slice(hi); + const operations=[{kind:'move',body:intermediate,start:lo,deletedText:body.slice(lo,hi),insertedText:inserted,move:{start,end,to}}]; + for(const fix of plan.fixes) { + const diff=window.EditorSurface.diff(fix.from,fix.to),position=fix.at+diff.start; + const deleted=intermediate.slice(position,position+diff.deletedLength); + if(deleted!==fix.from.slice(diff.start,diff.start+diff.deletedLength))throw new Error('Block separator changed before move'); + intermediate=intermediate.slice(0,position)+diff.insertedText+intermediate.slice(position+diff.deletedLength); + operations.push({kind:'edit',body:intermediate,start:position,deletedText:deleted,insertedText:diff.insertedText}); + } + if(intermediate!==plan.text)throw new Error('Block move plan does not match its exact edits'); + await this.authorityMutation(()=>this.editor.authorityEnqueue(()=>this.editor.authorityTransaction({route:'history',expectedBody:body,targetBody:plan.text,operationsJson:JSON.stringify(operations)},()=> { + this.editor.paintMoveRange({},...plan.range); + for(const fix of plan.fixes) { + const diff=window.EditorSurface.diff(fix.from,fix.to); + this.surface.replaceRange(fix.at+diff.start,fix.at+diff.start+diff.deletedLength,diff.insertedText,{source:'blocks',selectStart:plan.movedStart}); + } + if(operations.length>1&&!this.surface.squashHistory(operations.length,'move'))throw new Error('Block move history could not be grouped'); + this.surface.setSelectionOffsets(plan.movedStart,plan.movedStart); + }))); + this.showNotice(null); + if(this.isActive()) { + this.render(false);const target=at+dir,card=this.cards()[target]; + const button=focusAction&&card?card.querySelector(`.te-block-action[data-action="${focusAction}"]`):null; + this.setCurrent(target,!(button&&!button.hidden));if(button&&!button.hidden)button.focus(); + } + return true; + }); + } + // --------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------- diff --git a/public/js/chrome.js b/public/js/chrome.js index 5fddc9e..ebbe96a 100644 --- a/public/js/chrome.js +++ b/public/js/chrome.js @@ -470,11 +470,28 @@ class WriteOnChrome { } toggle() { - this.setMode(this.isWriteOn() ? 'plain' : 'write-on'); + const current=this.authorityModePending?this.authorityRequestedMode:this.mode; + return this.setMode(current==='write-on'?'plain':'write-on'); } setMode(mode, { persist = true } = {}) { if (this.destroyed) return; + const blocks=this.editor.blocks; + if(this.editor.authority&&!this.authorityModePainting&&blocks&& + (blocks.editing||blocks.authorityApplying||blocks.authorityPendingActions||this.authorityModePending)) { + this.authorityRequestedMode=mode==='write-on'?'write-on':'plain'; + this.authorityModePending=(this.authorityModePending||0)+1; + const pending=(this.authorityModeQueue||Promise.resolve()).then(async()=> { + try { + await blocks.settledAuthority(); + if(this.destroyed)return; + this.authorityModePainting=true; + try{return this.setMode(mode,{persist});}finally{this.authorityModePainting=false;} + } finally {this.authorityModePending-=1;} + }); + this.authorityModeQueue=pending.catch(error=>blocks.showNotice(`Mode not changed: ${error?.message||error}`)); + return pending; + } const next = mode === 'write-on' ? 'write-on' : 'plain'; const changed = next !== this.mode; this.mode = next; diff --git a/public/js/editor.js b/public/js/editor.js index 3aa2bdc..1078539 100644 --- a/public/js/editor.js +++ b/public/js/editor.js @@ -150,6 +150,7 @@ class EditorSurface { * source (a label passed to change listeners and used for undo grouping). */ replaceRange(start, end, insert, options = {}) { + if (this.authorityEditor && !this.authorityEditor.authorityPainting) return this.authorityEditor.authorityReplaceRange(start, end, insert, options); const source = options.source || 'api'; const old = this.text; let s = this.snap(this.clamp(Math.min(start, end)), old); @@ -208,6 +209,7 @@ class EditorSurface { * edit). Returns the applied edits, or [] when nothing changed. */ replaceRanges(edits, options = {}) { + if (this.authorityEditor && !this.authorityEditor.authorityPainting) return this.authorityEditor.authorityReplaceRanges(edits, options); const source = options.source || 'api'; const sorted = (edits || []) .map((e) => ({ start: Math.min(e.start, e.end), end: Math.max(e.start, e.end), insert: String(e.insert || '') })) @@ -249,6 +251,24 @@ class EditorSurface { /** Replace the current selection with `insert`. */ replaceSelection(insert, source = 'api') { + if (this.authorityEditor && !this.authorityEditor.authorityPainting) { + // Enter/paste act on the visible raw input, which may be ahead of the + // accepted native projection. Keep that input visible and queue its + // exact text through the same guarded path as browser typing. + const before = this.serialise().text; + const selection = this.getSelectionOffsets(); + const at = offset => this.snap(Math.max(0, Math.min(before.length, offset)), before); + const start = at(selection.start), end = at(selection.end); + const text = EditorSurface.normaliseNewlines(String(insert)); + const after = before.slice(0, start) + text + before.slice(end); + const caret = start + text.length; + this.root.textContent = after; + this.pendingSelection = null; + this.lastSelection = { start: caret, end: caret, direction: 'none' }; + const node = this.root.firstChild, live = document.getSelection(); + if (node && live && document.activeElement === this.root) live.setBaseAndExtent(node, caret, node, caret); + return this.authorityEditor.authorityInput(after, this.lastSelection, source, { start, end, exact: true }); + } const { start, end } = this.getSelectionOffsets(); return this.replaceRange(start, end, insert, { source }); } @@ -508,6 +528,7 @@ class EditorSurface { } undo() { + if (this.authorityEditor && !this.authorityEditor.authorityPainting) return this.authorityEditor.authorityHistory('undo'); if (this.historyOwner) { this.historyOwner.undo(); return true; } if (!this.canUndo()) return false; const undone = this.history[this.historyIndex]; @@ -521,6 +542,7 @@ class EditorSurface { } redo() { + if (this.authorityEditor && !this.authorityEditor.authorityPainting) return this.authorityEditor.authorityHistory('redo'); if (this.historyOwner) { this.historyOwner.redo(); return true; } if (!this.canRedo()) return false; const next = this.history[this.historyIndex + 1]; @@ -723,6 +745,13 @@ class EditorSurface { * external DOM mutation with the selection elsewhere. */ sync(source, hint = null, inputType = '') { + if (this.authorityEditor && !this.authorityEditor.authorityPainting) { + const selection = document.getSelection(); + const anchor = selection && this.root.contains(selection.anchorNode) ? this.pointToOffset(selection.anchorNode, selection.anchorOffset) : this.text.length; + const focus = selection && this.root.contains(selection.focusNode) ? this.pointToOffset(selection.focusNode, selection.focusOffset) : anchor; + this.authorityEditor.authorityInput(this.serialise().text, {start:Math.min(anchor,focus),end:Math.max(anchor,focus),direction:anchor>focus?'backward':'forward'}, source, hint); + return; + } const shape = this.isCanonical(); const text = shape.ok ? this.root.textContent : this.serialise().text; const old = this.text; @@ -1464,9 +1493,9 @@ class MarkdownEditor { if (this.decorations) this.decorations.destroy(); if (this.surface) this.surface.destroy(); // Host providers (#83) belong to this editor; the registry is the page's. - for (const id of Array.from((this.alternativeProviders || new Map()).keys())) { - this.unregisterAlternativesProvider(id); - } + this.authorityControlPainting=true; + try {for (const id of Array.from((this.alternativeProviders || new Map()).keys())) this.unregisterAlternativesProvider(id);} + finally {this.authorityControlPainting=false;} this.warningListeners.clear(); return drafts; } @@ -1574,7 +1603,7 @@ class MarkdownEditor { const wrappedText = selection ? selection : 'text'; this.surface.focus(); - this.surface.replaceRange(start, end, prefix + wrappedText + suffix, { + return this.surface.replaceRange(start, end, prefix + wrappedText + suffix, { source: 'format', selectStart: start + prefix.length, selectEnd: selection ? end + prefix.length : start + prefix.length + 4, @@ -1656,11 +1685,11 @@ class MarkdownEditor { item.append(teConfigIcon(cmd.icon), teTextElement('span', cmd.name)); item.addEventListener('click', () => { - if (slashPosition !== null && this.surface.getText().charAt(slashPosition) === '/') { - this.surface.replaceRange(slashPosition, slashPosition + 1, '', { source: 'command' }); - } - cmd.action(); - hideCommandMenu(); + const removed=slashPosition !== null && this.surface.getText().charAt(slashPosition) === '/' + ? this.surface.replaceRange(slashPosition,slashPosition+1,'',{source:'command'}) : null; + const invoke=()=>{cmd.action();hideCommandMenu();}; + if(removed&&typeof removed.then==='function')void removed.then(invoke).catch(error=>this.ghostHistoryNotice(error)); + else invoke(); }, { signal }); commandList.appendChild(item); @@ -1797,6 +1826,243 @@ class MarkdownEditor { * null when the module does not expose it, in which case the editor still * works as a plain Markdown editor. */ + /** Opt-in host authority. Standalone callers keep their synchronous API. */ + setAuthority(adapter) { + if (!adapter || typeof adapter.transact !== 'function') throw new Error('An asynchronous native authority adapter is required'); + if (this.authority && this.authority !== adapter) throw new Error('Explicitly reopen before replacing native authority'); + this.authority = adapter; + if(this.graphClient?.options?.roleCoordinator)this.graphClient.options.roleCoordinator.allowAsyncCommit=true; + this.authorityQueue = Promise.resolve(); + this.authorityQueueGeneration = 0; + this.authorityDraftSequence = 0; + this.authorityDrafts = new Map(); + this.surface.authorityEditor = this; + this.surface.ghostHistoryAdapter = this; + const status = document.createElement('div'); + status.className = 'te-authority-status'; status.setAttribute('role','status'); status.setAttribute('aria-live','polite'); + this.surface.root.before(status); this.createdNodes.push(status); this.authorityStatus = status; + return this; + } + + /** Only the host's verified explicit fresh-authority recovery may reset this view. */ + resetAuthorityAfterRecovery() { + const retained=this.pendingAuthorityDraft(); + this.authorityQueueGeneration=(this.authorityQueueGeneration||0)+1; + this.authorityQueue=Promise.resolve();this.authorityFault=null;this.authorityDrafts.clear(); + this.authorityStatus.textContent='';this.surface.root.removeAttribute('aria-readonly'); + if(this.altPanel) { + this.altPanel.authorityCancelGeneration=(this.altPanel.authorityCancelGeneration||0)+1; + if(this.altPanel.live)this.altPanel.endLive(); + this.altPanel.authorityLiveStarting=false;this.altPanel.authorityLineQueue=Promise.resolve(); + this.altPanel.authorityInputGroup=(this.altPanel.authorityInputGroup||0)+1; + } + return retained; + } + + pendingAuthorityDraft() { + return this.authority ? {sequence:this.authorityDraftSequence, intents:Array.from(this.authorityDrafts.values(), value => structuredClone(value))} : null; + } + + authorityReportDraft(key, input) { + const sequence = ++this.authorityDraftSequence; + this.authorityDrafts.set(key, {key,sequence,input:structuredClone(input)}); + if (this.authority.reportDraft) this.authority.reportDraft(this.pendingAuthorityDraft()); + return sequence; + } + + authorityClearDraft(key, sequence) { + if (this.authorityDrafts.get(key)?.sequence === sequence) this.authorityDrafts.delete(key); + if (this.authority.reportDraft) this.authority.reportDraft(this.pendingAuthorityDraft()); + } + + async authoritySettled() { + if (!this.authority) return; + if (this.overflow?.pending()) await this.overflow.flush(); + if (this.blocks?.settledAuthority) await this.blocks.settledAuthority(); + if (this.altPanel?.flushAuthorityInputs) await this.altPanel.flushAuthorityInputs(); + let queued; + do { queued = this.authorityQueue; await queued; } while (queued !== this.authorityQueue); + if (this.authorityFault) throw this.authorityFault; + if (this.authorityDrafts.size) throw new Error('Unaccepted raw input remains available for recovery'); + } + + authorityHistoryChanged() { + if (!this.authority) return; + if (typeof this.authority.historyChanged !== 'function') throw new Error('Native history synchronization is unavailable'); + return this.authority.historyChanged(); + } + + authorityRenderDraft() { + const draft=this.authorityDrafts.get('body'); + if (!draft || draft.input.text===this.surface.getText()) return; + this.surface.root.textContent=draft.input.text; + const node=this.surface.root.firstChild, sel=document.getSelection(), selection=draft.input.selection; + if(node && sel && document.activeElement===this.surface.root) sel.setBaseAndExtent(node,Math.min(selection.start,node.length),node,Math.min(selection.end,node.length)); + } + + authorityEnqueue(work) { + const generation=this.authorityQueueGeneration; + const queued = this.authorityQueue.then(async () => { + if(generation!==this.authorityQueueGeneration)throw new Error('Pending operation belongs to a recovered authority'); + if (this.destroyed) throw new Error('Editor disposed; pending draft retained by host'); + if (this.authorityFault) throw this.authorityFault; + this.authorityStatus.textContent = 'Applying pending changes…'; + try { return await work(); } + finally { if (generation===this.authorityQueueGeneration && !this.authorityFault) this.authorityStatus.textContent = ''; } + }); + // Keep the queue usable after a confirmed refusal; callers still receive it. + this.authorityQueue = queued.catch(error => { + if(generation!==this.authorityQueueGeneration)return; + if (error?.authorityUncertain) this.authorityFault = error; + this.authorityStatus.textContent = `Not accepted: ${error?.message || error}`; + if (this.authorityFault) this.surface.root.setAttribute('aria-readonly','true'); + }); + return queued; + } + + async authorityTransaction(request, paint) { + const generation=this.authorityQueueGeneration; + const api = this.requireDocumentApi(); + const saved = this.ghostHistoryCheckpoint(api); + // Keep object identities used by alternatives.live.base across rollback. + const references = this.surface.history.slice(); + const source = api.save_document(); + let painted = false; + try { + const accepted = await this.authority.transact(request, async started => { + if(generation!==this.authorityQueueGeneration)throw new Error('Prepared outcome belongs to a recovered authority'); + if (!started || started.ok !== true || typeof started.token !== 'string' || typeof started.source !== 'string') throw new Error('Invalid prepared native outcome'); + const focus=document.activeElement; + const focusCaret=focus instanceof HTMLInputElement||focus instanceof HTMLTextAreaElement ? [focus.selectionStart,focus.selectionEnd] : null; + this.authorityPainting = true; + try { + // This is ONLY the disposable view projection, never the native owner. + api.open_document(started.source); + await paint(started, saved); + if(generation!==this.authorityQueueGeneration)throw new Error('Painted outcome belongs to a recovered authority'); + if (api.document_body() !== this.surface.getText()) throw new Error('Prepared native body differs from tentative surface'); + painted = true; + if (typeof window.TerraphimHistoryCheckpoint?.capture === 'function') return window.TerraphimHistoryCheckpoint.capture(this.surface,started.presentationSource || api.save_document(),this.altPanel); + } finally { + if(generation===this.authorityQueueGeneration && focus?.isConnected && focus!==this.surface.root && typeof focus.focus==='function') { + focus.focus();if(focusCaret)focus.setSelectionRange(...focusCaret); + } + this.authorityPainting = false; this.authorityRenderDraft(); + } + }); + if(generation!==this.authorityQueueGeneration)throw new Error('Accepted outcome belongs to a recovered authority'); + if (accepted) { + this.surface.root.dispatchEvent(new CustomEvent('te:authority-accepted',{bubbles:true,detail:{editor:this}})); + } + return accepted; + } catch (error) { + if(generation!==this.authorityQueueGeneration)throw error; + if (error?.authorityRolledBack || error?.authorityUnchanged) { + this.authorityPainting = true; + try { + api.open_document(source); + this.restoreGhostHistoryCheckpoint(saved, api); + for (let index=0;index this.authorityTransaction({route:'action',expectedBody:this.surface.getText(),meta,textArgs},paint)); + } + + static async authorityHash(text) { + const digest = await crypto.subtle.digest('SHA-256',new TextEncoder().encode(text)); + return Array.from(new Uint8Array(digest), value=>value.toString(16).padStart(2,'0')).join(''); + } + + async authorityEditMeta(body, start, end, inserted, insertedRef) { + return {start,deleteUtf16:end-start,insertedRef,beforeBodyHash:await MarkdownEditor.authorityHash(body),afterBodyHash:await MarkdownEditor.authorityHash(body.slice(0,start)+inserted+body.slice(end))}; + } + + authorityReplaceRange(start, end, insert, options = {}) { + const before = this.surface.getText(); + return this.authorityEnqueue(async () => { + if (before !== this.surface.getText()) throw new Error('Text action base changed; input retained'); + const text = EditorSurface.normaliseNewlines(String(insert)); + const a = this.surface.snap(this.surface.clamp(Math.min(start,end))), b=this.surface.snap(this.surface.clamp(Math.max(start,end))); + const meta = {kind:'text_edit',...await this.authorityEditMeta(before,a,b,text,0)}; + let edit; + const accepted=await this.authorityTransaction({route:'action',expectedBody:before,meta,textArgs:[text]}, () => { edit=this.surface.replaceRange(a,b,text,options); }); + return accepted ? edit : null; + }); + } + + authorityReplaceRanges(edits, options = {}) { + const before = this.surface.getText(); + return this.authorityEnqueue(async () => { + if(before!==this.surface.getText()) throw new Error('Batch action base changed; input retained'); + const ordered=edits.map(edit=>({start:Math.min(edit.start,edit.end),end:Math.max(edit.start,edit.end),insert:EditorSurface.normaliseNewlines(String(edit.insert||''))})).sort((a,b)=>b.start-a.start); + let body=before; const batch=[], args=[]; + for(const edit of ordered) { batch.push(await this.authorityEditMeta(body,edit.start,edit.end,edit.insert,args.length));args.push(edit.insert);body=body.slice(0,edit.start)+edit.insert+body.slice(edit.end); } + if(!batch.length)return []; + let applied; + const accepted=await this.authorityTransaction({route:'action',expectedBody:before,meta:{kind:'text_edits',edits:batch},textArgs:args},()=>{applied=this.surface.replaceRanges(edits,options);}); + return accepted ? applied : []; + }); + } + + authorityInput(text, selection, source, hint) { + if (this.authorityPainting || (text === this.surface.getText() && !this.authorityDrafts.has('body'))) return; + const sequence=this.authorityReportDraft('body',{text,selection,source}); + this.authorityEnqueue(async () => { + const before=this.surface.getText(), edit=EditorSurface.diff(before,text,hint); + if(!edit) {this.authorityClearDraft('body',sequence);return;} + const meta={kind:'text_edit',...await this.authorityEditMeta(before,edit.start,edit.start+edit.deletedLength,edit.insertedText,0)}; + await this.authorityTransaction({route:'action',expectedBody:before,meta,textArgs:[edit.insertedText]},()=> { + this.surface.replaceRange(edit.start,edit.start+edit.deletedLength,edit.insertedText,{source,selectStart:selection.start,selectEnd:selection.end}); + }); + this.authorityClearDraft('body',sequence); + const later=this.authorityDrafts.get('body'); + if(later && later.sequence>sequence) { + this.surface.root.textContent=later.input.text; + const node=this.surface.root.firstChild, sel=document.getSelection(); + if(node && sel && document.activeElement===this.surface.root) sel.setBaseAndExtent(node,Math.min(later.input.selection.start,node.length),node,Math.min(later.input.selection.end,node.length)); + } + }).catch(error=>this.ghostHistoryNotice(error)); + } + + authorityHistory(direction) { + return this.authorityEnqueue(async () => { + const surface=this.surface; + const index=direction==='undo'?surface.historyIndex-1:surface.historyIndex+1; + if(index<0 || index>=surface.history.length)return false; + const entry=direction==='undo'?surface.history[surface.historyIndex]:surface.history[index]; + let steps=entry.edits; + if(!Array.isArray(steps))throw new Error('Exact existing history steps are unavailable'); + if(direction==='undo')steps=steps.slice().reverse().map(EditorSurface.invertStep); + const body=surface.getText(), target=surface.history[index]; + const applied=EditorSurface.replaySteps(body,steps,target.text); + if(!applied)throw new Error('Existing history steps do not reproduce the target'); + const operations=applied.map(({edit,text})=> { + if(edit.ghost)return {kind:'ghost',body:text,...edit.ghost}; + const base={body:text,start:edit.start,deletedText:edit.deletedText,insertedText:edit.insertedText}; + if(edit.alt)return {...base,kind:'alt',span:edit.alt.span,target:edit.alt.after}; + if(edit.move)return {...base,kind:'move',move:edit.move}; + if(edit.swap)return {...base,kind:'swap',...edit.swap}; + if(edit.overflow)return {...base,kind:'overflow',...edit.overflow}; + return {...base,kind:'edit'}; + }); + const accepted=await this.authorityTransaction({route:'history',expectedBody:body,targetBody:target.text,operationsJson:JSON.stringify(operations)},started=> { + surface.historyIndex=index;this.paintGhostHistory(started.annotations,applied); + surface.setSelectionOffsets(target.start,target.end); + }); + if(accepted)this.notifyGhostHistory(direction==='undo'?'historyUndo':'historyRedo',accepted.annotations); + return !!accepted; + }); + } + documentApi() { const api = (this.config && this.config.bindings) || window.wasmBindings; return api && typeof api.open_document === 'function' && typeof api.apply_edit === 'function' @@ -1928,6 +2194,11 @@ class MarkdownEditor { } commitTrimGhosts(level, kept) { + if(this.authority) return this.authorityEnqueue(()=>this.authorityTransaction({route:'trim',expectedBody:this.surface.getText(),level,kept},(started,saved)=>{ + if(!started.change||!started.annotations)throw new Error('Native Trim receipt is missing'); + const surface=this.surface;surface.record('trim-ghost',surface.lastSelection,{start:0,deletedText:'',insertedText:'',ghost:{change:structuredClone(started.change),undo:false}}); + this.paintGhostHistory(started.annotations);surface.setSelectionOffsets(saved.selection.start,saved.selection.end,saved.selection.direction); + })).then(started=>{if(started)this.notifyGhostHistory('insertReplacementText',started.annotations);return started;}); const s = this.surface; if (!Number.isInteger(s.historyLimit) || s.historyLimit < 2 || s.historyIndex < 0) { throw new Error('History capacity must retain a base state and one ghost operation'); @@ -2051,6 +2322,7 @@ class MarkdownEditor { } mirrorEdit(change) { + if (this.authority) return; // Host authority, local WASM is a read-only projection. if (this.suppressModelSync) return; if (this.ghostHistoryConflict) { this.ghostHistoryNotice(new Error('Explicitly reopen before mirroring edits after a ghost history conflict')); @@ -2112,6 +2384,13 @@ class MarkdownEditor { * model outcome ({ detached, reattached, setAside, warning, notice }). */ moveRange(start, end, to) { + if(this.authority) return this.authorityEnqueue(async()=> { + const body=this.surface.getText(),lo=Math.min(start,to),hi=Math.max(end,to),piece=body.slice(start,end); + const insert=to>end?body.slice(end,to)+piece:piece+body.slice(to,start); + const target=body.slice(0,lo)+insert+body.slice(hi); + const operationsJson=JSON.stringify([{kind:'move',body:target,start:lo,deletedText:body.slice(lo,hi),insertedText:insert,move:{start,end,to}}]); + let outcome;await this.authorityTransaction({route:'history',expectedBody:body,targetBody:target,operationsJson},started=>{outcome=this.paintMoveRange(started.result||{},start,end,to);});return outcome; + }); const api = this.requireDocumentApi(); if (typeof api.move_document_range !== 'function') { throw new Error('The WASM document API cannot move text'); @@ -2120,6 +2399,10 @@ class MarkdownEditor { // Throws, changing nothing, if the model refuses the move (invalid range // or destination, or an item it would split). const outcome = api.move_document_range(start, end, to); + return this.paintMoveRange(outcome,start,end,to); + } + + paintMoveRange(outcome,start,end,to) { if (to === start || to === end) return { ...outcome, moved: false, start, end }; const text = this.surface.getText(); const len = end - start; @@ -2176,6 +2459,11 @@ class MarkdownEditor { * through `apply_edit`, and the KG spans re-derive from the text. */ swapAlternative(spanId, index) { + if(this.authority) { + const isKg=TeKg.isKgSpan(spanId);let outcome; + const meta=isKg?{kind:'kg_swap',spanId:String(spanId),index,roleGeneration:this.authority.roleGeneration}:{kind:'alt_active',spanId:String(spanId),index}; + return this.authorityAction(meta,[],started=>{outcome=started.result;this.paintAlternativeSwap(outcome,isKg);}).then(accepted=>accepted?outcome:null); + } const api = this.requireDocumentApi(); const isKg = TeKg.isKgSpan(spanId); const swapFn = isKg ? api.kg_swap_alternative : api.set_active_alternative; @@ -2185,6 +2473,10 @@ class MarkdownEditor { this.alignDocumentModel(api); // Throws, changing nothing, if the model refuses the swap. const outcome = swapFn(String(spanId), index); + return this.paintAlternativeSwap(outcome,isKg); + } + + paintAlternativeSwap(outcome,isKg=false) { const edit = outcome && outcome.edit; if (!edit) return outcome; const editEnd = edit.start + edit.deletedLength; @@ -2251,6 +2543,11 @@ class MarkdownEditor { * thesaurus, if it cannot be read. */ loadThesaurus(json) { + if(this.authority && !this.authorityControlPainting)return this.authorityEnqueue(async()=> { + const text=typeof json==='string'?json:JSON.stringify(json); + await this.authority.control({kind:'role-load',json:text}); + this.authorityControlPainting=true;try{return this.loadThesaurus(text);}finally{this.authorityControlPainting=false;} + }); const api = this.requireDocumentApi(); if (typeof api.kg_load_thesaurus !== 'function') { throw new Error('The WASM document API has no knowledge-graph support'); @@ -2263,6 +2560,10 @@ class MarkdownEditor { /** Forget the thesaurus: no KG spans until the next load. */ clearThesaurus() { + if(this.authority && !this.authorityControlPainting)return this.authorityEnqueue(async()=> { + await this.authority.control({kind:'role-clear'}); + this.authorityControlPainting=true;try{return this.clearThesaurus();}finally{this.authorityControlPainting=false;} + }); const api = this.documentApi(); if (api && typeof api.kg_clear_thesaurus === 'function') api.kg_clear_thesaurus(); if (!this.graphRoleListenerApi) this.syncGraphRole(); @@ -2282,8 +2583,23 @@ class MarkdownEditor { const options = Object.assign({}, this.config.rolegraph || {}, { assetBase: this.config.rolegraphAssetBase || new URL('.', document.baseURI).href, roleCoordinator: { - prepare: (text) => api.kg_prepare_role(text), + allowAsyncCommit:!!this.authority, + prepare: (text) => { + if(!this.authority)return api.kg_prepare_role(text); + return this.authorityEnqueue(async()=> { + const token=await this.authority.control({kind:'role-prepare',json:text}); + if(!this.authorityRoleTokens)this.authorityRoleTokens=new Map(); + this.authorityRoleTokens.set(token,api.kg_prepare_role(text));return token; + }); + }, commit: (token) => { + if(this.authority) return this.authorityEnqueue(async()=> { + const local=this.authorityRoleTokens?.get(token);if(!local)throw new Error('Unknown prepared role token'); + const result=await this.authority.control({kind:'role-commit',token}); + this.graphRoleCommitInProgress=true; + try {api.kg_commit_prepared_role(local);}finally{this.graphRoleCommitInProgress=false;} + this.authorityRoleTokens.delete(token);this.redrawRoleIndicators();return typeof result==='string'?JSON.parse(result):result; + }); // Check before publishing: activation must follow the authoritative generation. const current = JSON.parse(api.kg_role_snapshot_json()); if (String(BigInt(current.generation) + 1n) !== this.graphClient.generation) { @@ -2298,7 +2614,13 @@ class MarkdownEditor { this.redrawRoleIndicators(); return result; }, - discard: (token) => api.kg_discard_prepared_role(token), + discard: (token) => { + if(!this.authority)return api.kg_discard_prepared_role(token); + return this.authorityEnqueue(async()=> { + await this.authority.control({kind:'role-discard',token}); + const local=this.authorityRoleTokens?.get(token);if(local)api.kg_discard_prepared_role(local);this.authorityRoleTokens?.delete(token); + }); + }, }, onStateChange: (state) => this.graphStateChanged(state), }); @@ -2450,6 +2772,7 @@ class MarkdownEditor { /** Open the panel (Write_On) on the span the append touched. */ showAlternativesFor(sel, change) { + if(change && typeof change.then==='function')return change.then(result=>this.showAlternativesFor(sel,result)); const term = this.kgLookup(sel.start, sel.end); const spanId = change ? change.span : term && term.span; if (spanId && this.altPanel && typeof this.altPanel.open === 'function' && this.altPanel.isWriteOn()) { @@ -2484,6 +2807,12 @@ class MarkdownEditor { * Returns a function that unregisters it. */ registerAlternativesProvider(provider) { + if(this.authority && !this.authorityControlPainting)return this.authorityEnqueue(async()=> { + if(!provider||typeof provider.id!=='string'||!provider.id||provider.id==='thesaurus'||typeof provider.alternatives!=='function')throw new Error('Invalid alternatives provider'); + const model=typeof provider.model==='string'&&provider.model?provider.model:provider.id; + await this.authority.control({kind:'provider-register',id:provider.id,model}); + this.authorityControlPainting=true;try{return this.registerAlternativesProvider(provider);}finally{this.authorityControlPainting=false;} + }); if (!provider || typeof provider.id !== 'string' || !provider.id) { throw new Error('An alternatives provider needs an id'); } @@ -2513,6 +2842,11 @@ class MarkdownEditor { /** Remove a provider registered here; false when there was none. */ unregisterAlternativesProvider(id, entry = null) { + if(this.authority && !this.authorityControlPainting)return this.authorityEnqueue(async()=> { + if(!this.alternativeProviders?.has(id)||(entry&&this.alternativeProviders.get(id)!==entry))return false; + await this.authority.control({kind:'provider-unregister',id}); + this.authorityControlPainting=true;try{return this.unregisterAlternativesProvider(id,entry);}finally{this.authorityControlPainting=false;} + }); if (!this.alternativeProviders || !this.alternativeProviders.has(id)) return false; if (entry && this.alternativeProviders.get(id) !== entry) return false; const held = this.alternativeProviders.get(id); @@ -2554,6 +2888,7 @@ class MarkdownEditor { * appended. Resolves to the change, or null. */ async alternativesForSelection(ctx = null) { + const authorityRole=this.authority?.roleGeneration, authorityProviders=this.authority?.providerGeneration; if (!this.hasAlternativesProviders()) return this.kgAlternativesForSelection(ctx); const sel = ctx ? { start: ctx.start, end: ctx.end } : this.surface.getSelectionOffsets(); const body = this.surface.getText(); @@ -2577,7 +2912,7 @@ class MarkdownEditor { } }), ); - if (this.destroyed || this.surface.getText() !== body) { + if (this.destroyed || this.surface.getText() !== body || (this.authority && (authorityRole!==this.authority.roleGeneration || authorityProviders!==this.authority.providerGeneration))) { console.warn('The text changed while alternatives were being fetched; nothing appended'); return null; } @@ -2589,7 +2924,7 @@ class MarkdownEditor { }); let change = null; try { - change = this.alternativeOp('append_alternatives', start, end, JSON.stringify(supplied)); + change = await this.alternativeOp('append_alternatives', start, end, JSON.stringify(supplied)); } catch (err) { console.warn('No alternatives for the selection', err); return null; @@ -2621,6 +2956,7 @@ class MarkdownEditor { * ({ edit, overflow: { before, after }, detached, setAside, ... }). */ stashRange(start, end) { + if(this.authority) {let outcome;return this.authorityAction({kind:'stash',start:Math.min(start,end),end:Math.max(start,end)},[],started=>{outcome=started.result;this.paintStash(outcome);}).then(accepted=>accepted?outcome:null);} const api = this.requireDocumentApi(); if (typeof api.stash_document_range !== 'function') { throw new Error('The WASM document API cannot stash text'); @@ -2628,6 +2964,10 @@ class MarkdownEditor { this.alignDocumentModel(api); // Throws, changing nothing, if the model refuses the stash. const outcome = api.stash_document_range(Math.min(start, end), Math.max(start, end)); + return this.paintStash(outcome); + } + + paintStash(outcome) { const edit = outcome.edit; this.suppressModelSync = true; try { @@ -2690,6 +3030,7 @@ class MarkdownEditor { /** Make sure the model body is exactly the surface text before reading it. */ alignDocumentModel(api) { + if (this.authority) return; // Never reopen/sync the authoritative native session. if (this.ghostHistoryConflict) throw new Error('Ghost history conflict: explicitly reopen before model alignment'); if (this.ghostHistoryActive) { if (api.document_body() !== this.surface.getText()) throw new Error('Ghost transaction body conflict; implicit resync is forbidden'); @@ -2863,6 +3204,19 @@ class MarkdownEditor { * Making an alternative active is swapAlternative (issue #9). */ alternativeOp(name, ...args) { + if (this.authority) { + let meta, textArgs=[]; + if(name==='create_span') {meta={kind:'alt_create',spanKind:args[0],start:args[1],end:args[2],textRef:0,source:args[4]};textArgs=[String(args[3])];} + else if(name==='add') {meta={kind:'alt_add',spanId:args[0],textRef:0,source:args[2]};textArgs=[String(args[1])];} + else if(name==='edit') {meta={kind:'alt_edit',spanId:args[0],index:args[1],textRef:0};textArgs=[String(args[2])];} + else if(name==='remove') meta={kind:'alt_remove',spanId:args[0],index:args[1]}; + else if(name==='move') meta={kind:'alt_move',spanId:args[0],from:args[1],to:args[2]}; + else if(name==='kg_append') {meta={kind:'append_alternatives',start:args[0],end:args[1],suppliedJsonRef:0,roleGeneration:this.authority.roleGeneration,providerGeneration:this.authority.providerGeneration};textArgs=['{}'];} + else if(name==='append_alternatives') {meta={kind:'append_alternatives',start:args[0],end:args[1],suppliedJsonRef:0,roleGeneration:this.authority.roleGeneration,providerGeneration:this.authority.providerGeneration};textArgs=[args[2]];} + else return Promise.reject(new Error('Unsupported authoritative alternative operation')); + let change; + return this.authorityAction(meta,textArgs,started=>{change=started.result;if(change)this.applyAltChange(change);}).then(accepted=>accepted?change:null); + } const api = this.requireDocumentApi(); const fn = api[`alt_${name}`]; if (typeof fn !== 'function') throw new Error(`The WASM document API has no alt_${name}`); diff --git a/public/js/lab.js b/public/js/lab.js index 09fcd95..79ca9b8 100644 --- a/public/js/lab.js +++ b/public/js/lab.js @@ -507,7 +507,8 @@ class TeLabPopover { if (this.surface.getText().slice(mark.start, mark.end) !== mark.data.text) return false; const index = Array.from(this.proposals.querySelectorAll('[data-mark]')) .findIndex((b) => b.dataset.mark === mark.id); - this.surface.replaceRange(mark.start, mark.end, mark.data.proposal, { source: 'lab' }); + const applied=this.surface.replaceRange(mark.start, mark.end, mark.data.proposal, { source: 'lab' }); + const accepted=()=> { // The change listener has refreshed the list; keep focus in it. if (keepFocus && this.isOpen) { const buttons = this.proposals.querySelectorAll('[data-mark]'); @@ -518,6 +519,11 @@ class TeLabPopover { start: mark.start, end: mark.end, text: mark.data.text, proposal: mark.data.proposal, }); return true; + }; + if(applied&&typeof applied.then==='function')return applied.then(value=>value?accepted():false).catch(error=>{ + this.renderResults();this.editor.ghostHistoryNotice(error);return false; + }); + return accepted(); } /** Refresh the legend, proposals and the closed-state chip. */ diff --git a/public/js/overflow.js b/public/js/overflow.js index 102f334..e424ac3 100644 --- a/public/js/overflow.js +++ b/public/js/overflow.js @@ -380,11 +380,12 @@ class TeOverflowPanel { } schedule() { + if(this.editor.authority&&!this.destroyed&&!this.readOnly)this.editor.authorityReportDraft('overflow',{text:this.area.value}); if (this.destroyed || this.readOnly) return; if (this.timer !== null) clearTimeout(this.timer); this.timer = setTimeout(() => { this.timer = null; - this.write(); + const written=this.write();if(written&&typeof written.catch==='function')written.catch(()=>undefined); }, this.delay); } @@ -398,10 +399,23 @@ class TeOverflowPanel { if (this.timer === null) return; clearTimeout(this.timer); this.timer = null; - this.write(); + return this.write(); } write() { + if(this.editor.authority) { + if(this.readOnly)return; + const text=this.area.value,sequence=this.editor.authorityReportDraft('overflow',{text});let result; + return this.editor.authorityAction({kind:'overflow_set',textRef:0},[text],started=>{result=started.result;}).then(accepted=>{ + // A real native no-change result also represents this exact input. + // Clear only its sequence: a later keystroke still owns its raw draft. + if(accepted||this.api()?.document_overflow()===text){ + this.editor.authorityClearDraft('overflow',sequence); + if(accepted)this.changed('edit'); + } + return accepted?result:null; + }).catch(error=>{this.editor.ghostHistoryNotice(error);throw error;}); + } const api = this.api(); if (!api || this.readOnly) return; const result = api.set_document_overflow(this.area.value); @@ -412,12 +426,13 @@ class TeOverflowPanel { refresh() { if (this.destroyed) return; this.pointerDrag = null; - if (this.timer !== null) { + const draft=this.editor.authority && this.editor.authorityDrafts?.get('overflow'); + if (!draft && this.timer !== null) { clearTimeout(this.timer); this.timer = null; } const api = this.api(); - const value = api ? api.document_overflow() : ''; + const value = draft ? draft.input.text : api ? api.document_overflow() : ''; if (this.area.value !== value) this.area.value = value; this.readOnly = this.preservedBlock(); this.area.readOnly = this.readOnly; @@ -525,6 +540,7 @@ class TeOverflowPanel { * { ok: false, error } when refused (nothing changes). */ stash(start, end) { + if(this.editor.authority)return this.authorityStash(start,end); if (!this.canStash()) return { ok: false, error: 'Stashing is not available here' }; let outcome; try { @@ -572,6 +588,7 @@ class TeOverflowPanel { /** Copy `picked` ({ text, start, end } of the panel) into the page at `at`. */ pullBackAt(at, picked) { + if(this.editor.authority)return this.authorityPull(at,picked); const { selectionStart, selectionEnd, selectionDirection } = this.area; const focused = document.activeElement === this.area; let start = at; @@ -595,6 +612,28 @@ class TeOverflowPanel { return { start: edit.start, end: edit.start + edit.insertedText.length }; } + async authorityStash(start,end) { + try { + await this.flush(); + const outcome=await this.editor.stashRange(start,end); + if(!outcome)return {ok:false,error:'No native stash change'}; + this.refresh();this.open({focus:false});this.area.scrollTop=this.area.scrollHeight;this.changed('stash'); + return {ok:true,...outcome}; + } catch(error){this.editor.ghostHistoryNotice(error);return {ok:false,error:String(error?.message||error)};} + } + + async authorityPull(at,picked) { + const {selectionStart,selectionEnd,selectionDirection}=this.area,focused=document.activeElement===this.area; + await this.flush();let result; + const accepted=await this.editor.authorityAction({kind:'pull',at,from:picked.start,to:picked.end},[],started=>{ + result=started.result; + if(!result||typeof result.text!=='string'||!Number.isSafeInteger(result.start))throw new Error('Native pull insertion is missing'); + this.surface.replaceRange(result.start,result.start,result.text,{source:'overflow'}); + }); + if(focused){this.area.focus({preventScroll:true});this.area.setSelectionRange(selectionStart,selectionEnd,selectionDirection);} + return accepted?{start:result.start,end:result.start+result.text.length}:null; + } + /** Selected panel text dropped in the page: pulled back at the drop point. */ onPageDrop(e) { const drag = this.panelDrag; diff --git a/public/js/rolegraph-client.js b/public/js/rolegraph-client.js index 28d7f4f..933eeef 100644 --- a/public/js/rolegraph-client.js +++ b/public/js/rolegraph-client.js @@ -176,7 +176,7 @@ _load(op, payload) { return this._transition(async epoch => { const coordinator = this.options.roleCoordinator; - if (!coordinator?.prepare || !coordinator?.commit || !coordinator?.discard || coordinator.commit.constructor?.name === 'AsyncFunction') throw error('invalid_input', 'Role coordinator requires a synchronous atomic commit'); + if (!coordinator?.prepare || !coordinator?.commit || !coordinator?.discard || (coordinator.commit.constructor?.name === 'AsyncFunction' && !coordinator.allowAsyncCommit)) throw error('invalid_input', 'Role coordinator requires an atomic commit'); const previous = { generation: this.generation, hasRole: this.hasRole, checkpoint: this.checkpoint }; let mainToken; let activated = false; let committed = false; try { @@ -187,9 +187,14 @@ mainToken = await coordinator.prepare(candidate.thesaurusText); this._latest(epoch); await this._command('activate_role', { token: candidate.token, nextGeneration: String(BigInt(this.generation) + 1n) }, {}, true); activated = true; this._latest(epoch); - // Commit must check its opaque token synchronously against the main role. + // Standalone commits remain synchronous. A native resource authority + // explicitly opts in to an acknowledged commit; its opaque token still + // validates the current role atomically before native publication. const commitResult = coordinator.commit(mainToken); - if (commitResult && typeof commitResult.then === 'function') throw error('invalid_input', 'Role coordinator commit must be synchronous'); + if (commitResult && typeof commitResult.then === 'function') { + if (!coordinator.allowAsyncCommit) throw error('invalid_input', 'Role coordinator commit must be synchronous'); + await commitResult; + } committed = true; this.hasRole = true; this.checkpoint = null; this._latest(epoch); } catch (reason) { if (mainToken !== undefined) { try { await coordinator.discard(mainToken); } catch (_) {} } diff --git a/public/js/selection-menu.js b/public/js/selection-menu.js index f62d107..aab44ab 100644 --- a/public/js/selection-menu.js +++ b/public/js/selection-menu.js @@ -274,6 +274,16 @@ class TeGhostLayer { } change(action, start, end) { + if(this.editor.authority) { + const s=Math.max(0,Math.min(start,end)),e=Math.max(0,Math.max(start,end));let result; + return this.editor.authorityAction({kind:action==='ghost'?'ghost_range':'revive_range',start:s,end:e},[],started=>{ + result=started.result;if(!result||!result.ok)throw new Error(result?.error||'Native Ghost action refused'); + this.refresh(started.annotations||result.annotations); + }).then(accepted=>{ + if(accepted)this.surface.root.dispatchEvent(new CustomEvent('te:ghosts-change',{bubbles:true,detail:{editor:this.editor,action,start:s,end:e,id:result.id??null,changed:result.changed??true,ghosts:this.ghosts.map(g=>({...g}))}})); + return accepted?result:null; + }); + } const api = this.destroyed ? null : this.api(); if (!api) return { ok: false, error: 'The WASM document API does not offer ghosting', kind: 'unavailable' }; const s = Math.max(0, Math.min(start, end) | 0); @@ -687,7 +697,9 @@ class TeSelectionMenu { run(item, ctx) { try { - return item.run(ctx); + const result=item.run(ctx); + if(result&&typeof result.then==='function')return result.catch(err=>{console.error(`Selection menu item "${item.id}" failed`,err);return undefined;}); + return result; } catch (err) { console.error(`Selection menu item "${item.id}" failed`, err); return undefined; diff --git a/public/js/trim.js b/public/js/trim.js index bdf8137..39e755f 100644 --- a/public/js/trim.js +++ b/public/js/trim.js @@ -334,6 +334,7 @@ class TeLabTrim { * Returns the status, or null. */ select(level, opts = {}) { + if(this.editor.authority)return this.authoritySelect(level,opts); if (this.destroyed) return null; if (level === 'original') { this.reset(); @@ -374,6 +375,7 @@ class TeLabTrim { /** Recompute the status and the preview (one bridge call when the plan is fresh). */ refresh() { + if(this.editor.authority)return this.authorityRefresh(); const api = this.api(); if (!api || !this.trimming) return null; let status; @@ -501,6 +503,7 @@ class TeLabTrim { if (!this.plan || !this.plan.cuts.some((c) => c.id === cutId)) return null; this.kept.add(cutId); const status = this.refresh(); + if(status&&typeof status.then==='function')return status.then(value=>{if(value)this.announce(`Kept. ${value.cardText}`);return value;}); if (status) this.announce(`Kept. ${status.cardText}`); return status; } @@ -577,6 +580,7 @@ class TeLabTrim { * edits, or null when refused (stale plan, nothing to cut, no API). */ makeCuts() { + if(this.editor.authority)return this.authorityMakeCuts(); if (this.destroyed || this.stale || !this.trimming) return null; const api = this.api(); if (!api) return null; @@ -609,6 +613,7 @@ class TeLabTrim { /** Protected Done records independent persistent ghosts before retiring UI. */ done() { + if(this.editor.authority)return this.authorityDone(); if (!this.cardOpen) return; let committed; try { @@ -628,6 +633,64 @@ class TeLabTrim { return true; } + async authoritySelect(level,opts={}) { + if(this.destroyed)return null; + if(level==='original'){this.reset();return null;} + this.cancelPendingReset();this.stale=false;this.level=level; + const status=await this.authorityRefresh(); + if(!status)return null; + this.cardOpen=true;this.lab.close(false);this.sync(); + if(opts.focusCard)this.card.focus({preventScroll:true}); + return status; + } + + async authorityRefresh() { + if(this.destroyed||!this.trimming)return null; + const request=this.authorityRefreshSequence=(this.authorityRefreshSequence||0)+1; + const level=this.level; + try { + await this.editor.authoritySettled(); + const body=this.surface.getText(),generation=this.editor.authority.roleGeneration; + const raw=await this.editor.authority.query('kg_role_snapshot_json',[]); + const role=typeof raw==='string'?JSON.parse(raw):raw; + if(!role?.loaded||!role.protectionReady)throw new Error(role?.protectionError||'Load a protected role before Trim'); + if(!this.plan||this.planText!==body||this.plan.roleGeneration!==generation) { + this.plan=await this.editor.authority.query('trim_plan_json',[]);this.planText=body;this.kept.clear(); + } + const status=await this.editor.authority.query('trim_status',[level,JSON.stringify(Array.from(this.kept))]); + if(request!==this.authorityRefreshSequence||this.level!==level||this.surface.getText()!==body||this.editor.authority.roleGeneration!==generation)return null; + if(!status||status.kgProtected!==true||status.roleGeneration!==generation)throw new Error('Trim role generation changed'); + this.status=status;this.active=Array.from(status.active||[]); + if(this.walkIndex>=this.active.length)this.walkIndex=this.active.length-1; + this.draw();this.renderCard();this.emit('te:trim',{level,status});return status; + } catch(error){if(request===this.authorityRefreshSequence)this.refuse(error);return null;} + } + + async authorityMakeCuts() { + if(this.destroyed||this.stale||!this.trimming)return null; + try { + await this.editor.authoritySettled(); + const body=this.surface.getText(),generation=this.editor.authority.roleGeneration,level=this.level; + const made=await this.editor.authority.query('trim_make_cuts',[level,JSON.stringify(Array.from(this.kept))]); + if(this.surface.getText()!==body||this.editor.authority.roleGeneration!==generation)throw new Error('Trim snapshot changed before cuts'); + const edits=Array.from(made.edits||[]);if(!edits.length)return null; + this.applying=true;let applied; + try {applied=await this.surface.replaceRanges(edits,{source:'trim'});}finally{this.applying=false;} + if(this.surface.getText()!==made.text)throw new Error('Accepted cuts differ from native plan'); + const words=this.status?.cardText||'';this.reset(false);this.announce(`Cuts made. ${words}`);this.surface.focus();this.emit('te:trim-cut',{level,edits:applied});return applied; + } catch(error){this.refuse(error);return null;} + } + + async authorityDone() { + if(!this.cardOpen)return false; + try { + await this.editor.authoritySettled(); + const committed=await this.editor.commitTrimGhosts(this.level,Array.from(this.kept)); + this.stopWalk(false);this.plan=null;this.planText=null;this.reset(); + this.announce(committed?'Done: cuts saved as ghosts. Undo restores them.':'Done: no cuts to save.');return true; + } catch(error){this.announce(`Done refused: ${error?.error||error?.message||error}`);return false;} + } + /** Back to Original: no preview, no kept set, no card. */ reset(focusPill = false) { if (this.destroyed) return; diff --git a/src/document.rs b/src/document.rs index dabbb25..ddb0d82 100644 --- a/src/document.rs +++ b/src/document.rs @@ -3012,12 +3012,460 @@ impl DocumentSession { } } +// The ordinary dispatcher shares the native checkpoint, never a reopened mirror. +fn action_generation(value: &Value, field: &str) -> Result { + let text = history_string(value, field)?; + if text.is_empty() + || !text.bytes().all(|b| b.is_ascii_digit()) + || (text.len() > 1 && text.starts_with('0')) + { + return Err(history_error( + "invalid-payload", + "generation must be a canonical decimal string", + )); + } + Ok(text) +} + +fn current_action_role_generation() -> String { + let snapshot: Value = serde_json::from_str(&crate::kg::role_snapshot_json()) + .expect("native role snapshot is JSON"); + snapshot["generation"] + .as_str() + .expect("native generation string") + .to_owned() +} + +fn action_edit_fields(value: &Value, with_kind: bool) -> Result<(), HistoryTransactionError> { + if with_kind { + history_fields( + value, + &[ + "kind", + "start", + "deleteUtf16", + "insertedRef", + "beforeBodyHash", + "afterBodyHash", + ], + )?; + } else { + history_fields( + value, + &[ + "start", + "deleteUtf16", + "insertedRef", + "beforeBodyHash", + "afterBodyHash", + ], + )?; + } + for field in ["start", "deleteUtf16", "insertedRef"] { + history_integer(value, field)?; + } + for field in ["beforeBodyHash", "afterBodyHash"] { + let hash = history_string(value, field)?; + if hash.len() != 64 + || !hash + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) + { + return Err(history_error( + "invalid-payload", + "expected lowercase SHA-256", + )); + } + } + Ok(()) +} + +fn action_ref( + value: &Value, + field: &str, + used: &mut [bool], +) -> Result<(), HistoryTransactionError> { + let index = history_integer(value, field)?; + let slot = used + .get_mut(index) + .ok_or_else(|| history_error("invalid-payload", "text reference out of range"))?; + if *slot { + return Err(history_error("invalid-payload", "text reference reused")); + } + *slot = true; + Ok(()) +} + +fn parse_action_metadata(meta: &str, args: &[String]) -> Result { + history_bytes(meta)?; + if args.len() > HISTORY_OPERATION_LIMIT { + return Err(history_error("limit", "more than 4096 text arguments")); + } + let mut bytes = 0usize; + for arg in args { + history_bytes(arg)?; + bytes = bytes + .checked_add(arg.len()) + .ok_or_else(|| history_error("limit", "text budget exhausted"))?; + if bytes > HISTORY_JSON_LIMIT { + return Err(history_error( + "limit", + "combined text arguments exceed 16 MiB", + )); + } + } + let value: Value = serde_json::from_str(meta) + .map_err(|_| history_error("invalid-payload", "invalid action JSON or nesting"))?; + let kind = value["kind"] + .as_str() + .ok_or_else(|| history_error("invalid-payload", "missing action kind"))?; + let fields: &[&str] = match kind { + "text_edit" => &[ + "kind", + "start", + "deleteUtf16", + "insertedRef", + "beforeBodyHash", + "afterBodyHash", + ], + "text_edits" => &["kind", "edits"], + "ghost_range" | "revive_range" | "stash" => &["kind", "start", "end"], + "alt_create" => &["kind", "spanKind", "start", "end", "textRef", "source"], + "alt_add" => &["kind", "spanId", "textRef", "source"], + "alt_edit" => &["kind", "spanId", "index", "textRef"], + "alt_remove" | "alt_active" => &["kind", "spanId", "index"], + "kg_swap" => &["kind", "spanId", "index", "roleGeneration"], + "alt_move" => &["kind", "spanId", "from", "to"], + "append_alternatives" => &[ + "kind", + "start", + "end", + "suppliedJsonRef", + "roleGeneration", + "providerGeneration", + ], + "pull" => &["kind", "at", "from", "to"], + "overflow_set" => &["kind", "textRef"], + _ => { + return Err(history_error( + "invalid-payload", + "unsupported ordinary action", + )) + } + }; + history_fields(&value, fields)?; + for field in fields { + match *field { + "start" | "end" | "at" | "from" | "to" | "index" | "deleteUtf16" => { + history_integer(&value, field)?; + } + "spanId" | "spanKind" | "source" => { + history_string(&value, field)?; + } + "roleGeneration" | "providerGeneration" => { + action_generation(&value, field)?; + } + _ => {} + } + } + let mut used = vec![false; args.len()]; + match kind { + "text_edit" => { + action_edit_fields(&value, true)?; + action_ref(&value, "insertedRef", &mut used)?; + } + "text_edits" => { + let edits = value["edits"] + .as_array() + .ok_or_else(|| history_error("invalid-payload", "edits must be an array"))?; + if edits.is_empty() || edits.len() > HISTORY_OPERATION_LIMIT { + return Err(history_error("limit", "batch must contain 1–4096 edits")); + } + for edit in edits { + action_edit_fields(edit, false)?; + action_ref(edit, "insertedRef", &mut used)?; + } + } + "alt_create" | "alt_add" | "alt_edit" | "overflow_set" => { + action_ref(&value, "textRef", &mut used)? + } + "append_alternatives" => action_ref(&value, "suppliedJsonRef", &mut used)?, + _ => {} + } + if used.iter().any(|used| !used) { + return Err(history_error("invalid-payload", "unused text argument")); + } + Ok(value) +} + +fn apply_action_edit( + trial: &mut DocumentSession, + edit: &Value, + args: &[String], +) -> Result { + if crate::host::source_hash(trial.body()) + != edit["beforeBodyHash"].as_str().expect("checked hash") + { + return Err(history_error( + "body-conflict", + "intermediate edit body does not match", + )); + } + let start = history_integer(edit, "start")?; + let deleted_len = history_integer(edit, "deleteUtf16")?; + let end = start + .checked_add(deleted_len) + .ok_or_else(|| history_error("invalid-payload", "edit range overflow"))?; + let (a, b) = ( + utf16_to_byte(trial.body(), start), + utf16_to_byte(trial.body(), end), + ); + let (Some(a), Some(b)) = (a, b) else { + return Err(history_error( + "model-refusal", + "edit range does not fit UTF-16 body", + )); + }; + let deleted = trial.body()[a..b].to_owned(); + let inserted = &args[history_integer(edit, "insertedRef")?]; + let outcome = trial + .apply_edit(start, deleted_len, inserted) + .map_err(|e| history_error("model-refusal", e.to_string()))?; + history_bytes(trial.body())?; + if crate::host::source_hash(trial.body()) + != edit["afterBodyHash"].as_str().expect("checked hash") + { + return Err(history_error( + "body-conflict", + "result edit body does not match", + )); + } + let mut result = outcome.to_json(); + result.as_object_mut().expect("outcome object").insert("edit".into(), json!({"start":start,"deletedLength":deleted_len,"deletedText":deleted,"insertedText":inserted})); + Ok(result) +} + +/// Begin an ordinary typed action on the existing native session authority. +/// +/// # Errors +/// Refuses stale bodies/roles, malformed or oversized arguments, domain errors, +/// and outstanding transactions. Failure leaves the session/checkpoint intact. +/// A returned token must be finished or guarded-aborted exactly once. +pub fn begin_document_action_json( + expected_body: &str, + action_meta_json: &str, + text_args: &[String], +) -> Result, HistoryTransactionError> { + history_bytes(expected_body)?; + let meta = parse_action_metadata(action_meta_json, text_args)?; + history_available()?; + let role = if meta.get("roleGeneration").is_some() { + Some(action_generation(&meta, "roleGeneration")?) + } else { + None + }; + if role + .as_ref() + .is_some_and(|g| *g != current_action_role_generation()) + { + return Err(history_error("role-conflict", "displayed role changed")); + } + let started = begin_history_checkpoint(None, |trial| { + if trial.body() != expected_body { + return Err(history_error("body-conflict", "action body does not match")); + } + let before = trial.clone(); + let n = |field| history_integer(&meta, field); + let s = |field: &str| meta[field].as_str().expect("checked string"); + let text = |field| -> Result<&str, HistoryTransactionError> { Ok(&text_args[n(field)?]) }; + let domain = |e: String| history_error("model-refusal", e); + let kind = s("kind"); + let result = match kind { + "text_edit" => apply_action_edit(trial, &meta, text_args)?, + "text_edits" => { + let mut results = Vec::new(); + for edit in meta["edits"].as_array().expect("checked edits") { + results.push(apply_action_edit(trial, edit, text_args)?); + } + json!({"edits":results}) + } + "ghost_range" => { + let id = trial + .ghost(n("start")?, n("end")?) + .map_err(|e| domain(e.to_string()))?; + json!({"ok":true,"id":id,"annotations":trial.annotations_json()}) + } + "revive_range" => { + let changed = trial + .revive(n("start")?, n("end")?) + .map_err(|e| domain(e.to_string()))?; + json!({"ok":true,"changed":changed,"annotations":trial.annotations_json()}) + } + "alt_create" => trial + .alt_create_span( + parse_span_kind(s("spanKind")).map_err(domain)?, + n("start")?, + n("end")?, + text("textRef")?, + parse_alt_source(s("source")).map_err(domain)?, + ) + .map_err(domain)? + .to_json(), + "alt_add" => match trial + .alt_add( + s("spanId"), + text("textRef")?, + parse_alt_source(s("source")).map_err(domain)?, + ) + .map_err(domain)? + { + Some(v) => v.to_json(), + None => return Ok(None), + }, + "alt_edit" => match trial + .alt_edit(s("spanId"), n("index")?, text("textRef")?) + .map_err(domain)? + { + Some(v) => v.to_json(), + None => return Ok(None), + }, + "alt_remove" => trial + .alt_remove(s("spanId"), n("index")?) + .map_err(domain)? + .to_json(), + "alt_move" => match trial + .alt_move(s("spanId"), n("from")?, n("to")?) + .map_err(domain)? + { + Some(v) => v.to_json(), + None => return Ok(None), + }, + "alt_active" => trial + .set_active(s("spanId"), n("index")?) + .map_err(|e| domain(e.to_string()))? + .to_json(), + "kg_swap" => crate::kg::kg_swap(trial, s("spanId"), n("index")?) + .map_err(|e| domain(e.to_string()))? + .to_json(), + "append_alternatives" => { + let supplied = + crate::providers::parse_supplied(text("suppliedJsonRef")?).map_err(domain)?; + match crate::providers::append_from_providers( + trial, + n("start")?, + n("end")?, + &supplied, + ) + .map_err(|e| domain(e.to_string()))? + { + Some(v) => v.to_json(), + None => return Ok(None), + } + } + "stash" => trial + .stash(n("start")?, n("end")?) + .map_err(|e| domain(e.to_string()))? + .to_json(), + "pull" => { + let pull = trial + .pull_back_text(n("at")?, n("from")?, n("to")?) + .map_err(|e| domain(e.to_string()))?; + let outcome = trial + .apply_edit(pull.start, 0, &pull.text) + .map_err(|e| domain(e.to_string()))?; + json!({"start":pull.start,"text":pull.text,"outcome":outcome.to_json(), + "edit":{"start":pull.start,"deletedLength":0,"deletedText":"","insertedText":pull.text}, + "overflow":{"before":trial.overflow(),"after":trial.overflow()}}) + } + "overflow_set" => { + trial + .set_overflow(text("textRef")?) + .map_err(|e| domain(e.to_string()))?; + json!({"ok":true,"overflow":trial.overflow()}) + } + _ => unreachable!("strict allowlist"), + }; + if *trial == before { + return Ok(None); + } + let source = trial.save(); + history_bytes(&source)?; + Ok(Some( + json!({"ok":true,"result":result,"annotations":trial.annotations_json(),"source":source}), + )) + })?; + if started.is_some() { + HISTORY_BRIDGE.with(|b| { + b.borrow_mut() + .pending + .as_mut() + .expect("issued checkpoint") + .action_role_generation = role + }); + } + Ok(started) +} + +/// WASM transport for the strict ordinary-action checkpoint dispatcher. +/// +/// # Errors +/// Uses the native typed refusals. Response conversion failure guarded-aborts +/// the issued checkpoint rather than leaving an undelivered action accepted. +#[wasm_bindgen] +pub fn begin_document_action( + expected_body: &str, + action_meta_json: &str, + text_args: js_sys::Array, +) -> Result { + if text_args.length() as usize > HISTORY_OPERATION_LIMIT { + return Err(history_js_error(history_error( + "limit", + "more than 4096 text arguments", + ))); + } + let mut args = Vec::with_capacity(text_args.length() as usize); + let mut total = 0usize; + for value in text_args.iter() { + if !value.is_string() { + return Err(history_js_error(history_error( + "invalid-payload", + "text arguments must be strings", + ))); + } + let js_text = js_sys::JsString::from(value.clone()); + if js_text.length() as usize > HISTORY_JSON_LIMIT { + return Err(history_js_error(history_error( + "limit", + "text argument exceeds limit", + ))); + } + let text = value.as_string().expect("checked string"); + total = total + .checked_add(text.len()) + .ok_or_else(|| history_js_error(history_error("limit", "text budget exhausted")))?; + if total > HISTORY_JSON_LIMIT { + return Err(history_js_error(history_error( + "limit", + "combined text arguments exceed 16 MiB", + ))); + } + args.push(text); + } + match begin_document_action_json(expected_body, action_meta_json, &args) + .map_err(history_js_error)? + { + Some(v) => history_begin_js(v), + None => Ok(JsValue::NULL), + } +} + #[derive(Debug)] struct HistoryCheckpoint { token: String, before: DocumentSession, after: DocumentSession, role_generation: Option, + action_role_generation: Option, } #[derive(Debug, Default)] @@ -3076,6 +3524,7 @@ fn begin_history_checkpoint( before: session.clone(), after: trial.clone(), role_generation, + action_role_generation: None, }; bridge.watermark = next; bridge.pending = Some(checkpoint); @@ -3177,6 +3626,17 @@ fn acknowledge_history(token: &str, abort: bool) -> Result Option { + let body = with_session(|s| s.body().to_owned()); + begin_document_action_json( + &body, + &meta.to_string(), + &args.iter().map(|s| s.to_string()).collect::>(), + ) + .unwrap() + } + fn commit(meta: Value, args: &[&str]) -> Value { + let v = run(meta, args).unwrap(); + finish_history_transaction_json(v["token"].as_str().unwrap()).unwrap(); + v["result"].clone() + } + fn edit(before: &str, after: &str, start: usize, len: usize, arg: usize) -> Value { + json!({"kind":"text_edit","start":start,"deleteUtf16":len,"insertedRef":arg, + "beforeBodyHash":crate::host::source_hash(before),"afterBodyHash":crate::host::source_hash(after)}) + } + #[test] + fn actual_actions_keep_private_receipt_identity_and_support_guarded_abort() { + reset("risk here"); + let receipt = with_session(|s| { + s.ghost_ranges_atomic("risk here", &[(5, 9)]) + .unwrap() + .unwrap() + }); + let before = with_session(|s| s.clone()); + let v = run(edit("risk here", "risk here!", 9, 0, 0), &["!"]).unwrap(); + abort_history_transaction_json(v["token"].as_str().unwrap()).unwrap(); + assert_eq!(with_session(|s| s.clone()), before); + commit(edit("risk here", "risk here!", 9, 0, 0), &["!"]); + commit(edit("risk here!", "risk here", 9, 1, 0), &[""]); + with_session(|s| s.replay_ghost_change(&receipt, true)).unwrap(); + assert!(with_session(|s| s.annotations_json()["ghosts"] + .as_array() + .unwrap() + .is_empty())); + } + #[test] + fn real_alternative_crud_swap_manual_ghost_and_overflow_actions() { + reset("risk here"); + let created = commit( + json!({"kind":"alt_create","spanKind":"word","start":0,"end":4,"textRef":0,"source":"human"}), + &["concern"], + ); + let id = created["span"].as_str().unwrap(); + commit( + json!({"kind":"alt_add","spanId":id,"textRef":0,"source":"human"}), + &["worry"], + ); + commit( + json!({"kind":"alt_edit","spanId":id,"index":2,"textRef":0}), + &["hazard"], + ); + commit(json!({"kind":"alt_move","spanId":id,"from":2,"to":1}), &[]); + commit(json!({"kind":"alt_active","spanId":id,"index":1}), &[]); + assert_eq!(with_session(|s| s.body().to_owned()), "hazard here"); + commit(json!({"kind":"alt_remove","spanId":id,"index":1}), &[]); + assert_eq!(with_session(|s| s.body().to_owned()), "risk here"); + commit(json!({"kind":"ghost_range","start":5,"end":9}), &[]); + commit(json!({"kind":"revive_range","start":5,"end":9}), &[]); + commit(json!({"kind":"stash","start":5,"end":9}), &[]); + assert_eq!(with_session(|s| s.overflow().to_owned()), "here"); + let pulled = commit(json!({"kind":"pull","at":5,"from":0,"to":4}), &[]); + assert_eq!(pulled["edit"]["insertedText"], pulled["text"]); + assert_eq!(pulled["overflow"]["before"], "here"); + assert_eq!(pulled["overflow"]["after"], "here"); + assert!(with_session(|s| s.body().contains("here"))); + commit(json!({"kind":"overflow_set","textRef":0}), &["notes"]); + assert_eq!(with_session(|s| s.overflow().to_owned()), "notes"); + } + #[test] + fn strict_metadata_refs_and_batch_failure_leave_actual_state_and_pending_unchanged() { + reset("abc"); + for (meta, args) in [ + (json!({"kind":"history_replay"}), vec![]), + (json!({"kind":"trim_promote"}), vec![]), + ( + json!({"kind":"overflow_set","textRef":0,"extra":true}), + vec!["x".into()], + ), + (json!({"kind":"overflow_set","textRef":1}), vec!["x".into()]), + ( + json!({"kind":"overflow_set","textRef":0}), + vec!["x".into(), "unused".into()], + ), + ] { + let before = with_session(|s| s.clone()); + assert!(begin_document_action_json("abc", &meta.to_string(), &args).is_err()); + assert_eq!(with_session(|s| s.clone()), before); + assert!(HISTORY_BRIDGE.with(|b| b.borrow().pending.is_none())); + } + let first = edit("abc", "abcd", 3, 0, 0); + let second = edit("wrong", "abcde", 4, 0, 1); + let entries: Vec = [first, second] + .into_iter() + .map(|mut v| { + v.as_object_mut().unwrap().remove("kind"); + v + }) + .collect(); + assert!(begin_document_action_json( + "abc", + &json!({"kind":"text_edits","edits":entries}).to_string(), + &["d".into(), "e".into()] + ) + .is_err()); + assert_eq!(with_session(|s| s.body().to_owned()), "abc"); + } + #[test] + fn append_uses_real_provider_parser_and_stale_role_cannot_finish() { + reset("risk"); + crate::providers::register_external_provider("test-provider", "real-model").unwrap(); + let generation: Value = serde_json::from_str(&crate::kg::role_snapshot_json()).unwrap(); + let meta = json!({"kind":"append_alternatives","start":0,"end":4,"suppliedJsonRef":0, + "roleGeneration":generation["generation"],"providerGeneration":"1"}); + let prepared = run(meta, &[r#"{"test-provider":["concern"]}"#]).unwrap(); + assert_eq!( + prepared["result"]["after"]["alts"][1]["model"], + "real-model" + ); + crate::kg::clear_thesaurus(); + assert_eq!( + finish_history_transaction_json(prepared["token"].as_str().unwrap()) + .unwrap_err() + .kind, + "role-conflict" + ); + abort_history_transaction_json(prepared["token"].as_str().unwrap()).unwrap(); + assert!(with_session(|s| s.document().annotations.spans.is_empty())); + } + #[test] + fn kg_swap_binds_displayed_role_at_begin_and_finish_without_resetting_native_state() { + reset("risk"); + let role = r#"{"name":"one","data":{"risk":{"id":1,"nterm":"risk"},"concern":{"id":1,"nterm":"risk"}}}"#; + crate::kg::load_thesaurus(role).unwrap(); + let snapshot: Value = serde_json::from_str(&crate::kg::role_snapshot_json()).unwrap(); + let spans = with_session(|s| crate::kg::kg_spans_json(s)); + let row = &spans[0]; + let index = row["alts"] + .as_array() + .unwrap() + .iter() + .position(|v| v["text"] == "concern") + .unwrap(); + let action = json!({"kind":"kg_swap","spanId":row["id"],"index":index,"roleGeneration":snapshot["generation"]}); + let before = with_session(|s| s.clone()); + let v = run(action.clone(), &[]).unwrap(); + assert_eq!(with_session(|s| s.body().to_owned()), "concern"); + crate::kg::load_thesaurus(role).unwrap(); + assert_eq!( + finish_history_transaction_json(v["token"].as_str().unwrap()) + .unwrap_err() + .kind, + "role-conflict" + ); + abort_history_transaction_json(v["token"].as_str().unwrap()).unwrap(); + assert_eq!(with_session(|s| s.clone()), before); + assert_eq!( + begin_document_action_json("risk", &action.to_string(), &[]) + .unwrap_err() + .kind, + "role-conflict" + ); + } + #[test] + fn valid_batch_and_nochange_preserve_order_and_do_not_issue_phantom_tokens() { + reset("a"); + let mut first = edit("a", "ab", 1, 0, 0); + let mut second = edit("ab", "abc", 2, 0, 1); + first.as_object_mut().unwrap().remove("kind"); + second.as_object_mut().unwrap().remove("kind"); + let v = run( + json!({"kind":"text_edits","edits":[first,second]}), + &["b", "c"], + ) + .unwrap(); + assert_eq!(v["result"]["edits"][1]["edit"]["insertedText"], "c"); + assert_eq!( + begin_document_action_json( + "abc", + &json!({"kind":"overflow_set","textRef":0}).to_string(), + &["x".into()] + ) + .unwrap_err() + .kind, + "pending" + ); + finish_history_transaction_json(v["token"].as_str().unwrap()).unwrap(); + let watermark = HISTORY_BRIDGE.with(|b| b.borrow().watermark); + assert!(run(edit("abc", "abc", 3, 0, 0), &[""]).is_none()); + assert_eq!(HISTORY_BRIDGE.with(|b| b.borrow().watermark), watermark); + } + #[test] + fn action_limits_and_utf16_refusals_preserve_actual_engine() { + reset("😀a"); + for field in ["start", "deleteUtf16", "insertedRef"] { + let mut v = edit("😀a", "😀b", 2, 1, 0); + v[field] = json!(9_007_199_254_740_991u64); + assert!(begin_document_action_json("😀a", &v.to_string(), &["b".into()]).is_err()); + } + assert!(begin_document_action_json( + "😀a", + &edit("😀a", "bad", 1, 1, 0).to_string(), + &["b".into()] + ) + .is_err()); + assert_eq!(with_session(|s| s.body().to_owned()), "😀a"); + assert_eq!( + begin_document_action_json("😀a", &" ".repeat(HISTORY_JSON_LIMIT + 1), &[]) + .unwrap_err() + .kind, + "limit" + ); + assert_eq!( + begin_document_action_json( + "😀a", + r#"{"kind":"overflow_set","textRef":0}"#, + &["a".repeat(HISTORY_JSON_LIMIT), "b".into()] + ) + .unwrap_err() + .kind, + "limit" + ); + let repeated = json!({"start":0,"deleteUtf16":0,"insertedRef":0,"beforeBodyHash":crate::host::source_hash("😀a"),"afterBodyHash":crate::host::source_hash("😀a")}); + assert!(begin_document_action_json( + "😀a", + &json!({"kind":"text_edits","edits":[repeated.clone(),repeated]}).to_string(), + &["".into()] + ) + .is_err()); + } + #[test] + fn exact_argument_and_operation_budgets_admit_boundary_and_refuse_next_item() { + reset("x"); + let mut edits = Vec::new(); + for index in 0..HISTORY_OPERATION_LIMIT { + let mut value = edit("x", "x", 1, 0, index); + value.as_object_mut().unwrap().remove("kind"); + edits.push(value); + } + let args = vec![String::new(); HISTORY_OPERATION_LIMIT]; + let meta = json!({"kind":"text_edits","edits":edits}); + assert!(begin_document_action_json("x", &meta.to_string(), &args) + .unwrap() + .is_none()); + let mut over = meta; + let repeated = over["edits"][0].clone(); + over["edits"].as_array_mut().unwrap().push(repeated); + assert_eq!( + begin_document_action_json("x", &over.to_string(), &args) + .unwrap_err() + .kind, + "limit" + ); + let exact = vec!["x".repeat(HISTORY_JSON_LIMIT)]; + assert!(parse_action_metadata(r#"{"kind":"overflow_set","textRef":0}"#, &exact).is_ok()); + let excessive = vec!["x".repeat(HISTORY_JSON_LIMIT + 1)]; + assert_eq!( + parse_action_metadata(r#"{"kind":"overflow_set","textRef":0}"#, &excessive) + .unwrap_err() + .kind, + "limit" + ); + assert_eq!(with_session(|s| s.body().to_owned()), "x"); + } + + #[test] + fn admitted_near_limit_full_replacement_does_not_duplicate_deleted_transport_budget() { + let before = "a".repeat(HISTORY_JSON_LIMIT - 1024); + let after = "b".repeat(HISTORY_JSON_LIMIT - 1024); + reset(&before); + let v = begin_document_action_json( + &before, + &edit(&before, &after, 0, before.len(), 0).to_string(), + std::slice::from_ref(&after), + ) + .unwrap() + .unwrap(); + assert_eq!(v["source"], after); + abort_history_transaction_json(v["token"].as_str().unwrap()).unwrap(); + assert_eq!(with_session(|s| s.body().to_owned()), before); + } + #[test] + fn pure_persisted_eol_materialization_retains_native_receipts_and_roundtrips_annotations() { + reset("😀 risk\nnext line\n"); + let receipt = with_session(|s| { + s.ghost_ranges_atomic("😀 risk\nnext line\n", &[(8, 17)]) + .unwrap() + .unwrap() + }); + let native = with_session(|s| s.clone()); + let source = native.save(); + let crlf = crate::host::host_persistence_source(&source, true).unwrap(); + assert!(crlf.contains("\r\n")); + assert_eq!( + crate::host::host_presentation_source(&crlf).unwrap(), + source + ); + assert_eq!(with_session(|s| s.clone()), native); + with_session(|s| s.replay_ghost_change(&receipt, true)).unwrap(); + let malformed = "body\n\n```terraphim-alternatives\n{ broken }\n```\n"; + assert!(crate::host::host_persistence_source(malformed, true).is_err()); + } + #[test] + fn dispatcher_saved_synonyms_query_real_graph_and_original_authority_undo_redo() { + use terraphim_rolegraph_browser::GraphSession; + fn query(group: &Value, text: &str) -> Value { + let mut graph = GraphSession::default(); + let mut call = |generation: &str, op: &str, payload: Value| -> Value { + let response: Value = serde_json::from_str(&graph.dispatch(&json!({"version":1,"requestId":"native-authority-synonyms","generation":generation,"expectedRevision":null,"op":op,"payload":payload}).to_string()).unwrap()).unwrap(); + assert_eq!(response["ok"], true, "{response}"); + response["result"].clone() + }; + let prepared = call( + "0", + "prepare_role", + json!({"thesaurusText":group["thesaurus"].to_string()}), + ); + call( + "0", + "activate_role", + json!({"token":prepared["token"],"nextGeneration":"1"}), + ); + call("1", "match", json!({"text":text})) + } + reset("risk here"); + let ghost = with_session(|s| { + s.ghost_ranges_atomic("risk here", &[(5, 9)]) + .unwrap() + .unwrap() + }); + let added = commit( + json!({"kind":"alt_create","spanKind":"word","start":0,"end":4,"textRef":0,"source":"human"}), + &["concern"], + ); + let id = added["span"].as_str().unwrap(); + assert_eq!( + with_session(|s| s.synonyms_for(id, "risk")), + vec!["risk", "concern"] + ); + let groups = with_session(|s| s.synonym_groups_json()); + assert!(groups[0]["markdown"] + .as_str() + .unwrap() + .contains("synonyms:: risk, concern")); + assert_eq!(query(&groups[0], "concern"), query(&groups[0], "risk")); + assert_eq!(query(&groups[0], "concern"), json!(["1"])); + let saved = with_session(|s| s.save()); + let path = std::env::temp_dir().join(format!( + "terraphim-authority-synonyms-{}-{}.md", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, saved.as_bytes()).unwrap(); + let actual = std::fs::read_to_string(&path).unwrap(); + assert_eq!(actual, saved); + let mut reopened = DocumentSession::new(); + reopened.open(&actual); + assert_eq!(reopened.synonym_groups_json(), groups); + assert_eq!( + query(&reopened.synonym_groups_json()[0], "concern"), + json!(["1"]) + ); + let op = |target: Value| { + json!([{"kind":"alt","body":"risk here","start":0,"deletedText":"","insertedText":"","span":id,"target":target}]).to_string() + }; + let undo = begin_history_replay_json("risk here", "risk here", &op(Value::Null)).unwrap(); + finish_history_transaction_json(undo["token"].as_str().unwrap()).unwrap(); + assert!(with_session(|s| s.synonyms_for(id, "risk").is_empty())); + assert_eq!(with_session(|s| s.synonym_groups_json()), json!([])); + let redo = begin_history_replay_json("risk here", "risk here", &op(added["after"].clone())) + .unwrap(); + finish_history_transaction_json(redo["token"].as_str().unwrap()).unwrap(); + assert_eq!(with_session(|s| s.synonym_groups_json()), groups); + assert_eq!(with_session(|s| s.save()), saved); + with_session(|s| s.replay_ghost_change(&ghost, true)).unwrap(); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/src/host.rs b/src/host.rs index ee538a1..b6dc819 100644 --- a/src/host.rs +++ b/src/host.rs @@ -248,6 +248,18 @@ pub fn host_presentation_source(source: &str) -> Result { bounded(source)?; source_eol(source, false) } +/// Materialize persisted EOLs without opening or mutating any native session. +/// +/// # Errors +/// Refuses oversized sources, overflowing anchors and unsafe malformed-block conversions. +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn host_persistence_source(presentation_source: &str, crlf: bool) -> Result { + bounded(presentation_source)?; + let source = source_eol(presentation_source, crlf)?; + bounded(&source)?; + Ok(source) +} + fn bounded(source: &str) -> Result<(), String> { if source.len() > HOST_SOURCE_LIMIT { Err("host source/message exceeds size limit".into()) diff --git a/src/lib.rs b/src/lib.rs index 6fd9321..cfdaf8f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,13 +9,13 @@ mod document; pub mod host; // Cohort C: native mixed-history/checkpoint bridge; no serialized session clones. pub use document::{ - abort_document_history_transaction, abort_history_transaction_json, - begin_document_history_replay, begin_history_replay_json, begin_trim_ghost_transaction, - begin_trim_ghost_transaction_json, finish_document_history_transaction, - finish_history_transaction_json, parse_history_operations, preflight_document_ghost_change, - recover_document_history_transaction, recover_history_transaction_json, - replay_document_ghost_change, GhostChange, GhostSnapshot, HistoryOperation, - HistoryTransactionError, HISTORY_JSON_LIMIT, HISTORY_OPERATION_LIMIT, + abort_document_history_transaction, abort_history_transaction_json, begin_document_action, + begin_document_action_json, begin_document_history_replay, begin_history_replay_json, + begin_trim_ghost_transaction, begin_trim_ghost_transaction_json, + finish_document_history_transaction, finish_history_transaction_json, parse_history_operations, + preflight_document_ghost_change, recover_document_history_transaction, + recover_history_transaction_json, replay_document_ghost_change, GhostChange, GhostSnapshot, + HistoryOperation, HistoryTransactionError, HISTORY_JSON_LIMIT, HISTORY_OPERATION_LIMIT, }; pub use document::{ apply_edit, counts_json, document_annotations, document_body, document_counts, diff --git a/tests/browser/clipboard-user-flow.cjs b/tests/browser/clipboard-user-flow.cjs new file mode 100644 index 0000000..99cc50d --- /dev/null +++ b/tests/browser/clipboard-user-flow.cjs @@ -0,0 +1,68 @@ +'use strict'; +const {firefox} = require(process.env.TERRAPHIM_PLAYWRIGHT_MODULE || 'playwright'); +const fs = require('node:fs/promises'); +const {spawnSync} = require('node:child_process'); +const assert = require('node:assert/strict'); +const output = process.env.TERRAPHIM_STORY_OUT; +const readClipboard = () => { + const result = spawnSync('/usr/bin/pbpaste'); + assert.equal(result.status, 0, 'Read local clipboard'); + return result.stdout; +}; +const writeClipboard = value => { + const result = spawnSync('/usr/bin/pbcopy', {input:value}); + assert.equal(result.status, 0, 'Write local clipboard'); +}; +(async () => { + assert.equal(process.platform, 'darwin', 'This fixture verifies actual macOS clipboard input'); + const prior = readClipboard(); // Never print or persist the user's clipboard. + const pasted = 'p1\np2\np3'; + const copied = 'p1\np2'; + let browser; + try { + browser = await firefox.launch({headless:true}); + const page = await browser.newPage({viewport:{width:1140,height:900}}); + await page.goto(process.env.TERRAPHIM_STORY_URL); + const source = page.getByRole('textbox', {name:'Markdown source',exact:true}); + await source.waitFor(); + await source.fill('Hello'); + const capabilities = await page.evaluate(value => { + const data = new DataTransfer(); data.setData('text/plain', value); + const event = new ClipboardEvent('paste', {clipboardData:data}); + const button = document.createElement('button'); + button.textContent = 'Copy public clipboard fixture'; + button.onclick = async () => { + try {await navigator.clipboard.writeText(value);button.dataset.copied='true';} + catch (error) {button.dataset.error=error.name;} + }; + document.body.append(button); + return {platform:navigator.platform,syntheticDataPreserved:event.clipboardData?.getData('text/plain')===value}; + }, pasted); + await fs.writeFile(output+'/browser-capabilities.json',JSON.stringify(capabilities,null,2)+'\n'); + await page.getByRole('button', {name:'Copy public clipboard fixture',exact:true}).click(); + await page.waitForFunction(() => document.querySelector('button[data-copied="true"]'), undefined, {timeout:5000}); + await source.press('Meta+ArrowUp'); + await source.press('Meta+v'); + await assert.doesNotReject(async () => { + await page.waitForFunction(() => document.querySelector('[role="textbox"][aria-label="Markdown source"]').innerText === 'p1\np2\np3Hello', undefined, {timeout:5000}); + }); + await source.press('Home'); + await source.press('Meta+ArrowUp'); + await source.press('Shift+ArrowDown'); + await source.press('Shift+ArrowRight'); + await source.press('Shift+ArrowRight'); + await source.press('Meta+c'); + const target = page.locator('#clipboard-roundtrip'); + await page.evaluate(() => {const field=document.createElement('textarea');field.id='clipboard-roundtrip';document.body.append(field);}); + await target.press('Meta+v'); + const actual = await target.inputValue(); + await fs.writeFile(output+'/actual-pasted.txt', await source.innerText()); + assert(actual === copied, 'Clipboard did not match the selected public test text'); + await fs.writeFile(output+'/actual-copied.txt', actual); + await fs.writeFile(output+'/clipboard-receipt.json',JSON.stringify({status:'PASS',browser:browser.version(),scope:'Actual trusted keyboard paste/copy using the browser clipboard; public text written by a trusted fixture button, editor unchanged',pastedText:await source.innerText(),copiedText:actual},null,2)+'\n'); + } finally { + await browser?.close(); + const current = readClipboard().toString('utf8'); + if (current === pasted || current === copied) writeClipboard(prior); + } +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/tests/browser/corpus-rolegraph.html b/tests/browser/corpus-rolegraph.html new file mode 100644 index 0000000..98ed697 --- /dev/null +++ b/tests/browser/corpus-rolegraph.html @@ -0,0 +1,230 @@ + + +Local engineering corpus qualification +
Running local corpus qualification
+ + diff --git a/tests/browser/delivery-rolegraph.html b/tests/browser/delivery-rolegraph.html new file mode 100644 index 0000000..bbd957e --- /dev/null +++ b/tests/browser/delivery-rolegraph.html @@ -0,0 +1,133 @@ + + + +Actual Worker RoleGraph qualification + +
+
Running local WASM, Worker and IndexedDB checks…
+ + + diff --git a/tests/browser/distribution-publish.test.py b/tests/browser/distribution-publish.test.py new file mode 100644 index 0000000..b015045 --- /dev/null +++ b/tests/browser/distribution-publish.test.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +"""Synthetic transaction unit tests; NOT production release qualification.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("publisher", ROOT / "scripts/publish-editor-dist.py") +P = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(P) +SCRATCH = Path("/private/var/folders/rb/l4_6j2nx0lnfhysk974ldpg40000gn/T/opencode") + + +class PublisherTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="distribution-unit-", dir=SCRATCH) + self.addCleanup(self.temp.cleanup) + self.repo = Path(self.temp.name) + self.stage = self.repo / "target/release-staging-test" + self.stage.mkdir(parents=True) + self.git("init", "-q") + self.git("config", "user.name", "Synthetic unit fixture") + self.git("config", "user.email", "fixture@example.invalid") + self.write("dist/index.html", b"old") + self.write("dist/terraphim-editor-123456789abcdef0.js", b"obsolete") + self.git("add", "dist") + self.git("commit", "-qm", "synthetic baseline") + self.write_stage("index.html", b"new") + + def git(self, *args): + return subprocess.check_output(["git", "-C", str(self.repo), *args], stderr=subprocess.STDOUT) + + def write(self, name, data): + path = self.repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + def write_stage(self, name, data): + path = self.stage / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + def run_publish(self, **kwargs): + files = {p.relative_to(self.stage).as_posix(): P.digest(p.read_bytes()) + for p in self.stage.rglob("*") if p.is_file()} + return P.publish(self.repo, self.stage, files, ["dist", "package/dist"], **kwargs) + + def test_preserves_extras_and_unknown_old_hash_and_cleans_owned(self): + self.write("dist/notes.txt", b"user") + unknown = "dist/terraphim-editor-d0697d7d1ae1409.js" + self.write(unknown, b"protected") + self.write("package/README.md", b"package user content") + removed = self.run_publish() + self.assertEqual(removed, ["dist/terraphim-editor-123456789abcdef0.js"]) + for destination in ("dist", "package/dist"): + self.assertEqual((self.repo / destination / "index.html").read_bytes(), b"new") + self.assertEqual((self.repo / unknown).read_bytes(), b"protected") + self.assertEqual((self.repo / "dist/notes.txt").read_bytes(), b"user") + self.assertEqual((self.repo / "package/README.md").read_bytes(), b"package user content") + self.assertEqual(self.git("diff", "--cached"), b"") + + def test_unknown_collision_refuses_entire_batch(self): + self.write("package/dist/index.html", b"untracked package") + with self.assertRaisesRegex(ValueError, "unknown collision"): + self.run_publish() + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"old") + self.assertTrue((self.repo / "dist/terraphim-editor-123456789abcdef0.js").exists()) + self.assertFalse((self.repo / "target/release-transactions").exists()) + + def test_approval_requires_exact_path_and_before_hash(self): + self.write("package/dist/index.html", b"unknown") + for approval in ({"dist/index.html": P.digest(b"unknown")}, + {"package/dist/index.html": P.digest(b"wrong")}): + with self.assertRaisesRegex(ValueError, "unknown collision"): + self.run_publish(approved=approval) + self.run_publish(approved={"package/dist/index.html": P.digest(b"unknown")}) + self.assertEqual((self.repo / "package/dist/index.html").read_bytes(), b"new") + + def test_identical_untracked_collision_is_safe(self): + self.write("package/dist/index.html", b"new") + self.run_publish() + + def test_dirty_tracked_even_identical_to_new_refused(self): + self.write("dist/index.html", b"new") + with self.assertRaisesRegex(ValueError, "dirty"): + self.run_publish() + self.assertFalse((self.repo / "package").exists()) + + def test_deleted_tracked_refused(self): + (self.repo / "dist/index.html").unlink() + with self.assertRaisesRegex(ValueError, "deleted tracked"): + self.run_publish() + + def test_dirty_obsolete_refused(self): + self.write("dist/terraphim-editor-123456789abcdef0.js", b"dirty") + with self.assertRaisesRegex(ValueError, "dirty obsolete"): + self.run_publish() + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"old") + + def test_prior_owned_exact_bytes_cleanup(self): + name = "terraphim-editor-abcdef1234567890_bg.wasm" + self.write("package/dist/" + name, b"owned") + self.run_publish(prior={"package/dist": {name: P.digest(b"owned")}}) + self.assertFalse((self.repo / "package/dist" / name).exists()) + + def test_prior_owned_dirty_refused(self): + name = "terraphim-editor-abcdef1234567890_bg.wasm" + self.write("package/dist/" + name, b"dirty") + with self.assertRaisesRegex(ValueError, "dirty obsolete"): + self.run_publish(prior={"package/dist": {name: P.digest(b"owned")}}) + + def test_mid_copy_failure_rolls_back_both_destinations(self): + self.write_stage("js/worker.js", b"worker") + count = 0 + + def fail(source, destination): + nonlocal count + count += 1 + if count == 4: + raise OSError("injected copy failure") + os.replace(source, destination) + + with self.assertRaisesRegex(OSError, "injected"): + self.run_publish(replace=fail) + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"old") + self.assertEqual((self.repo / "dist/terraphim-editor-123456789abcdef0.js").read_bytes(), b"obsolete") + self.assertFalse((self.repo / "dist/js").exists()) + self.assertFalse((self.repo / "package").exists()) + self.assertFalse(list(self.repo.rglob(".release-*"))) + + def test_dry_run_never_changes_destinations(self): + self.run_publish(dry_run=True) + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"old") + self.assertFalse((self.repo / "package").exists()) + + def test_report_failure_also_rolls_back(self): + def fail_report(removed): + raise OSError("injected metadata failure") + with self.assertRaisesRegex(OSError, "metadata failure"): + self.run_publish(finalize=fail_report) + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"old") + self.assertTrue((self.repo / "dist/terraphim-editor-123456789abcdef0.js").exists()) + self.assertFalse((self.repo / "package").exists()) + + def temp_writer(self, action): + original = P.tempfile.NamedTemporaryFile + def factory(*args, **kwargs): + stream = original(*args, **kwargs) + write = stream.write + if kwargs.get("prefix") == ".release-": + def intercepted(data): + return action(stream, write, data) + stream.write = intercepted + return stream + return mock.patch.object(P.tempfile, "NamedTemporaryFile", factory) + + def test_pending_external_edit_during_temp_write_is_preserved(self): + def edit(stream, write, data): + result = write(data) + self.write("dist/index.html", b"external pending edit") + return result + with self.temp_writer(edit): + with self.assertRaisesRegex(ValueError, "changed after preflight"): + self.run_publish() + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"external pending edit") + self.assertFalse((self.repo / "package").exists()) + + def test_external_edit_before_later_failure_is_not_rolled_back(self): + self.write_stage("js/worker.js", b"worker") + count = 0 + def fail(source, destination): + nonlocal count + count += 1 + if count == 2: + self.write("dist/index.html", b"external installed edit") + raise OSError("injected later failure") + os.replace(source, destination) + with self.assertRaisesRegex(OSError, "injected later failure") as error: + self.run_publish(replace=fail) + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"external installed edit") + self.assertIn("manual recovery", str(error.exception)) + ledger = next((self.repo / "target/release-transactions").glob("*/recovery.json")) + report = json.loads(ledger.read_text()) + self.assertEqual(report["status"], "manual-recovery-required") + self.assertTrue(report["conflicts"]) + + def test_partial_temp_write_failure_cleans_owned_artifact(self): + def fail(stream, write, data): + write(data[:1]) + raise OSError("injected partial write failure") + with self.temp_writer(fail): + with self.assertRaisesRegex(OSError, "injected partial write failure"): + self.run_publish() + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"old") + self.assertFalse(list(self.repo.rglob(".release-*"))) + + def test_external_temp_path_substitution_is_preserved(self): + external = None + def fail(stream, write, data): + nonlocal external + external = Path(stream.name) + external.unlink() + external.write_bytes(b"external temp substitution") + raise OSError("injected temp substitution failure") + with self.temp_writer(fail): + with self.assertRaisesRegex(OSError, "injected temp substitution failure") as error: + self.run_publish() + self.assertEqual(external.read_bytes(), b"external temp substitution") + self.assertIn("manual recovery", str(error.exception)) + + def test_same_bytes_inode_replacement_after_preflight_refused(self): + replacement_inode = None + def edit(stream, write, data): + nonlocal replacement_inode + result = write(data) + external = self.write("external-edit", b"old") + replacement_inode = external.stat().st_ino + os.replace(external, self.repo / "dist/index.html") + return result + with self.temp_writer(edit): + with self.assertRaisesRegex(ValueError, "changed after preflight"): + self.run_publish() + self.assertEqual((self.repo / "dist/index.html").stat().st_ino, replacement_inode) + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"old") + + def test_same_postimage_bytes_external_write_refuses_rollback(self): + self.write_stage("js/worker.js", b"worker") + count = 0 + def fail(source, destination): + nonlocal count + count += 1 + if count == 2: + self.write("dist/index.html", b"new") + raise OSError("injected same-byte external edit") + os.replace(source, destination) + with self.assertRaisesRegex(OSError, "manual recovery"): + self.run_publish(replace=fail) + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"new") + + def test_temp_flush_lseek_fsync_and_close_failures_cleanup(self): + original = P.tempfile.NamedTemporaryFile + for phase in ("flush", "lseek", "fsync", "close"): + with self.subTest(phase=phase): + def factory(*args, **kwargs): + stream = original(*args, **kwargs) + if phase == "flush": + stream.flush = mock.Mock(side_effect=OSError("injected flush failure")) + if phase == "close": + close = stream.close + def fail_close(): + close() + raise OSError("injected close failure") + stream.close = fail_close + return stream + with mock.patch.object(P.tempfile, "NamedTemporaryFile", factory): + if phase in ("lseek", "fsync"): + with mock.patch.object(P.os, phase, side_effect=OSError(f"injected {phase} failure")): + with self.assertRaisesRegex(OSError, f"injected {phase}"): + self.run_publish() + else: + with self.assertRaisesRegex(OSError, f"injected {phase}"): + self.run_publish() + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"old") + self.assertFalse(list(self.repo.rglob(".release-*"))) + + def test_partial_write_and_close_failure_keeps_original_error(self): + original = P.tempfile.NamedTemporaryFile + def factory(*args, **kwargs): + stream = original(*args, **kwargs) + write, close = stream.write, stream.close + def fail_write(data): + write(data[:1]) + raise OSError("original partial write failure") + def fail_close(): + close() + raise OSError("secondary close failure") + stream.write, stream.close = fail_write, fail_close + return stream + with mock.patch.object(P.tempfile, "NamedTemporaryFile", factory): + with self.assertRaisesRegex(OSError, "original partial write failure") as error: + self.run_publish() + self.assertIn("manual recovery", str(error.exception)) + self.assertFalse(list(self.repo.rglob(".release-*"))) + ledger = next((self.repo / "target/release-transactions").glob("*/recovery.json")) + self.assertTrue(json.loads(ledger.read_text())["cleanupErrors"]) + + def test_unlink_failure_reports_residual_without_masking_root(self): + unlink = Path.unlink + def fail_unlink(path, *args, **kwargs): + if path.name.startswith(".release-"): + raise OSError("secondary unlink failure") + return unlink(path, *args, **kwargs) + def fail_write(stream, write, data): + write(data[:1]) + raise OSError("original private-body-not-for-ledger") + with self.temp_writer(fail_write), mock.patch.object(Path, "unlink", fail_unlink): + with self.assertRaisesRegex(OSError, "original private-body-not-for-ledger") as error: + self.run_publish() + self.assertIn("manual recovery", str(error.exception)) + ledger = next((self.repo / "target/release-transactions").glob("*/recovery.json")) + report = json.loads(ledger.read_text()) + self.assertEqual(report["status"], "manual-recovery-required") + self.assertTrue(report["residualArtifacts"]) + self.assertTrue(report["cleanupErrors"]) + self.assertNotIn("private-body-not-for-ledger", ledger.read_text()) + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"old") + + def test_external_new_file_at_deleted_path_is_not_restored_over(self): + name = "dist/terraphim-editor-123456789abcdef0.js" + count = 0 + def fail(source, destination): + nonlocal count + count += 1 + if count == 2: + self.write(name, b"external recreation") + raise OSError("injected after owned deletion") + os.replace(source, destination) + with self.assertRaisesRegex(OSError, "manual recovery"): + self.run_publish(replace=fail) + self.assertEqual((self.repo / name).read_bytes(), b"external recreation") + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"old") + + def test_rollback_rechecks_after_preparing_restore_temp(self): + original = P.tempfile.NamedTemporaryFile + def factory(*args, **kwargs): + stream = original(*args, **kwargs) + if kwargs.get("prefix") == ".rollback-": + write = stream.write + def edit(data): + result = write(data) + self.write("dist/index.html", b"external during rollback") + return result + stream.write = edit + return stream + def fail_report(removed): + raise OSError("original report failure") + with mock.patch.object(P.tempfile, "NamedTemporaryFile", factory): + with self.assertRaisesRegex(OSError, "original report failure") as error: + self.run_publish(finalize=fail_report) + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"external during rollback") + self.assertIn("manual recovery", str(error.exception)) + self.assertFalse(list(self.repo.rglob(".rollback-*"))) + + def test_partial_rollback_temp_failure_reports_manual_recovery(self): + original = P.tempfile.NamedTemporaryFile + def factory(*args, **kwargs): + stream = original(*args, **kwargs) + if kwargs.get("prefix") == ".rollback-": + write = stream.write + def fail(data): + write(data[:1]) + raise OSError("secondary rollback write failure") + stream.write = fail + return stream + def fail_report(removed): + raise OSError("original report failure") + with mock.patch.object(P.tempfile, "NamedTemporaryFile", factory): + with self.assertRaisesRegex(OSError, "original report failure") as error: + self.run_publish(finalize=fail_report) + self.assertIn("manual recovery", str(error.exception)) + self.assertFalse(list(self.repo.rglob(".rollback-*"))) + ledger = next((self.repo / "target/release-transactions").glob("*/recovery.json")) + report = json.loads(ledger.read_text()) + self.assertEqual(report["status"], "manual-recovery-required") + self.assertTrue(report["conflicts"]) + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"new") + + def test_rollback_symlink_ancestor_is_never_followed(self): + self.write_stage("js/worker.js", b"worker") + self.write("external/index.html", b"external user bytes") + count = 0 + def fail(source, destination): + nonlocal count + count += 1 + if count == 2: + os.rename(self.repo / "dist", self.repo / "moved-dist") + (self.repo / "dist").symlink_to(self.repo / "external", target_is_directory=True) + raise OSError("original symlink failure") + os.replace(source, destination) + with self.assertRaisesRegex(OSError, "original symlink failure") as error: + self.run_publish(replace=fail) + self.assertEqual((self.repo / "external/index.html").read_bytes(), b"external user bytes") + self.assertIn("manual recovery", str(error.exception)) + + def test_manifest_ancestor_symlinks_rejected(self): + self.write("external/approval.json", b'{"schema":1,"files":[]}') + (self.repo / "approval-dir").symlink_to(self.repo / "external", target_is_directory=True) + with self.assertRaisesRegex(ValueError, "symlink"): + P.load_manifest(self.repo / "approval-dir/approval.json") + with self.assertRaisesRegex(ValueError, "symlink"): + P.write_metadata(self.repo, self.repo / "approval-dir/output.json", {"schema": 1}) + self.assertFalse((self.repo / "external/output.json").exists()) + + def test_metadata_partial_write_failure_cleanup_and_substitution(self): + path = self.repo / "report.json" + write = P.os.write + count = 0 + def fail(fd, data): + nonlocal count + count += 1 + if count == 1: + return write(fd, data[:1]) + raise OSError("original metadata write failure") + with mock.patch.object(P.os, "write", fail): + with self.assertRaisesRegex(OSError, "original metadata write failure"): + P.write_metadata(self.repo, path, {"schema": 1}) + self.assertFalse(path.exists()) + def substitute(fd): + path.unlink() + path.write_bytes(b"external report") + raise OSError("original metadata fsync failure") + with mock.patch.object(P.os, "fsync", substitute): + with self.assertRaisesRegex(OSError, "original metadata fsync failure") as error: + P.write_metadata(self.repo, path, {"schema": 1}) + self.assertEqual(path.read_bytes(), b"external report") + self.assertTrue(error.exception.recovery_conflicts) + + def test_non_directory_ancestor_preflight(self): + self.write("package", b"protected file") + with self.assertRaisesRegex(ValueError, "non-directory ancestor"): + self.run_publish() + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"old") + + def test_traversal_duplicate_and_invalid_hash_manifest(self): + for name in ("../x", "/x", "x/../y", "x//y", "./x", "x\\y"): + with self.assertRaises(ValueError): + P.entries([{"path": name, "sha256": "a" * 64}]) + with self.assertRaises(ValueError): + P.entries([{"path": "x", "sha256": "bad"}]) + with self.assertRaises(ValueError): + P.entries([{"path": "x", "sha256": "a" * 64}] * 2) + + def test_source_hash_and_source_symlink_refused(self): + with self.assertRaisesRegex(ValueError, "SHA mismatch"): + P.publish(self.repo, self.stage, {"index.html": "a" * 64}, ["dist"]) + (self.stage / "index.html").unlink() + (self.stage / "index.html").symlink_to(self.repo / "dist/index.html") + with self.assertRaisesRegex(ValueError, "symlink"): + self.run_publish() + + def test_destination_symlink_refused(self): + (self.repo / "package").symlink_to(self.stage, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "symlink"): + self.run_publish() + self.assertEqual((self.repo / "dist/index.html").read_bytes(), b"old") + + def production_shape(self): + for name in P.FIXED | {"terraphim-editor-abcdef1234567890.js", "terraphim-editor-abcdef1234567890_bg.wasm"}: + self.write_stage(name, b"synthetic") + self.write_stage("vendor/manifest.json", json.dumps({"schema": 1, "files": []}).encode()) + + def test_inventory_curated_hashes_and_rejects_private_or_harness(self): + self.production_shape() + actual = P.inventory(self.stage) + self.assertEqual(actual["index.html"], P.digest(b"synthetic")) + for name in ("browser-test.html", "rawsearch.json", "corpus/private.txt", "demos/out/video.mp4"): + self.write_stage(name, b"not production") + with self.assertRaisesRegex(ValueError, "non-production"): + P.inventory(self.stage) + (self.stage / name).unlink() + + def test_cli_actual_rejection_exit_status(self): + result = subprocess.run(["python3", str(ROOT / "scripts/publish-editor-dist.py"), + "--stage", str(self.stage), "--manifest", str(self.repo / "report.json")], + text=True, capture_output=True) + self.assertEqual(result.returncode, 1) + self.assertIn("isolated target/release-staging", result.stderr) + + def test_inventory_rejects_orphan_snippet_and_symlink_directory(self): + self.production_shape() + self.write_stage("snippets/browser-harness/inline0.js", b"test") + with self.assertRaisesRegex(ValueError, "non-production"): + P.inventory(self.stage) + (self.stage / "snippets/browser-harness/inline0.js").unlink() + (self.stage / "linked").symlink_to(self.repo / "dist", target_is_directory=True) + with self.assertRaisesRegex(ValueError, "symlink"): + P.inventory(self.stage) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/browser/editor-startup.test.cjs b/tests/browser/editor-startup.test.cjs new file mode 100644 index 0000000..a1eaf71 --- /dev/null +++ b/tests/browser/editor-startup.test.cjs @@ -0,0 +1,64 @@ +// Startup ordering units with explicit DOM/editor-initialization doubles. +// These are not evidence of browser component, font or WASM readiness. +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); + +function fixture(ready, {embed = false, assets = true} = {}) { + const listeners = new Map(); + const errors = []; + let initialized = 0; + const app = {querySelector: () => ({})}; + const context = vm.createContext({ + AbortController, setTimeout, + console: {error: (...args) => errors.push(args)}, + document: { + getElementById: id => id === 'app' ? app : null, + addEventListener: (name, fn) => listeners.set(name, fn), + }, + }); + context.window = context; + context.TE_EMBED = embed; + context.EditorConfig = {shortcuts: [], commands: []}; + if (assets) context.TerraphimRuntimeAssets = {ready}; + vm.runInContext(fs.readFileSync('public/js/editor.js', 'utf8'), context); + context.MarkdownEditor.prototype.initialize = () => { ++initialized; }; + return {start: () => listeners.get('DOMContentLoaded')(), + count: () => initialized, errors}; +} + +test('full-page initialization waits for runtime readiness', async () => { + let resolve; + const f = fixture(new Promise(done => { resolve = done; })); + const started = f.start(); + assert.equal(f.count(), 0); + resolve(); + await started; + assert.equal(f.count(), 1); + assert.equal(f.errors.length, 0); +}); + +test('runtime rejection is reported without initializing', async () => { + let reject; + const f = fixture(new Promise((_, fail) => { reject = fail; })); + const started = f.start(); + reject(new Error('synthetic asset failure')); + await started; + assert.equal(f.count(), 0); + assert.equal(f.errors.length, 1); +}); + +test('missing runtime readiness fails closed', async () => { + const f = fixture(undefined, {assets: false}); + await f.start(); + assert.equal(f.count(), 0); + assert.equal(f.errors.length, 1); +}); + +test('embedded hosts are not initialized by the full-page listener', async () => { + const f = fixture(undefined, {embed: true, assets: false}); + await f.start(); + assert.equal(f.count(), 0); + assert.equal(f.errors.length, 0); +}); diff --git a/tests/browser/ghost-history.test.cjs b/tests/browser/ghost-history.test.cjs new file mode 100644 index 0000000..94b1d9e --- /dev/null +++ b/tests/browser/ghost-history.test.cjs @@ -0,0 +1,333 @@ +// Actual production module methods with explicit native checkpoint/receipt and +// DOM doubles. NOT native model, WASM runtime, browser or human acceptance. +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function fixture(limit = 500) { + const context = vm.createContext({console, performance, TextEncoder, structuredClone, + document:{activeElement:null,addEventListener(){}}, InputEvent:class {}, setTimeout, clearTimeout}); + context.window = context; + for (const name of ['editor','trim','indicators']) vm.runInContext(fs.readFileSync(`public/js/${name}.js`,'utf8'), context); + const surface = Object.create(context.EditorSurface.prototype); + Object.assign(surface, {text:'abc', history:[], historyIndex:-1, historyLimit:limit, coalesceMs:1000, + decorations:[], lastSelection:{start:1,end:2,direction:'backward'}, nextDecorationId:1, + root:{childNodes:[{text:'abc'}],replaceChildren(...nodes){this.childNodes=nodes;},dispatchEvent(){}},changeListeners:new Set(), + render(){this.renders=(this.renders||0)+1;this.root.childNodes=[{text:this.text,decorations:structuredClone(this.decorations)}];}, readSelection(){return this.lastSelection;}, + setSelectionOffsets(start,end=start,direction='none'){this.lastSelection={start,end,direction};}}); + surface.record('init', surface.lastSelection); + const state={body:'abc',ghosts:[],reservations:[],receipts:[]}; + const calls=[]; let pending; let watermark=0; + const receipt={body:'abc',before:{live:[],setAside:[],reservedIds:[]}, + after:{live:[{id:'g9007199254740994',anchor:{start:0,end:1,text:'a'}}],setAside:[],reservedIds:['g9007199254740994']}}; + const annotations=()=>({spans:[],ghosts:state.ghosts,setAside:{spans:[],ghosts:[]},overflow:''}); + const begin=(payload)=>{const token=`double-${++watermark}`;pending={token,before:structuredClone(state)};return {ok:true,token,...payload};}; + const api={open_document(){},apply_edit(){},document_body:()=>state.body, + begin_trim_ghost_transaction(body,level,kept){calls.push(['trim',body,level,kept]); + if(body!==state.body) throw Error('body-conflict'); + const result=begin({change:receipt});state.ghosts=structuredClone(receipt.after.live); + state.reservations=['g9007199254740994'];state.receipts=[receipt];result.annotations=annotations();return result;}, + begin_document_history_replay(body,target,ops){calls.push(['replay',body,target,JSON.parse(ops)]); + const result=begin({});state.body=target;for(const op of JSON.parse(ops)) if(op.kind==='ghost') state.ghosts=structuredClone(op.undo?receipt.before.live:receipt.after.live); + result.annotations=annotations();return result;}, + finish_document_history_transaction(token){calls.push(['finish',token]);assert.equal(token,pending.token);pending=null;return {ok:true,finalized:true,token};}, + abort_document_history_transaction(token){calls.push(['abort',token]);assert.equal(token,pending.token);Object.assign(state,pending.before);pending=null;return {ok:true,rolledBack:true,token};}, + kg_role_snapshot_json:()=>JSON.stringify({generation:'1',loaded:true,protectionReady:true}), + }; + const editor=Object.create(context.MarkdownEditor.prototype); + const registry=Object.create(context.TeDecorationRegistry.prototype); + Object.assign(registry,{surface,layers:new Map(),pending:false,batchDepth:0,applyListeners:new Set(),renderCount:0}); + Object.assign(editor,{surface,decorations:registry,config:{bindings:api}, + showWarning(message,kind=null){this.notice=message;this.warningKind=message ? kind : null;}}); + return {context,surface,editor,api,state,calls,receipt}; +} + +test('capacity 1 refuses protected begin before any native call',()=>{ + const f=fixture(1);assert.throws(()=>f.editor.commitTrimGhosts('sharper',[]),/capacity/); + assert.equal(f.calls.length,0);assert.equal(f.surface.history.length,1); +}); + +test('forced annotation step preserves exact receipt IDs and undoes/redoes model first',()=>{ + const f=fixture(2); f.editor.commitTrimGhosts('sharper',[]); + assert.equal(f.surface.history.length,2); + assert.equal(f.surface.history[1].edits[0].ghost.change.after.live[0].id,'g9007199254740994'); + assert.equal(f.surface.undo(),true);assert.equal(f.state.ghosts.length,0); + assert.equal(f.surface.redo(),true);assert.equal(f.state.ghosts[0].id,'g9007199254740994'); +}); + +const snapshot = f => JSON.stringify({history:f.surface.history,index:f.surface.historyIndex,text:f.surface.text, + selection:f.surface.lastSelection,decorations:f.surface.decorations,state:f.state, + nodes:f.surface.root.childNodes,layers:Array.from(f.editor.decorations.layers)}); + +for (const limit of [2,500]) test(`capacity ${limit}: real forced recording and redo truncation`,()=>{ + const f=fixture(limit);f.editor.commitTrimGhosts('sharper',[]);f.surface.undo(); + assert.equal(f.surface.canRedo(),true);f.editor.commitTrimGhosts('sharper',[]); + assert.equal(f.surface.canRedo(),false);assert.equal(f.surface.canUndo(),true); +}); + +for (const fault of ['record','paint','finish','bad-finish']) test(`${fault} failure rolls back complete owned state and redo`,()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',[]);f.surface.undo(); + const before=snapshot(f); + if(fault==='record') f.surface.record=function(){this.history.length=1;this.historyIndex=0;this.text='partial';throw Error('record fault');}; + if(fault==='paint') f.surface.render=()=>{throw Error('paint fault');}; + if(fault==='finish') f.api.finish_document_history_transaction=()=>{throw Error('finish fault');}; + if(fault==='bad-finish') f.api.finish_document_history_transaction=()=>({ok:1,finalized:true,token:'wrong'}); + assert.throws(()=>f.editor.commitTrimGhosts('sharper',[1]),/fault|acknowledgement/); + assert.equal(snapshot(f),before);assert.equal(f.editor.ghostHistoryConflict,undefined); +}); + +test('capacity refusal preserves an existing redo branch with no native begin',()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',[]);f.surface.undo();f.surface.historyLimit=1; + const before=snapshot(f),calls=f.calls.length; + assert.throws(()=>f.editor.commitTrimGhosts('sharper',[]),/capacity/); + assert.equal(snapshot(f),before);assert.equal(f.calls.length,calls); +}); + +test('mixed undo passes intermediate bodies and notifies only after finish without duplicate mirror edits',()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',[]); + f.surface.text='abcX';f.state.body='abcX'; + f.surface.record('typing',f.surface.lastSelection,{start:3,deletedText:'',insertedText:'X'}); + assert.equal(f.surface.squashHistory(2),true); + let emitted=0,duplicate=0,phase; + f.surface.onChange(change=>{phase=[change.edit,f.calls.at(-1)[0],f.editor.suppressModelSync];emitted++;}); + f.api.apply_edit=()=>{duplicate++;};f.surface.onChange(change=>f.editor.mirrorEdit(change)); + assert.equal(f.surface.undo(),true); + const ops=f.calls.find(c=>c[0]==='replay')[3]; + assert.deepEqual(ops.map(o=>[o.kind,o.body]),[['edit','abc'],['ghost','abc']]); + assert.equal(emitted,1);assert.deepEqual(phase,[null,'finish',true]);assert.equal(duplicate,0);assert.equal(f.surface.text,'abc'); + assert.equal(f.surface.redo(),true);assert.equal(f.surface.text,'abcX'); +}); + +test('native whole-group last receipt refusal happens before cursor, paint, selection or DOM',()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',[]); + f.surface.text='abcX';f.state.body='abcX';f.surface.record('typing',f.surface.lastSelection,{start:3,deletedText:'',insertedText:'X'}); + f.surface.squashHistory(2);const before=snapshot(f),renders=f.surface.renders; + f.api.begin_document_history_replay=(body,target,json)=>{const ops=JSON.parse(json);assert.equal(ops[0].body,'abc');assert.equal(ops[1].kind,'ghost');throw Error('last receipt conflict (native double)');}; + assert.equal(f.surface.undo(),false);assert.equal(snapshot(f),before);assert.equal(f.surface.renders,renders); + assert.match(f.editor.notice,/last receipt conflict/); +}); + +for (const fault of ['throw','coerced','wrong-token']) test(`abort ${fault} never claims rollback; block and preserve interleaved state`,()=>{ + const f=fixture(); + f.surface.record=function(){this.text='external';f.state.body='external';throw Error('record callback');}; + f.api.abort_document_history_transaction=()=>{ + if(fault==='throw') throw Error('session-conflict'); + return {ok:fault==='coerced'?1:true,rolledBack:true,token:fault==='wrong-token'?'foreign':'double-1'}; + }; + assert.throws(()=>f.editor.commitTrimGhosts('sharper',[]),/rollback not acknowledged/); + assert.equal(f.state.body,'external');assert.equal(f.surface.text,'external');assert.ok(f.editor.ghostHistoryConflict); + const calls=f.calls.length;assert.throws(()=>f.editor.commitTrimGhosts('sharper',[]),/conflict/); + assert.equal(f.calls.length,calls);assert.throws(()=>f.editor.alignDocumentModel(f.api),/explicitly reopen/); +}); + +test('role event during recording stays authoritative after native document abort',()=>{ + const f=fixture();let role='1';f.api.kg_role_snapshot_json=()=>role; + f.editor.decorations={layers:new Map([['lab-trim',[{id:'fade',start:1,end:2}]]])}; + f.surface.decorations=[{id:'lab-trim:fade',start:1,end:2}]; + f.surface.record=function(){role='2';f.editor.decorations.layers.set('lab-trim',[]);this.decorations=[];throw Error('role changed');}; + assert.throws(()=>f.editor.commitTrimGhosts('sharper',[]),/role changed/); + assert.equal(f.editor.decorations.layers.get('lab-trim').length,0); + assert.equal(f.surface.decorations.length,0);assert.equal(f.surface.history.length,1); +}); + +test('all six kinds transport real metadata without numeric receipt/ID coercion',()=>{ + const f=fixture();const ghost={start:0,deletedText:'',insertedText:'',ghost:{change:f.receipt,undo:false}}; + const steps=[ghost, + {start:3,deletedText:'',insertedText:'X'}, + {start:0,deletedText:'',insertedText:'',alt:{span:'s1',after:null,before:null}}, + {start:0,deletedText:'',insertedText:'',move:{start:0,end:1,to:1}}, + {start:0,deletedText:'',insertedText:'',swap:{span:'s1',from:0,to:1}}, + {start:0,deletedText:'',insertedText:'',overflow:{before:'',after:'note'}}]; + const result=f.context.MarkdownEditor.nativeGhostHistoryOperations('abc',steps,'abcX'); + assert.deepEqual(JSON.parse(result.json).map(o=>o.kind),['ghost','edit','alt','move','swap','overflow']); + assert.equal(JSON.parse(result.json)[0].change.after.live[0].id,'g9007199254740994'); + const inverse=f.context.EditorSurface.invertStep(ghost); + assert.equal(inverse.ghost.change,f.receipt);assert.equal(inverse.ghost.undo,true); +}); + +for (const fault of ['unknown','conflicting','wrong-body','float','count','missing']) test(`invalid mixed metadata ${fault} refuses before native mutation`,()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',[]); + const step=f.surface.history[1].edits[0]; + if(fault==='unknown')step.surprise=true; + if(fault==='conflicting')step.swap={span:'s1',from:0,to:1}; + if(fault==='wrong-body')f.surface.history[0].text='wrong'; + if(fault==='float')step.start=0.5; + if(fault==='count')f.surface.history[1].edits=Array(4097).fill(step); + if(fault==='missing')f.surface.history[1].edits=null; + const before=snapshot(f),calls=f.calls.length; + assert.equal(f.surface.undo(),false);assert.equal(snapshot(f),before);assert.equal(f.calls.length,calls); +}); + +test('large bounded receipt transport and UTF-8 byte +1 refusal',()=>{ + const f=fixture();const padding='x'.repeat(1024*1024);f.receipt.after.live[0].anchor.before=padding; + f.editor.commitTrimGhosts('sharper',[]);assert.equal(f.surface.history[1].edits[0].ghost.change.after.live[0].anchor.before,padding); + assert.throws(()=>f.context.MarkdownEditor.ghostTransportBounds('é'.repeat(8*1024*1024)+'x','','[]'),/16 MiB/); +}); + +test('legacy typing still coalesces and ghost metadata never coalesces',()=>{ + const f=fixture();f.surface.text='abcd';f.surface.record('typing',null,{start:3,deletedText:'',insertedText:'d'}); + f.surface.text='abcde';f.surface.record('typing',null,{start:4,deletedText:'',insertedText:'e'}); + assert.equal(f.surface.history.length,2);assert.equal(f.surface.history[1].edits.length,1); + const append=f.context.EditorSurface.appendStep([{start:0,deletedText:'',insertedText:'',ghost:{change:f.receipt,undo:false}}],{start:0,deletedText:'',insertedText:'X'},'abc'); + assert.equal(append.length,2);assert.ok(append[0].ghost); +}); + +function trimFixture(f) { + const trim=Object.create(f.context.TeLabTrim.prototype); + Object.assign(trim,{editor:f.editor,cardOpen:true,level:'sharper',kept:new Set([1]),plan:{safe:true}, + planText:'abc',walkIndex:2,stopWalk(){this.stopped=true;},reset(){this.cardOpen=false;this.kept.clear();}, + announce(message){this.notice=message;}}); + return trim; +} + +test('actual Done retires UI only after final acknowledgement',()=>{ + const f=fixture(),trim=trimFixture(f);let observed=false; + const finish=f.api.finish_document_history_transaction; + f.api.finish_document_history_transaction=t=>{assert.equal(trim.cardOpen,true);assert.equal(trim.walkIndex,2);observed=true;return finish(t);}; + assert.equal(trim.done(),true);assert.equal(observed,true);assert.equal(trim.plan,null);assert.equal(trim.cardOpen,false); +}); + +test('actual Done uses announce, not destructive B refusal, on record failure',()=>{ + const f=fixture(),trim=trimFixture(f);const plan=trim.plan; + f.surface.record=()=>{throw Error('record refused');};trim.refuse=()=>assert.fail('must not retire safe plan'); + assert.equal(trim.done(),false);assert.equal(trim.plan,plan);assert.equal(trim.cardOpen,true); + assert.equal(trim.kept.size,1);assert.equal(trim.walkIndex,2);assert.equal(trim.stopped,undefined);assert.match(trim.notice,/record refused/); +}); + +test('protected no-op clears Done UI without issuing history or claiming an undo step',()=>{ + const f=fixture(),trim=trimFixture(f),before=snapshot(f); + f.api.begin_trim_ghost_transaction=()=>null; + assert.equal(trim.done(),true);assert.equal(snapshot(f),before);assert.equal(trim.plan,null); + assert.match(trim.notice,/no cuts to save/);assert.equal(f.calls.length,0); +}); + +test('protected native begin refusal leaves redo, selection, registry and DOM intact',()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',[]);f.surface.undo(); + const before=snapshot(f);f.api.begin_trim_ghost_transaction=()=>{throw {kind:'protection-refusal',error:'stale role/body'};}; + assert.throws(()=>f.editor.commitTrimGhosts('sharper',[]));assert.equal(snapshot(f),before); +}); + +test('unknown nested metadata survives inversion and refuses instead of being discarded',()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',[]); + f.surface.history[1].edits[0].ghost.extra='not allowed';const before=snapshot(f),calls=f.calls.length; + assert.equal(f.surface.undo(),false);assert.equal(snapshot(f),before);assert.equal(f.calls.length,calls); +}); + +test('string ghost direction is not boolean-coerced by inversion',()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',[]); + f.surface.history[1].edits[0].ghost.undo='false';const calls=f.calls.length; + assert.equal(f.surface.undo(),false);assert.equal(f.calls.length,calls); +}); + +test('4096 kept indices reach native; 4097 refuse before begin',()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',Array(4096).fill(0)); + assert.equal(JSON.parse(f.calls[0][3]).length,4096);const before=snapshot(f),calls=f.calls.length; + assert.throws(()=>f.editor.commitTrimGhosts('sharper',Array(4097).fill(0)),/Too many/); + assert.equal(snapshot(f),before);assert.equal(f.calls.length,calls); +}); + +test('silent record drop refuses and aborts rather than reporting Done success',()=>{ + const f=fixture(),before=snapshot(f);f.surface.record=()=>{}; + assert.throws(()=>f.editor.commitTrimGhosts('sharper',[]),/not retained/);assert.equal(snapshot(f),before); +}); + +test('explicit canonical recovery uses exact ack and resets conflict/history',()=>{ + const f=fixture();f.editor.ghostHistoryConflict={token:'conflicted-token'}; + f.api.recover_document_history_transaction=(token,source)=>({ok:true,recovered:true,rolledBack:false,token, + opened:{body:source,kind:null,warning:null,unresolved:0}}); + f.api.sync_document_body=body=>{f.state.body=body;return {changed:false,unresolved:0};}; + f.editor.setAsideCount=0;f.surface.history.push({text:'old',edits:[]});f.surface.historyIndex=1; + f.editor.openDocument('canonical','new-name'); + assert.equal(f.editor.ghostHistoryConflict,null);assert.equal(f.surface.text,'canonical'); + assert.equal(f.surface.history.length,1);assert.equal(f.surface.canUndo(),false);assert.equal(f.surface.canRedo(),false); +}); + +test('malformed explicit recovery keeps the manual block and surface unchanged',()=>{ + const f=fixture();f.editor.ghostHistoryConflict={token:'conflicted-token'}; + f.api.recover_document_history_transaction=token=>({ok:true,recovered:true,rolledBack:false,token, + opened:{body:'bad',kind:'malformed',warning:'invalid annotations',unresolved:0}}); + const before=snapshot(f); + assert.throws(()=>f.editor.openDocument('bad','not-adopted'),/malformed/); + assert.ok(f.editor.ghostHistoryConflict);assert.equal(f.editor.ghostHistoryConflict.token,null); + assert.equal(snapshot(f),before);assert.equal(f.editor.documentKey,undefined); +}); + +test('undo finish failure restores cursor and annotations only after acknowledged abort',()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',[]);const before=snapshot(f); + f.api.finish_document_history_transaction=()=>{throw Error('undo finish fault');}; + assert.equal(f.surface.undo(),false);assert.equal(snapshot(f),before); +}); + +test('redo guard failure preserves redo branch and last selection',()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',[]);f.surface.undo();const before=snapshot(f); + f.api.begin_document_history_replay=()=>{throw Error('redo guard refused');}; + assert.equal(f.surface.redo(),false);assert.equal(snapshot(f),before);assert.equal(f.surface.canRedo(),true); +}); + +test('real registry paint callback conflict never overwrites external native state',()=>{ + const f=fixture();f.editor.decorations.onApply(()=>{f.state.body='external-native';throw Error('paint callback');}); + f.api.abort_document_history_transaction=()=>{throw Error('session-conflict');}; + assert.throws(()=>f.editor.commitTrimGhosts('sharper',[]),/rollback not acknowledged/); + assert.equal(f.state.body,'external-native');assert.ok(f.editor.ghostHistoryConflict); +}); + +test('missing begin token cannot be silently retired by ordinary document open',()=>{ + const f=fixture();let opened=0;f.api.open_document=()=>{opened++;}; + f.api.begin_trim_ghost_transaction=()=>undefined; + assert.throws(()=>f.editor.commitTrimGhosts('sharper',[]),/begin acknowledgement/); + assert.equal(f.editor.ghostHistoryConflict.nativeRetired,false); + assert.throws(()=>f.editor.openDocument('replacement'),/token unavailable/);assert.equal(opened,0); +}); + +test('acknowledged native abort plus DOM restore failure retains explicit recovery path',()=>{ + const f=fixture();f.surface.record=()=>{throw Error('record fault');}; + f.surface.root.replaceChildren=()=>{throw Error('DOM restore fault');}; + assert.throws(()=>f.editor.commitTrimGhosts('sharper',[]),/native rollback acknowledged but surface restoration failed/); + assert.equal(f.state.ghosts.length,0);assert.equal(f.editor.ghostHistoryConflict.nativeRetired,true); + assert.equal(f.editor.ghostHistoryConflict.token,null); + f.surface.record=f.context.EditorSurface.prototype.record; + f.api.open_document=body=>{f.state.body=body;return {body,kind:null,warning:null,unresolved:0};}; + f.api.sync_document_body=()=>({changed:false,unresolved:0}); + f.editor.openDocument('canonical');assert.equal(f.editor.ghostHistoryConflict,null);assert.equal(f.surface.text,'canonical'); +}); + +test('successful mixed replay publishes aggregate authoritative status only after exact finish',()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',[]); + Object.assign(f.editor,{setAsideCount:1,warningKind:'detached',notice:'previous detach'}); + const begin=f.api.begin_document_history_replay,finish=f.api.finish_document_history_transaction; + let target={spans:[],ghosts:[]},finished=false; + f.api.begin_document_history_replay=(...args)=>{const result=begin(...args);result.annotations.setAside=target;return result;}; + f.api.finish_document_history_transaction=token=>{ + assert.equal(f.editor.setAsideCount,finished ? 0 : 1); + assert.equal(f.editor.warningKind,finished ? null : 'detached'); + const result=finish(token);finished=true;return result; + }; + assert.equal(f.surface.undo(),true);assert.equal(f.editor.setAsideCount,0); + assert.equal(f.editor.warningKind,null);assert.equal(f.editor.notice,null); + target={spans:[{id:'disjoint-alt'}],ghosts:[{id:'unresolved-ghost'}]}; + assert.equal(f.surface.redo(),true);assert.equal(f.editor.setAsideCount,2); + assert.equal(f.editor.warningKind,'set-aside');assert.match(f.editor.notice,/2 annotations/); +}); + +for(const fault of ['begin','finish','bad-ack']) test(`mixed ${fault} refusal/acknowledged abort preserves prior annotation status`,()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',[]); + Object.assign(f.editor,{setAsideCount:3,warningKind:'detached',notice:'prior detached warning'}); + if(fault==='begin')f.api.begin_document_history_replay=()=>{throw Error('guard refusal');}; + if(fault==='finish')f.api.finish_document_history_transaction=()=>{throw Error('finish refusal');}; + if(fault==='bad-ack')f.api.finish_document_history_transaction=token=>({ok:true,finalized:1,token}); + const before=snapshot(f); + const steps=f.surface.history[1].edits.map(f.context.EditorSurface.invertStep); + assert.throws(()=>f.editor.replayGhostHistory(0,'undo',steps)); + assert.equal(f.editor.setAsideCount,3);assert.equal(f.editor.warningKind,'detached'); + assert.equal(f.editor.notice,'prior detached warning'); + assert.equal(snapshot(f),before); + if(fault!=='begin')assert.equal(f.calls.at(-1)[0],'abort'); +}); + +test('successful replay preserves malformed-block warning policy while updating count',()=>{ + const f=fixture();f.editor.commitTrimGhosts('sharper',[]); + Object.assign(f.editor,{setAsideCount:1,warningKind:'malformed',notice:'preserved malformed block'}); + assert.equal(f.surface.undo(),true);assert.equal(f.editor.setAsideCount,0); + assert.equal(f.editor.warningKind,'malformed');assert.equal(f.editor.notice,'preserved malformed block'); +}); diff --git a/tests/browser/native-authority-controller.integration.test.cjs b/tests/browser/native-authority-controller.integration.test.cjs new file mode 100644 index 0000000..77947c0 --- /dev/null +++ b/tests/browser/native-authority-controller.integration.test.cjs @@ -0,0 +1,362 @@ +// Actual browser DOM + production shared UI + real Rust/WASM in ResourceAuthority. +// The loopback transports native operations; it does not fabricate domain outcomes. +const {test,before,after}=require('node:test'); +const assert=require('node:assert/strict'); +const fs=require('node:fs/promises');const path=require('node:path');const os=require('node:os'); +const http=require('node:http');const {createHash}=require('node:crypto');const {createRequire}=require('node:module'); +const extension=process.env.TERRAPHIM_AUTHORITY_EXTENSION_DIR; +const assets=process.env.TERRAPHIM_AUTHORITY_ASSETS; +if(!extension||!assets)throw new Error('Set TERRAPHIM_AUTHORITY_EXTENSION_DIR and TERRAPHIM_AUTHORITY_ASSETS to actual built artifacts'); +const extensionRequire=createRequire(path.join(extension,'package.json')); +const {ResourceAuthority}=extensionRequire('./out/resourceAuthority.js'); +const {chromium}=extensionRequire('playwright-core'); +const hash=value=>createHash('sha256').update(value).digest('hex'); +let fixture,server,browser,origin;const authorities=[];let dispatch=0; +const role=JSON.stringify({name:'Controller fixture',data:{risk:{id:1,nterm:'risk',display_value:'Risk',pinned:false}}}); +before(async()=>{ + fixture=await fs.mkdtemp(path.join(os.tmpdir(),'terraphim-real-controller-')); + await fs.mkdir(path.join(fixture,'media','editor','roles'),{recursive:true}); + await fs.copyFile(path.join(extension,'media','resource-authority-worker.cjs'),path.join(fixture,'media','resource-authority-worker.cjs')); + const manifest={};for(const name of ['terraphim_editor.js','terraphim_editor_bg.wasm']){const b=await fs.readFile(path.join(assets,name));manifest[name]=hash(b);await fs.writeFile(path.join(fixture,'media','editor',name),b);} + const rb=await fs.readFile(path.join(assets,'roles','default.json'));manifest['roles/default.json']=hash(rb);await fs.writeFile(path.join(fixture,'media','editor','roles','default.json'),rb); + await fs.writeFile(path.join(fixture,'media','editor-build.json'),JSON.stringify({protocol:1,assets:manifest})); + server=http.createServer(async(req,res)=>{try{const url=new URL(req.url,'http://localhost'); + const file=url.pathname==='/history-checkpoint.js'?path.join(extension,'media','history-checkpoint.js'):path.resolve(assets,'.'+decodeURIComponent(url.pathname)); + if(url.pathname!=='/history-checkpoint.js'&&!file.startsWith(path.resolve(assets)+path.sep))throw new Error('outside fixture'); + const bytes=await fs.readFile(file);res.writeHead(200,{'content-type':file.endsWith('.wasm')?'application/wasm':file.endsWith('.js')?'text/javascript':'text/plain'});res.end(bytes); + }catch{res.writeHead(404);res.end();}}); + await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});origin=`http://127.0.0.1:${server.address().port}`; + const candidates=[process.env.TERRAPHIM_BROWSER_EXECUTABLE,'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome','/usr/bin/chromium'];let executablePath; + for(const p of candidates.filter(Boolean)){try{await fs.access(p);executablePath=p;break;}catch{}} + browser=await chromium.launch({headless:true,executablePath,timeout:15000}); +}); +after(async()=>{await browser?.close();for(const a of authorities)await a.dispose();if(server)await new Promise(r=>server.close(r));if(fixture)await fs.rm(fixture,{recursive:true,force:true});}); +function base(s){return {resource:s.resource,authorityEpoch:s.authorityEpoch,revision:s.revision,sourceHash:s.sourceHash,historyGeneration:s.historyGeneration};} +async function setup(source='Risk needs a decision.\n',existing,authorityEnabled=true){ + let a=existing||await ResourceAuthority.create({extensionPath:fixture,resource:'file:///fixture-'+authorities.length+'.md',authorityEpoch:'opaque-'+authorities.length,revision:1,source,historyGeneration:0,roleJson:role}); + if(!existing)authorities.push(a);const view='view-'+(++dispatch);await a.attachView(view);let requestId=0;let lastPrepared; + const page=await browser.newPage();page.setDefaultTimeout(10000); + await page.exposeFunction('nativeOperation',async(op,value)=>{ + if(op==='snapshot')return a.snapshot(); + if(op==='recover'){a=await ResourceAuthority.create({extensionPath:fixture,resource:'file:///recovered.md',authorityEpoch:'fresh-'+(++dispatch),revision:1,source:value,historyGeneration:0,roleJson:role});authorities.push(a);await a.attachView(view);requestId=0;return a.snapshot();} + if(op==='prepare'){const s=await a.snapshot(),request={...base(s),protocol:1,viewSession:view,requestId:++requestId,dispatchId:++dispatch}; + const {route,...command}=value;lastPrepared=route==='action'?await a.prepareAction(request,command):route==='history'?await a.prepareHistory(request,command):await a.prepareTrim(request,command);return lastPrepared;} + if(op==='finish'||op==='abort'){const p=lastPrepared,s=await a.snapshot(),outcome=op==='finish'?await a.finish(p.request,p.token,p.afterHash):await a.abort(p.request,p.token); + if(outcome.kind!==(op==='finish'?'committed':'guarded-aborted'))throw new Error(outcome.reason||outcome.kind); + await a.ackOutcome(p.request,outcome,op==='finish'?{...base(s),sourceHash:p.afterHash,revision:s.revision+1,historyGeneration:s.historyGeneration+1}:base(s));return a.snapshot();} + if(op==='history'){const s=await a.snapshot();return a.updateHistory(base(s),s.historyGeneration+1);} + if(op==='query')return a.query(value.name,value.args); + if(op==='control'){const result=await a.control(value);return {result,snapshot:await a.snapshot()};} + throw new Error('Unsupported actual transport operation'); + }); + await page.goto(origin+'/terraphim-editor.min.js');await page.setContent('
'); + await page.addScriptTag({url:origin+'/terraphim-editor.min.js'});await page.addScriptTag({url:origin+'/history-checkpoint.js'}); + await page.evaluate(async({source,role,authorityEnabled})=>{ + window.actual=await TeraphimEditor.create(document.querySelector('#fixture'),{loadShoelace:false,value:source,dirtyTitle:false,config:{hostManaged:true,fileSystemAccess:false}});window.editor=actual.markdownEditor; + editor.loadThesaurus(role);window.nativeSnapshot=await nativeOperation('snapshot');window.captured=null;window.drafts=[]; + if(!authorityEnabled)return; + let queue=Promise.resolve();function serial(fn){const next=queue.then(fn);queue=next.catch(()=>{});return next;} + editor.setAuthority({get roleGeneration(){return nativeSnapshot.roleGeneration;},get providerGeneration(){return nativeSnapshot.providerGeneration;},reportDraft(value){drafts.push(structuredClone(value));}, + transact(command,paint){return serial(async()=>{let prepared;try{prepared=await nativeOperation('prepare',command);}catch(error){if(/context exited/.test(error.message))error.authorityUncertain=true;throw error;}if(prepared.kind==='no-change'){window.nativeFixtureNoChangePending=true;if(window.nativeFixtureNoChangeDelay)await new Promise(resolve=>setTimeout(resolve,window.nativeFixtureNoChangeDelay));return null;} + try{const started={...prepared.result,source:prepared.presentationSource,presentationSource:prepared.presentationSource};captured=await paint(started);if(window.nativeFixturePaintFailure)throw new Error('Actual post-paint checkpoint failure');if(window.nativeFixtureDelay)await new Promise(resolve=>setTimeout(resolve,window.nativeFixtureDelay));nativeSnapshot=await nativeOperation('finish');return started;} + catch(error){try{nativeSnapshot=await nativeOperation('abort');error.authorityRolledBack=true;}catch{error.authorityUncertain=true;}throw error;}});}, + historyChanged(){return serial(async()=>{captured=TerraphimHistoryCheckpoint.capture(editor.surface,editor.documentApi().save_document(),editor.altPanel);nativeSnapshot=await nativeOperation('history');});}, + query(name,args=[]){return serial(()=>nativeOperation('query',{name,args}));},control(value){return serial(async()=>{const r=await nativeOperation('control',value);nativeSnapshot=r.snapshot;return r.result;});} + }); + },{source,role,authorityEnabled});return {page,a,view}; +} +async function state(page){return page.evaluate(()=>({body:editor.surface.getText(),source:editor.documentApi().save_document(),history:editor.surface.historyIndex,canUndo:editor.surface.canUndo(),canRedo:editor.surface.canRedo(),drafts:editor.pendingAuthorityDraft(),status:editor.authorityStatus?.textContent??''}));} +async function settle(page){await page.evaluate(()=>editor.authoritySettled());} +test('actual controller accepts text, shared Undo/Redo and complete native source', {timeout:30000},async()=>{ + const {page,a}=await setup();try{ + await page.evaluate(()=>editor.surface.replaceRange(0,4,'Concern',{source:'test'}));await settle(page); + assert.equal((await a.snapshot()).projection.body,'Concern needs a decision.\n'); + assert.equal(await page.evaluate(()=>editor.surface.undo()),true);assert.equal((await a.snapshot()).projection.body,'Risk needs a decision.\n'); + assert.equal(await page.evaluate(()=>editor.surface.redo()),true);assert.equal((await state(page)).body,(await a.snapshot()).projection.body); + }finally{await page.close();} +}); +test('actual typed alternative groups as one accepted addition and Escape preserves earlier addition', {timeout:60000},async()=>{ + const {page,a}=await setup();try{ + await page.evaluate(()=>editor.altPanel.open({start:0,end:4,kind:'word'})); + await page.locator('.te-alt-input--new').pressSequentially('Concern');await settle(page); + await page.evaluate(()=>editor.altPanel.finishLive());const savedA=(await a.snapshot()).source; + assert.ok(savedA.includes('Concern'),JSON.stringify(await page.evaluate(()=>({state:editor.altPanel.pendingDrafts(),status:editor.altPanel.statusEl.textContent,live:!!editor.altPanel.live,source:editor.documentApi().save_document(),draft:editor.pendingAuthorityDraft()}))));await page.evaluate(()=>editor.altPanel.focusLine(editor.altPanel.lines().length)); + await page.locator('.te-alt-input--new').pressSequentially('Worry');await settle(page); + await page.evaluate(()=>editor.altPanel.authorityCancelLive());await settle(page); + assert.equal((await a.snapshot()).source,savedA);assert.equal((await state(page)).canRedo,false); + await page.evaluate(()=>editor.surface.undo());assert.ok(!(await a.snapshot()).source.includes('Concern')); + await page.evaluate(()=>editor.surface.redo());assert.equal((await a.snapshot()).source,savedA); + }finally{await page.close();} +}); +test('real protected Ghost receipt survives mixed edits and detached issuer through the same resource worker', {timeout:60000},async()=>{ + const body='Risk remains. Perhaps we might basically need to possibly review the very complex arrangement because the supplier could in some ways delay the launch.\n'; + const {page,a,view}=await setup(body);let other; + try{ + await page.evaluate(()=>editor.authority.query('trim_plan_json')); + const changed=await page.evaluate(()=>editor.commitTrimGhosts('sharper',[]));assert.ok(changed); + const ghost=(await a.snapshot()).source;assert.ok((await a.snapshot()).projection.annotations.ghosts.length); + await page.evaluate(()=>editor.surface.replaceRange(editor.surface.getText().length,editor.surface.getText().length,'Next.\n',{source:'test'})); + await page.evaluate(()=>editor.surface.undo());assert.equal((await a.snapshot()).source,ghost); + const cp=await page.evaluate(()=>TerraphimHistoryCheckpoint.capture(editor.surface,editor.documentApi().save_document(),editor.altPanel)); + await a.detachView(view);other=(await setup(body,a)).page; + await other.evaluate(({source,cp})=>{actual.openDocument(source);TerraphimHistoryCheckpoint.restore(editor.surface,cp,source,editor);},{source:ghost,cp}); + assert.equal(await other.evaluate(()=>editor.surface.undo()),true);assert.equal((await a.snapshot()).projection.annotations.ghosts.length,0); + assert.equal(await other.evaluate(()=>editor.surface.redo()),true);assert.equal((await a.snapshot()).source,ghost); + }finally{await page.close();await other?.close();} +}); + +test('raw typing and Escape during delayed real acceptance preserve accepted A and cancel only B', {timeout:60000},async()=>{ + const {page,a}=await setup();try{ + await page.evaluate(()=>{editor.altPanel.open({start:0,end:4,kind:'word'});window.nativeFixtureDelay=80;}); + await page.locator('.te-alt-input--new').pressSequentially('Concern');await settle(page);await page.evaluate(()=>editor.altPanel.finishLive()); + const saved=(await a.snapshot()).source;await page.evaluate(()=>editor.altPanel.focusLine(editor.altPanel.lines().length)); + await page.locator('.te-alt-input--new').pressSequentially('Worry');await page.keyboard.press('Escape');await settle(page); + assert.equal((await a.snapshot()).source,saved);assert.equal((await state(page)).canRedo,false); + assert.equal((await state(page)).drafts.intents.length,0); + await page.evaluate(()=>editor.altPanel.open({spanId:editor.annotations().spans[0].id}));await page.evaluate(()=>editor.altPanel.focusLine(editor.altPanel.lines().length)); + await page.locator('.te-alt-input--new').pressSequentially('Hazard');await settle(page);assert.ok((await a.snapshot()).source.includes('Hazard')); + }finally{await page.close();} +}); +test('body inputs later than a real pending native acceptance are retained and accepted in order', {timeout:30000},async()=>{ + const {page,a}=await setup();try{ + await page.evaluate(()=>{window.nativeFixtureDelay=80;editor.surface.focus();editor.surface.setSelectionOffsets(editor.surface.getText().length);}); + await page.keyboard.type('abc');await settle(page); + assert.equal((await a.snapshot()).projection.body,'Risk needs a decision.\nabc');assert.equal((await state(page)).drafts.intents.length,0); + }finally{await page.close();} +}); +test('rapid Unicode body typing followed by consecutive Enter keeps every character and line break', {timeout:60000},async()=>{ + const {page,a}=await setup();try{ + await page.evaluate(()=>{window.nativeFixtureDelay=80;editor.surface.focus();editor.surface.setSelectionOffsets(0);}); + await page.keyboard.type('🙂 Public prefix.');await page.keyboard.press('Enter');await page.keyboard.press('Enter'); + await settle(page); + assert.equal((await a.snapshot()).projection.body,'🙂 Public prefix.\n\nRisk needs a decision.\n'); + assert.equal((await state(page)).drafts.intents.length,0); + }finally{await page.close();} +}); +test('replacement paste uses a backward raw selection during delayed acceptance and preserves Unicode on reopen', {timeout:60000},async()=>{ + const {page,a}=await setup();let reopened;try{ + await page.evaluate(()=>{window.nativeFixtureDelay=80;editor.surface.focus();editor.surface.setSelectionOffsets(0);}); + await page.keyboard.type('🙂 draft '); + await page.evaluate(()=>{const node=editor.surface.root.firstChild;document.getSelection().setBaseAndExtent(node,8,node,3);editor.surface.replaceSelection('Café\r\n🚀','paste');}); + await settle(page); + const expected='🙂 Café\n🚀 Risk needs a decision.\n',saved=await a.snapshot(); + assert.equal(saved.projection.body,expected);assert.equal((await state(page)).drafts.intents.length,0); + const next=await setup(saved.source);reopened=next.page;assert.equal((await next.a.snapshot()).projection.body,expected); + }finally{await page.close();await reopened?.close();} +}); +test('rapid Overflow input during real delayed native acceptance keeps the entire draft and saved source', {timeout:60000},async()=>{ + const {page,a}=await setup();try{ + const text='Public note https://terraphim.ai\n- Risk review\nRAPID-OVERFLOW-SENTINEL'; + await page.evaluate(async()=>{await editor.chrome.setMode('write-on',{persist:false});window.nativeFixtureDelay=80;if(!editor.overflow.open({focus:true}))throw new Error('Actual Overflow panel did not open');}); + await page.getByRole('textbox',{name:'Overflow text',exact:true}).pressSequentially(text,{delay:15}); + await settle(page); + assert.equal(await page.getByRole('textbox',{name:'Overflow text',exact:true}).inputValue(),text); + const saved=await a.snapshot();assert.equal(saved.projection.overflow,text); + await page.evaluate(()=>editor.surface.focus());await settle(page); + assert.equal((await a.snapshot()).source,saved.source); + }finally{await page.close();} +}); +test('retyping identical Overflow retires only the represented raw draft and remains savable', {timeout:60000},async()=>{ + const {page,a}=await setup();try{ + const text='Public note https://terraphim.ai\n- Risk review\nRAPID-OVERFLOW-SENTINEL'; + await page.evaluate(async()=>{await editor.chrome.setMode('write-on',{persist:false});editor.overflow.open({focus:true});}); + const input=page.getByRole('textbox',{name:'Overflow text',exact:true});await input.pressSequentially(text);await settle(page);const saved=(await a.snapshot()).source; + await input.press(process.platform==='darwin'?'Meta+a':'Control+a');await input.pressSequentially(text);await settle(page); + assert.equal((await a.snapshot()).source,saved);assert.equal((await state(page)).drafts.intents.length,0);assert.equal(await input.inputValue(),text); + }finally{await page.close();} +}); +test('a delayed real no-change Overflow response never clears a later raw draft', {timeout:60000},async()=>{ + const {page,a}=await setup();try{ + const text='Review supplier';await page.evaluate(async()=>{await editor.chrome.setMode('write-on',{persist:false});editor.overflow.open({focus:true});}); + const input=page.getByRole('textbox',{name:'Overflow text',exact:true});await input.pressSequentially(text);await settle(page); + await page.evaluate(()=>{window.nativeFixtureNoChangeDelay=100;window.nativeFixtureNoChangePending=false;}); + await input.press(process.platform==='darwin'?'Meta+a':'Control+a');await input.pressSequentially(text); + await page.evaluate(()=>{window.noChangeFlush=editor.overflow.flush();});await page.waitForFunction(()=>window.nativeFixtureNoChangePending); + await input.pressSequentially(' later');await page.evaluate(()=>window.noChangeFlush); + assert.equal((await state(page)).drafts.intents.find(x=>x.key==='overflow').input.text,text+' later'); + await settle(page);assert.equal((await a.snapshot()).projection.overflow,text+' later');assert.equal((await state(page)).drafts.intents.length,0); + }finally{await page.close();} +}); +test('Overflow copy and guarded stash replay through actual native actions', {timeout:30000},async()=>{ + const {page,a}=await setup();try{ + await page.evaluate(async()=>{editor.overflow.open({focus:true});editor.overflow.area.value='Review supplier';editor.overflow.schedule();await editor.overflow.flush();});await settle(page); + assert.equal((await a.snapshot()).projection.overflow,'Review supplier'); + await page.evaluate(()=>editor.overflow.pullBackAt(0,{start:0,end:6,text:'Review'}));await settle(page); + assert.equal((await a.snapshot()).projection.overflow,'Review supplier');assert.ok((await a.snapshot()).projection.body.includes('Review')); + await page.evaluate(()=>editor.surface.undo());assert.equal((await a.snapshot()).projection.body,'Risk needs a decision.\n'); + await page.evaluate(()=>editor.stashRange(0,4));assert.equal((await a.snapshot()).projection.body,' needs a decision.\n'); + await page.evaluate(()=>editor.surface.undo());assert.equal((await a.snapshot()).projection.body,'Risk needs a decision.\n');assert.equal((await a.snapshot()).projection.overflow,'Review supplier'); + }finally{await page.close();} +}); +test('actual batch, move and alternative edit/reorder/remove preserve exact replay metadata', {timeout:30000},async()=>{ + const {page,a}=await setup();try{ + await page.evaluate(()=>editor.surface.replaceRanges([{start:0,end:4,insert:'Concern'},{start:13,end:21,insert:'choice'}],{source:'test'})); + assert.equal((await a.snapshot()).projection.body,'Concern needs a choice.\n');await page.evaluate(()=>editor.surface.undo()); + await page.evaluate(()=>editor.moveRange(0,4,21));const moved=(await a.snapshot()).source;assert.ok((await a.snapshot()).projection.body.includes('Risk')); + await page.evaluate(()=>editor.surface.undo());assert.equal((await a.snapshot()).projection.body,'Risk needs a decision.\n');await page.evaluate(()=>editor.surface.redo());assert.equal((await a.snapshot()).source,moved); + await page.evaluate(()=>editor.surface.undo()); + const id=await page.evaluate(async()=>{const c=await editor.alternativeOp('create_span','word',0,4,'Concern','human');return c.span;}); + await page.evaluate(id=>editor.alternativeOp('add',id,'Worry','human'),id);await page.evaluate(id=>editor.alternativeOp('edit',id,2,'Hazard'),id); + await page.evaluate(id=>editor.alternativeOp('move',id,2,1),id);await page.evaluate(id=>editor.alternativeOp('remove',id,1),id); + await page.evaluate(()=>editor.surface.undo());assert.ok((await a.snapshot()).source.includes('Hazard')); + await page.evaluate(()=>editor.surface.redo());assert.ok(!(await a.snapshot()).source.includes('Hazard')); + }finally{await page.close();} +}); +test('actual role publication preserves issued Ghost authority and provider changes reach native owner', {timeout:30000},async()=>{ + const body='Risk remains. Perhaps we might basically need to possibly review the very complex arrangement because the supplier could in some ways delay the launch.\n'; + const {page,a}=await setup(body);try{ + await page.evaluate(()=>editor.authority.query('trim_plan_json'));await page.evaluate(()=>editor.commitTrimGhosts('sharper',[]));const saved=(await a.snapshot()).source; + await page.evaluate(()=>editor.loadThesaurus(JSON.stringify({name:'New role',data:{supplier:{id:2,nterm:'supplier'}}}))); + await page.evaluate(()=>editor.surface.undo());assert.equal((await a.snapshot()).projection.annotations.ghosts.length,0); + await page.evaluate(()=>editor.surface.redo());assert.equal((await a.snapshot()).source,saved); + await page.evaluate(()=>editor.registerAlternativesProvider({id:'actual-fixture',model:'local-test',alternatives:async()=>[]})); + assert.ok((await a.query('alternatives_provider_ids')).includes('actual-fixture')); + await page.evaluate(()=>editor.unregisterAlternativesProvider('actual-fixture'));assert.ok(!(await a.query('alternatives_provider_ids')).includes('actual-fixture')); + }finally{await page.close();} +}); + +test('Enter during real delayed acceptance separates the next addition without losing raw typing', {timeout:60000},async()=>{ + const {page,a}=await setup();try{ + await page.evaluate(()=>{editor.altPanel.open({start:0,end:4,kind:'word'});window.nativeFixtureDelay=80;}); + await page.locator('.te-alt-input--new').pressSequentially('Concern');await page.keyboard.press('Enter');await page.keyboard.type('Worry');await settle(page); + const spans=(await a.snapshot()).projection.annotations.spans;assert.deepEqual(spans[0].alts.map(x=>x.text),['Risk','Concern','Worry']); + await page.keyboard.press('Escape');await settle(page);assert.deepEqual((await a.snapshot()).projection.annotations.spans[0].alts.map(x=>x.text),['Risk','Concern']); + }finally{await page.close();} +}); + +test('real guarded abort restores tentative Surface/history without claiming acceptance', {timeout:30000},async()=>{ + const {page,a}=await setup();try{ + const before=await a.snapshot();const prior=await state(page);await page.evaluate(()=>window.nativeFixturePaintFailure=true); + await assert.rejects(page.evaluate(()=>editor.surface.replaceRange(0,4,'Hazard',{source:'test'})),/post-paint/); + assert.equal((await a.snapshot()).source,before.source);assert.equal((await state(page)).source,prior.source);assert.equal((await state(page)).history,prior.history); + await page.evaluate(()=>window.nativeFixturePaintFailure=false);await page.evaluate(()=>editor.surface.replaceRange(0,4,'Concern',{source:'test'}));assert.equal((await a.snapshot()).projection.body,'Concern needs a decision.\n'); + }finally{await page.close();} +}); +test('actual worker crash blocks mutations until explicit fresh-authority recovery resets shared controller', {timeout:30000},async()=>{ + const {page,a}=await setup();try{ + await page.evaluate(()=>editor.surface.replaceRange(0,4,'Concern',{source:'test'}));const saved=(await a.snapshot()).source; + await a.worker.terminate();await assert.rejects(page.evaluate(()=>editor.surface.replaceRange(0,7,'Hazard',{source:'test'})),/context exited/); + await assert.rejects(settle(page),/context exited/); + await page.evaluate(async source=>{nativeSnapshot=await nativeOperation('recover',source);actual.openDocument(nativeSnapshot.presentationSource);editor.surface.resetHistory();editor.resetAuthorityAfterRecovery();},saved); + await settle(page);assert.equal((await state(page)).canUndo,false); + await page.evaluate(()=>editor.surface.replaceRange(0,7,'Hazard',{source:'test'}));assert.equal((await state(page)).body,'Hazard needs a decision.\n'); + }finally{await page.close();} +}); +test('real Trim preview and Done controls query native authority, then manual Ghost and revive remain native', {timeout:30000},async()=>{ + const body='Risk remains. Perhaps we might basically need to possibly review the very complex arrangement because the supplier could in some ways delay the launch.\n'; + const {page,a}=await setup(body);try{ + const status=await page.evaluate(()=>editor.lab.trim.select('sharper'));assert.ok(status?.kgProtected,JSON.stringify(await page.evaluate(()=>editor.lab.trim.protectionNote.textContent))); + assert.equal(await page.evaluate(()=>editor.lab.trim.done()),true);assert.ok((await a.snapshot()).projection.annotations.ghosts.length); + await page.evaluate(()=>editor.surface.undo());assert.equal((await a.snapshot()).projection.annotations.ghosts.length,0); + await page.evaluate(()=>editor.ghosts.change('ghost',0,4));assert.equal((await a.snapshot()).projection.annotations.ghosts.length,1); + await page.evaluate(()=>editor.ghosts.change('revive',0,4));assert.equal((await a.snapshot()).projection.annotations.ghosts.length,0); + }finally{await page.close();} +}); + +test('actual Lab acceptance waits for native finish and failed checkpoint retains proposal', {timeout:30000},async()=>{ + const {page,a}=await setup('We recieved the report.\n');try{ + const mark=await page.evaluate(()=>{editor.lab.run('typos_and_punctuation');return editor.lab.marks().find(m=>m.data.proposal);});assert.ok(mark); + const before=(await a.snapshot()).source;await page.evaluate(()=>window.nativeFixturePaintFailure=true); + assert.equal(await page.evaluate(id=>editor.lab.accept(id),mark.id),false);assert.equal((await a.snapshot()).source,before); + assert.ok(await page.evaluate(id=>editor.lab.marks().some(m=>m.id===id),mark.id)); + await page.evaluate(()=>window.nativeFixturePaintFailure=false);assert.equal(await page.evaluate(id=>editor.lab.accept(id),mark.id),true); + assert.equal((await a.snapshot()).projection.body,'We received the report.\n'); + }finally{await page.close();} +}); +module.exports={setup,state,settle}; +test('actual Blocks commit and view change wait for delayed native acceptance', {timeout:30000},async()=>{ + const {page,a}=await setup('First block.\n\nSecond block.\n');try{ + await page.evaluate(async()=>{await editor.chrome.setMode('plain');await editor.blocks.setView('blocks',{persist:false});await editor.blocks.editBlock(0);window.nativeFixtureDelay=80;}); + await page.locator('.te-block-editor').fill('Changed first.'); + const result=await page.evaluate(async()=>{const committed=editor.blocks.commitEdit();const switched=editor.blocks.setView('text',{persist:false});const selection=await committed;await switched;return selection;}); + await settle(page);assert.equal((await a.snapshot()).projection.body,'Changed first.\n\nSecond block.\n');assert.ok(Number.isFinite(result.start)); + assert.equal((await state(page)).drafts.intents.length,0); + }finally{await page.close();} +}); +test('actual Blocks failed native checkpoint retains open textarea and accepts explicit retry', {timeout:30000},async()=>{ + const {page,a}=await setup('First block.\n\nSecond block.\n');try{ + await page.evaluate(async()=>{await editor.chrome.setMode('plain');await editor.blocks.setView('blocks',{persist:false});await editor.blocks.editBlock(0);}); + await page.locator('.te-block-editor').fill('Changed first.');const before=(await a.snapshot()).source;const prior=await state(page); + await page.evaluate(()=>window.nativeFixturePaintFailure=true);await assert.rejects(page.evaluate(()=>editor.blocks.commitEdit()),/post-paint/); + assert.equal((await a.snapshot()).source,before);assert.equal((await state(page)).history,prior.history);assert.equal(await page.locator('.te-block-editor').inputValue(),'Changed first.'); + await page.evaluate(()=>window.nativeFixturePaintFailure=false);await page.evaluate(()=>editor.blocks.commitEdit());await settle(page); + assert.equal((await a.snapshot()).projection.body,'Changed first.\n\nSecond block.\n'); + }finally{await page.close();} +}); +test('actual Blocks structural move remains one Undo step and retained draft retires only on acceptance', {timeout:30000},async()=>{ + const {page,a}=await setup('First block.\n\nSecond block.\n');try{ + await page.evaluate(async()=>{await editor.chrome.setMode('plain');await editor.blocks.setView('blocks',{persist:false});}); + const initial=(await a.snapshot()).source;assert.equal(await page.evaluate(()=>editor.blocks.moveBlock(0,'down')),true);const moved=(await a.snapshot()).source;assert.notEqual(moved,initial); + await page.evaluate(()=>editor.blocks.undo());assert.equal((await a.snapshot()).source,initial);await page.evaluate(()=>editor.blocks.redo());assert.equal((await a.snapshot()).source,moved); + await page.evaluate(()=>editor.blocks.editBlock(0));await page.locator('.te-block-editor').fill('Kept draft.');const id=await page.evaluate(()=>editor.blocks.cancelEdit().id); + await page.evaluate(()=>window.nativeFixturePaintFailure=true);await assert.rejects(page.evaluate(id=>editor.blocks.applyDraft(id),id),/post-paint/); + assert.equal(await page.evaluate(id=>editor.blocks.drafts.some(d=>d.id===id),id),true); + await page.evaluate(()=>window.nativeFixturePaintFailure=false);await page.evaluate(id=>editor.blocks.applyDraft(id),id);await settle(page); + assert.equal(await page.evaluate(id=>editor.blocks.drafts.some(d=>d.id===id),id),false);assert.ok((await a.snapshot()).projection.body.includes('Kept draft.')); + }finally{await page.close();} +}); + +test('standalone editor retains synchronous native mutations and whole-addition history', {timeout:30000},async()=>{ + const {page}=await setup('Risk needs a decision.\n',undefined,false);try{ + const native=await page.evaluate(()=>{const edit=editor.surface.replaceRange(0,4,'Concern');const undo=editor.surface.undo();return {promise:!!edit?.then,undo,body:editor.documentApi().document_body()};}); + assert.deepEqual(native,{promise:false,undo:true,body:'Risk needs a decision.\n'}); + await page.evaluate(()=>editor.altPanel.open({start:0,end:4,kind:'word'}));await page.locator('.te-alt-input--new').pressSequentially('Concern'); + await page.evaluate(()=>editor.altPanel.finishLive());assert.ok((await state(page)).source.includes('Concern')); + assert.equal(await page.evaluate(()=>editor.surface.undo()),true);assert.ok(!(await state(page)).source.includes('Concern')); + }finally{await page.close();} +}); + +test('native duplicate live edit stays a recoverable raw draft until Escape cancels B', {timeout:30000},async()=>{ + const {page,a}=await setup();try{ + await page.evaluate(()=>editor.altPanel.open({start:0,end:4,kind:'word'}));await page.locator('.te-alt-input--new').pressSequentially('Concern');await settle(page);await page.evaluate(()=>editor.altPanel.finishLive());const saved=(await a.snapshot()).source; + await page.evaluate(()=>editor.altPanel.focusLine(editor.altPanel.lines().length));await page.locator('.te-alt-input--new').pressSequentially('Worry');await settle(page); + await page.locator('.te-alt-line[data-index="2"] input').fill('Concern');await page.evaluate(()=>editor.altPanel.authorityLineQueue); + await assert.rejects(settle(page),/Unaccepted raw input/);assert.ok((await a.snapshot()).source.includes('Worry')); + await page.keyboard.press('Enter');assert.ok((await state(page)).drafts.intents.some(d=>d.input.text==='Concern')); + await page.keyboard.press('Escape');await settle(page);assert.equal((await a.snapshot()).source,saved);assert.equal((await state(page)).canRedo,false); + }finally{await page.close();} +}); +test('duplicate new alternative records no phantom history and Escape discards only refused raw input', {timeout:30000},async()=>{ + const {page,a}=await setup();try{ + await page.evaluate(()=>editor.altPanel.open({start:0,end:4,kind:'word'}));await page.locator('.te-alt-input--new').pressSequentially('Concern');await settle(page);await page.evaluate(()=>editor.altPanel.finishLive()); + const saved=(await a.snapshot()).source,prior=(await state(page)).history;await page.evaluate(()=>editor.altPanel.focusLine(editor.altPanel.lines().length)); + await page.locator('.te-alt-input--new').fill('Concern');await page.evaluate(()=>editor.altPanel.authorityLineQueue); + assert.equal((await a.snapshot()).source,saved);assert.equal((await state(page)).history,prior); + await page.keyboard.press('Escape');await settle(page);assert.equal((await a.snapshot()).source,saved);assert.equal((await state(page)).drafts.intents.length,0); + }finally{await page.close();} +}); + +test('actual Write On switch retains Blocks textarea and plain mode until native commit accepts', {timeout:30000},async()=>{ + const {page,a}=await setup('First block.\n\nSecond block.\n');try{ + await page.evaluate(async()=>{await editor.chrome.setMode('plain');await editor.blocks.setView('blocks',{persist:false});await editor.blocks.editBlock(0);window.nativeFixtureDelay=120;}); + await page.locator('.te-block-editor').fill('Changed first.'); + await page.evaluate(()=>{window.modeSwitch=editor.chrome.setMode('write-on',{persist:false});}); + assert.deepEqual(await page.evaluate(()=>({mode:editor.chrome.mode,editing:!!editor.blocks.editing,connected:!!document.querySelector('.te-block-editor')})),{mode:'plain',editing:true,connected:true}); + await page.evaluate(()=>window.modeSwitch);await settle(page);assert.equal((await a.snapshot()).projection.body,'Changed first.\n\nSecond block.\n');assert.equal(await page.evaluate(()=>editor.chrome.mode),'write-on'); + }finally{await page.close();} +}); +test('actual Write On switch refuses failed native commit without hiding Blocks textarea', {timeout:30000},async()=>{ + const {page,a}=await setup('First block.\n\nSecond block.\n');try{ + await page.evaluate(async()=>{await editor.chrome.setMode('plain');await editor.blocks.setView('blocks',{persist:false});await editor.blocks.editBlock(0);}); + await page.locator('.te-block-editor').fill('Changed first.');const before=(await a.snapshot()).source; + await page.evaluate(()=>window.nativeFixturePaintFailure=true);await assert.rejects(page.evaluate(()=>editor.chrome.setMode('write-on',{persist:false})),/post-paint/); + assert.equal((await a.snapshot()).source,before);assert.equal(await page.evaluate(()=>editor.chrome.mode),'plain');assert.equal(await page.locator('.te-block-editor').inputValue(),'Changed first.'); + await page.evaluate(()=>window.nativeFixturePaintFailure=false);await page.evaluate(()=>editor.chrome.setMode('write-on',{persist:false}));assert.equal(await page.evaluate(()=>editor.chrome.mode),'write-on'); + }finally{await page.close();} +}); +test('real graph Worker prepared-role publication commits through the native authority coordinator', {timeout:30000},async()=>{ + const {page,a}=await setup();try{ + await page.evaluate(()=>editor.rolegraph.loadRole(JSON.stringify({name:'Graph coordinated',data:{concern:{id:7,nterm:'concern',display_value:'Concern',pinned:false}}}))); + const raw=await a.query('kg_role_snapshot_json');const roleState=typeof raw==='string'?JSON.parse(raw):raw;assert.equal(roleState.loaded,true);assert.ok(roleState.thesaurusText.includes('concern')); + const local=await page.evaluate(()=>JSON.parse(editor.documentApi().kg_role_snapshot_json()));assert.equal(local.thesaurusText,roleState.thesaurusText); + }finally{await page.close();} +}); +test('authored alternative exports real typed KG synonyms and Undo removes actual graph registration', {timeout:30000},async()=>{ + const {page,a}=await setup();try{ + const id=await page.evaluate(async()=>{const c=await editor.alternativeOp('create_span','word',0,4,'Concern','human');return c.span;}); + const graph=JSON.parse(await a.query('export_knowledge_graph'));assert.ok(graph.data.risk);assert.ok(graph.data.concern);assert.equal(graph.data.risk.id,graph.data.concern.id); + const rawSynonyms=await a.query('document_synonyms',[id,'Concern']);const synonyms=typeof rawSynonyms==='string'?JSON.parse(rawSynonyms):rawSynonyms;assert.ok(Array.from(synonyms).some(term=>String(term).toLowerCase()==='risk')); + await page.evaluate(()=>editor.surface.undo());assert.equal(Object.keys(JSON.parse(await a.query('export_knowledge_graph')).data).length,0); + await page.evaluate(()=>editor.surface.redo());assert.deepEqual(JSON.parse(await a.query('export_knowledge_graph')),graph); + }finally{await page.close();} +}); diff --git a/tests/browser/nested-build-imports.cjs b/tests/browser/nested-build-imports.cjs new file mode 100644 index 0000000..4368f7c --- /dev/null +++ b/tests/browser/nested-build-imports.cjs @@ -0,0 +1,101 @@ +// Parse-only AST extraction using existing Acorn/Terser tooling. Never execute +// input JavaScript, install dependencies, or fall back to lexical import regexes. +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const REQUIRED_ACORN_VERSION = '8.14.0'; + +function checkParserIdentity(parser) { + if (parser.version !== REQUIRED_ACORN_VERSION) { + const error = new Error('parser-version-mismatch'); + error.code = 'parser-version-mismatch'; + // Report only a bounded version identifier, never arbitrary module data. + error.observed = typeof parser.version === 'string' && + /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(parser.version) && parser.version.length <= 64 + ? parser.version : 'unknown'; + throw error; + } +} + +function existingAcorn() { + try { return require('acorn'); } catch (_) { /* Try installed build tooling. */ } + for (const directory of (process.env.PATH || '').split(path.delimiter)) { + if (!directory) continue; + const executable = path.join(directory, 'terser'); + try { + const real = fs.realpathSync(executable); + try { return createRequire(real)('acorn'); } catch (_) { /* A pnpm shell shim is not the package entry. */ } + if (fs.statSync(real).size > 65536) continue; + // Resolve only the literal Terser package target in its installed pnpm + // shim; never execute/eval the shim or treat it as application JS. + const shim = fs.readFileSync(real, 'utf8'); + const target = shim.match(/\$basedir\/([^"\r\n]+\/terser\/bin\/terser)/); + if (target) { + const entry = fs.realpathSync(path.resolve(path.dirname(real), target[1])); + return createRequire(entry)('acorn'); + } + } catch (_) { /* No install/download or guessed lexical fallback. */ } + } + throw new Error('parser-unavailable'); +} + +function literal(node) { + if (node && node.type === 'Literal' && typeof node.value === 'string') return node.value; + if (node && node.type === 'TemplateLiteral' && node.expressions.length === 0) { + return node.quasis[0].value.cooked; + } + return null; +} + +function extract(acorn, source) { + const ast = acorn.parse(source, { + ecmaVersion: 'latest', sourceType: 'module', allowAwaitOutsideFunction: true, + }); + const modules = [], wasm = [], pending = [ast]; + while (pending.length) { + const node = pending.pop(); + if (['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration', 'ImportExpression'].includes(node.type)) { + const value = literal(node.source); + if (value !== null) modules.push(value); + } + if (node.type === 'Property' && !node.computed && + (node.key.type === 'Identifier' ? node.key.name : literal(node.key)) === 'module_or_path') { + const value = literal(node.value); + if (value !== null) wasm.push(value); + } + for (const child of Object.values(node)) { + if (Array.isArray(child)) { + for (const item of child) if (item && typeof item === 'object') pending.push(item); + } else if (child && typeof child === 'object') pending.push(child); + } + } + return { modules, wasm }; +} + +try { + const chunks = [], buffer = Buffer.alloc(65536); + let total = 0, count; + while ((count = fs.readSync(0, buffer, 0, buffer.length, null)) > 0) { + total += count; + if (total > 32 * 1024 * 1024) throw new Error('input-limit'); + chunks.push(Buffer.from(buffer.subarray(0, count))); + } + const input = Buffer.concat(chunks).toString('utf8'); + const request = JSON.parse(input); + if (!Array.isArray(request.sources) || request.sources.some(source => typeof source !== 'string')) { + throw new Error('invalid-input'); + } + const acorn = existingAcorn(); + checkParserIdentity(acorn); + process.stdout.write(JSON.stringify({ parser: 'acorn', version: acorn.version, + references: request.sources.map(source => extract(acorn, source)) })); +} catch (error) { + // No source text, environment or package paths in diagnostics. + if (error.code === 'parser-version-mismatch') { + process.stderr.write(`AST extraction FAIL: parser-version-mismatch required=${REQUIRED_ACORN_VERSION} observed=${error.observed}; no install, upgrade or lexical fallback\n`); + } else { + process.stderr.write('AST extraction FAIL: parser unavailable, invalid input or JavaScript syntax; no lexical fallback\n'); + } + process.exitCode = 1; +} diff --git a/tests/browser/nested_build_paths_test.py b/tests/browser/nested_build_paths_test.py new file mode 100644 index 0000000..3ab694b --- /dev/null +++ b/tests/browser/nested_build_paths_test.py @@ -0,0 +1,274 @@ +"""Static generated-delivery contract; not browser or WASM execution evidence. + +Run units normally; use --check DISTRIBUTION after a real isolated Trunk build. +""" +import argparse +from html.parser import HTMLParser +import json +from pathlib import Path +import re +import subprocess +import tempfile +import unittest +from unittest import mock +from urllib.parse import urlsplit + +ROOT = Path(__file__).resolve().parents[2] +CSS_URL = re.compile(r'''url\(\s*["']?([^\)"']+)["']?\s*\)''') + + +def ast_references(sources): + if not sources: + return [] + try: + result = subprocess.run(['node', str(ROOT / 'tests/browser/nested-build-imports.cjs')], + input=json.dumps({'sources': sources}), text=True, + capture_output=True, timeout=20, check=True) + parsed = json.loads(result.stdout) + if parsed.get('parser') != 'acorn' or parsed.get('version') != '8.14.0' or len(parsed['references']) != len(sources): + raise ValueError('invalid AST helper result') + return parsed['references'] + except subprocess.CalledProcessError as error: + mismatch = re.search(r'^AST extraction FAIL: parser-version-mismatch required=8\.14\.0 observed=([a-zA-Z0-9.-]{1,64});', + error.stderr or '', re.M) + if mismatch: + raise ValueError(f'AST parser-version-mismatch required=8.14.0 observed={mismatch.group(1)}; no lexical fallback') from error + raise ValueError('AST extraction unavailable/failed; no lexical fallback') from error + except (OSError, subprocess.SubprocessError, ValueError, KeyError) as error: + raise ValueError('AST extraction unavailable/failed; no lexical fallback') from error + + +class Resources(HTMLParser): + def __init__(self): + super().__init__() + self.references = [] + self.scripts = [] + self.script = None + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == 'base': + raise ValueError('base tag is not the nested-delivery repair') + if tag == 'link' and set(attrs.get('rel', '').split()) & {'stylesheet', 'preload', 'modulepreload'}: + if attrs.get('href'): + self.references.append(attrs['href']) + if tag == 'script': + if attrs.get('src'): + self.references.append(attrs['src']) + self.script = [] + + def handle_data(self, data): + if self.script is not None: + self.script.append(data) + + def handle_endtag(self, tag): + if tag == 'script' and self.script is not None: + self.scripts.append(''.join(self.script)) + self.script = None + + +def html_references(html): + parser = Resources() + parser.feed(html) + references = list(parser.references) + for parsed in ast_references(parser.scripts): + references.extend(parsed['modules']) + references.extend(parsed['wasm']) + return references + + +def resolve_local(root, importer, reference): + parsed = urlsplit(reference) + if parsed.scheme or parsed.netloc or not parsed.path or parsed.path.startswith('/') or '%' in parsed.path or '\\' in parsed.path: + raise ValueError(f'non-relative generated runtime reference: {reference}') + target = (importer.parent / parsed.path).resolve() + if not target.is_relative_to(root.resolve()) or not target.is_file() or target.is_symlink(): + raise ValueError(f'missing/escaping generated runtime reference: {reference}') + return target + + +def check_delivery(root): + if root.is_symlink() or any(path.is_symlink() for path in root.rglob('*')): + raise ValueError('symlinked delivery is not accepted') + root = root.resolve() + index = root / 'index.html' + if not index.is_file(): + raise ValueError('built index.html missing') + references = html_references(index.read_text()) + if not any(urlsplit(ref).path.endswith('.wasm') for ref in references): + raise ValueError('generated WASM loader/preload reference missing') + if not any(urlsplit(ref).path.endswith('.css') for ref in references): + raise ValueError('generated stylesheet reference missing') + visited = set() + pending = [resolve_local(root, index, reference) for reference in references] + while pending: + path = pending.pop() + if path in visited: + continue + visited.add(path) + if path.suffix == '.js': + children = ast_references([path.read_text()])[0]['modules'] + elif path.suffix == '.css': + text = path.read_text() + if re.search(r'@import\b', text): + raise ValueError('unexpected CSS import requires an explicit closure rule') + children = [ref for ref in CSS_URL.findall(text) if not ref.startswith('data:')] + else: + children = [] + pending.extend(resolve_local(root, path, reference) for reference in children) + return len(visited) + + +class NestedDeliveryContract(unittest.TestCase): + def test_build_config_explicit_relative_public_url(self): + config = (ROOT / 'Trunk.toml').read_text() + build = re.search(r'^\[build\]\s*\n(.*?)(?=^\[|\Z)', config, re.M | re.S).group(1) + self.assertRegex(build, r'''(?m)^public_url\s*=\s*(["'])\./\1\s*(?:#.*)?$''') + + def test_ordinary_from_variables_strings_and_comments_are_not_imports(self): + html = '''''' + self.assertEqual(html_references(html), []) + + def test_ast_static_import_export_dynamic_and_wasm_literals(self): + source = '''import { + value +} from './multi-line.js'; +export {value} from './named.js'; export * from './all.js'; +import('./literal.js'); import(`./template.js`); import(computedURL); +const options = {module_or_path: './actual.wasm'}; +const phrase = "module_or_path: './not-actual.wasm'"; +''' + parsed = ast_references([source])[0] + self.assertEqual(set(parsed['modules']), {'./multi-line.js', './named.js', './all.js', './literal.js', './template.js'}) + self.assertEqual(parsed['wasm'], ['./actual.wasm']) + + def test_ast_parser_failure_is_explicit_not_a_lexical_fallback(self): + with self.assertRaisesRegex(ValueError, 'no lexical fallback'): + ast_references(['import malformed syntax !!!']) + + def test_mismatched_parser_version_fails_before_parse(self): + # Confined VM loader double: does not override real tooling or expose a + # production parser-selection option. Exercise the actual helper main. + script = r''' +const fs = require('node:fs'), vm = require('node:vm'), assert = require('node:assert/strict'); +let parsed = false, output = '', error = ''; +const processDouble = {env: process.env, exitCode: 0, + stdout: {write(value) {output += value;}}, stderr: {write(value) {error += value;}}}; +const parserDouble = {version:'8.99.0', parse() {parsed = true; return {type:'Program',body:[]};}}; +vm.runInNewContext(fs.readFileSync(process.argv[1], 'utf8'), { + require(name) {return name === 'acorn' ? parserDouble : require(name);}, + process:processDouble, Buffer, +}); +assert.equal(parsed, false, 'mismatched parser must never parse'); +assert.equal(processDouble.exitCode, 1); +assert.equal(output, ''); +assert.match(error, /parser-version-mismatch/); +assert.match(error, /required=8\.14\.0/); +assert.match(error, /observed=8\.99\.0/); +''' + subprocess.run(['node', '-e', script, str(ROOT / 'tests/browser/nested-build-imports.cjs')], + input=json.dumps({'sources': ['const fixture = 1;']}), text=True, + capture_output=True, timeout=20, check=True) + + def test_actual_parser_reports_required_identity(self): + result = subprocess.run(['node', str(ROOT / 'tests/browser/nested-build-imports.cjs')], + input=json.dumps({'sources': []}), text=True, + capture_output=True, timeout=20, check=True) + parsed = json.loads(result.stdout) + self.assertEqual(parsed['parser'], 'acorn') + self.assertEqual(parsed['version'], '8.14.0') + self.assertEqual(parsed['references'], []) + + def test_wrapper_preserves_only_sanitized_version_mismatch_diagnostic(self): + error = subprocess.CalledProcessError(1, ['synthetic-helper'], stderr= + 'AST extraction FAIL: parser-version-mismatch required=8.14.0 observed=8.99.0; no install\nprivate/path\n') + with mock.patch.object(subprocess, 'run', side_effect=error): + with self.assertRaisesRegex(ValueError, 'required=8.14.0 observed=8.99.0') as caught: + ast_references(['const fixture = 1;']) + self.assertNotIn('private/path', str(caught.exception)) + + def test_literal_dynamic_root_and_remote_imports_still_fail(self): + for reference in ('/outside/module.js', 'https://remote.invalid/module.js'): + with self.subTest(reference=reference), self.temp() as temp: + root = self.synthetic(temp) + entry = root / 'editor-abcd.js' + entry.write_text(entry.read_text() + f'\nimport({json.dumps(reference)});') + with self.assertRaisesRegex(ValueError, 'non-relative'): + check_delivery(root) + + def test_actual_prehistory_has_observed_root_absolute_defect(self): + baseline = ROOT / 'target/alternative-control-prehistory-check' + if not (baseline / 'index.html').is_file(): + self.skipTest('immutable prehistory output unavailable; synthetic regression controls still run') + references = html_references((baseline / 'index.html').read_text()) + self.assertTrue(any(re.match(r'/tokens-[a-f0-9]+\.css$', ref) for ref in references)) + self.assertTrue(any(re.match(r'/terraphim-editor-[a-f0-9]+_bg\.wasm$', ref) for ref in references)) + with self.assertRaisesRegex(ValueError, 'non-relative'): + check_delivery(baseline) + + def synthetic(self, directory): + root = Path(directory) + (root / 'index.html').write_text(''' + + + +not a resource +''') + (root / 'tokens-abcd.css').write_text('body { color: black; }') + (root / 'editor-abcd.js').write_text("import './snippets/bridge.js'; export default function init() {}") + (root / 'editor-abcd_bg.wasm').write_bytes(b'synthetic WASM fixture, not executed') + (root / 'snippets').mkdir() + (root / 'snippets/bridge.js').write_text('export const fixture = true;') + return root + + def temp(self): + scratch = ROOT / 'target/nested-path-unit' + scratch.mkdir(parents=True, exist_ok=True) + return tempfile.TemporaryDirectory(dir=scratch) + + def test_relative_synthetic_output_and_module_closure(self): + with self.temp() as temp: + self.assertEqual(check_delivery(self.synthetic(temp)), 4) + + def test_inline_module_and_wasm_root_paths_fail_even_with_local_preloads(self): + for old, new in [("from './editor-abcd.js'", "from '/editor-abcd.js'"), + ("module_or_path:'./editor-abcd_bg.wasm'", "module_or_path:'/editor-abcd_bg.wasm'")]: + with self.subTest(reference=new), self.temp() as temp: + root = self.synthetic(temp) + index = root / 'index.html' + index.write_text(index.read_text().replace(old, new)) + with self.assertRaisesRegex(ValueError, 'non-relative'): + check_delivery(root) + + def test_missing_transitive_module_and_external_css_url_fail(self): + with self.temp() as temp: + root = self.synthetic(temp) + (root / 'snippets/bridge.js').unlink() + with self.assertRaisesRegex(ValueError, 'missing'): + check_delivery(root) + with self.temp() as temp: + root = self.synthetic(temp) + (root / 'tokens-abcd.css').write_text('body {background:url(https://remote.invalid/icon.svg)}') + with self.assertRaisesRegex(ValueError, 'non-relative'): + check_delivery(root) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--check', type=Path) + args, rest = parser.parse_known_args() + if args.check: + try: + print(f'nested-delivery: PASS, {check_delivery(args.check)} reachable relative assets') + except (ValueError, OSError) as error: + raise SystemExit(f'nested-delivery: FAIL: {error}') + else: + unittest.main(argv=[__file__, *rest]) diff --git a/tests/browser/offline_icon_readiness_test.py b/tests/browser/offline_icon_readiness_test.py new file mode 100644 index 0000000..749fdcb --- /dev/null +++ b/tests/browser/offline_icon_readiness_test.py @@ -0,0 +1,93 @@ +"""Symbolic event-order unit tests, NOT real browser qualification. + +The actual oracle is executed by Node with deterministic clock/DOM doubles. +Real installed-Chrome evidence is recorded separately in the verification note. +""" +import importlib.util +import json +from pathlib import Path +import subprocess +import unittest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('offline_icons_unit', ROOT / 'scripts/ci/editor-offline-check.py') +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) + +HARNESS = r''' +const vm = require('node:vm'); +const {source, scenario} = JSON.parse(require('node:fs').readFileSync(0, 'utf8')); +let clock = 0, id = 0; const tasks = new Map(); +const setTimeout = (fn, delay) => { tasks.set(++id, {fn, at:clock + delay}); return id; }; +const clearTimeout = id => tasks.delete(id); +class SVGSVGElement { + namespaceURI = 'http://www.w3.org/2000/svg'; + querySelector() { return {}; } + getBoundingClientRect() { return {width:16, height:16}; } +} +const listeners = new Map(); let svg = null; +const icon = {name:'type-bold', shadowRoot:{querySelector:() => svg}, + addEventListener:(name, fn) => listeners.set(name, fn), + removeEventListener:(name, fn) => { if (listeners.get(name) === fn) listeners.delete(name); }}; +const emit = name => listeners.get(name)?.(); +const ready = vm.runInNewContext(source, {setTimeout, clearTimeout, SVGSVGElement, Error}); +let outcome = 'pending'; +ready(icon, () => { + if (scenario === 'cached') { emit('sl-load'); setTimeout(() => svg = new SVGSVGElement(), 1); } + if (scenario === 'cold') setTimeout(() => { emit('sl-load'); setTimeout(() => svg = new SVGSVGElement(), 1); }, 100); + if (scenario === 'error') emit('sl-error'); + if (scenario === 'zero') { svg = new SVGSVGElement(); svg.getBoundingClientRect = () => ({width:0,height:16}); emit('sl-load'); } + if (scenario === 'no-event') svg = new SVGSVGElement(); + if (scenario === 'no-dom') emit('sl-load'); + if (scenario === 'wrong-type') { svg = {namespaceURI:'http://www.w3.org/2000/svg', querySelector:()=>({}), getBoundingClientRect:()=>({width:16,height:16})}; emit('sl-load'); } + if (scenario === 'mount-throws') throw new Error('mount failed'); +}).then(() => outcome = 'ready', e => outcome = e.message); +(async () => { + await Promise.resolve(); + while (outcome === 'pending' && tasks.size) { + const [key, task] = [...tasks].sort((a,b) => a[1].at-b[1].at)[0]; + tasks.delete(key); clock = task.at; task.fn(); await Promise.resolve(); + } + console.log(JSON.stringify({outcome, clock, listeners:listeners.size, timers:tasks.size})); +})(); +''' + + +class IconReadiness(unittest.TestCase): + def check_case(self, scenario, expected): + result = subprocess.run(['node', '-e', HARNESS], input=json.dumps({ + 'source': runner.ICON_READINESS, 'scenario': scenario}), text=True, + capture_output=True, check=True) + actual = json.loads(result.stdout) + self.assertEqual(actual['outcome'], expected) + self.assertEqual(actual['listeners'], 0) + self.assertEqual(actual['timers'], 0) + return actual + + def test_cached_event_before_render(self): + self.assertEqual(self.check_case('cached', 'ready')['clock'], 16) + + def test_cold_async_load_before_render(self): + self.assertLess(self.check_case('cold', 'ready')['clock'], 10000) + + def test_error_is_distinct_and_cleans_up(self): + self.check_case('error', 'icon failed: type-bold') + + def test_event_without_render_cannot_pass(self): + self.assertEqual(self.check_case('no-dom', 'icon timeout: type-bold')['clock'], 10000) + + def test_dom_without_event_cannot_pass(self): + self.check_case('no-event', 'icon timeout: type-bold') + + def test_zero_geometry_cannot_pass(self): + self.check_case('zero', 'icon timeout: type-bold') + + def test_non_svg_element_cannot_pass(self): + self.check_case('wrong-type', 'icon timeout: type-bold') + + def test_mount_exception_cleans_up(self): + self.check_case('mount-throws', 'mount failed') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/browser/offline_runner_engine_test.py b/tests/browser/offline_runner_engine_test.py new file mode 100644 index 0000000..5bb9ce2 --- /dev/null +++ b/tests/browser/offline_runner_engine_test.py @@ -0,0 +1,184 @@ +"""Engine-selection/report unit doubles only; no real browser qualification.""" +import importlib.util +import json +import hashlib +from pathlib import Path +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('offline_engine_unit', ROOT / 'scripts/ci/editor-offline-check.py') +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) + + +class BrowserTypeDouble: + def __init__(self): + self.calls = [] + self.browser = object() + + def launch(self, **options): + self.calls.append(options) + return self.browser + + +class PlaywrightDouble: + def __init__(self): + self.chromium = BrowserTypeDouble() + self.firefox = BrowserTypeDouble() + + +class EngineSelection(unittest.TestCase): + def executable_fixture(self, temp): + path = Path(temp) / 'firefox' + path.write_bytes(b'symbolic unit executable, not a browser') + path.chmod(0o700) + return path, hashlib.sha256(path.read_bytes()).hexdigest() + + def test_explicit_firefox_launch_and_identity(self): + with tempfile.TemporaryDirectory(dir=ROOT / 'target') as temp: + path, digest = self.executable_fixture(temp) + executable = runner.validate_executable('firefox', path, digest) + playwright = PlaywrightDouble() + self.assertIs(runner.launch_browser(playwright, 'firefox', executable), playwright.firefox.browser) + self.assertEqual(playwright.firefox.calls, [{'headless': True, 'executable_path': str(path)}]) + class Browser: + version = 'symbolic-nightly-version' + class Page: + def evaluate(self, _): + return 'symbolic-UA' + identity = runner.browser_metadata(Browser(), Page(), 'firefox', executable) + self.assertEqual(identity['browserChannel'], 'explicit-executable') + self.assertEqual(identity['executablePath'], str(path)) + self.assertEqual(identity['executableSha256'], digest) + self.assertEqual(identity['browserVersion'], Browser.version) + + def test_override_requires_firefox_absolute_existing_executable_and_checksum(self): + with tempfile.TemporaryDirectory(dir=ROOT / 'target') as temp: + path, digest = self.executable_fixture(temp) + directory = path.parent / 'directory' / 'firefox' + directory.mkdir(parents=True) + for engine, candidate, checksum in [ + ('chrome', path, digest), ('chromium', path, digest), ('safari', path, digest), + ('firefox', Path('firefox'), digest), ('firefox', path.parent / 'absent' / 'firefox', digest), + ('firefox', directory, digest), ('firefox', path.parent / 'other-browser', digest), + ('firefox', path, '0' * 64), + ('firefox', path, None), ('firefox', None, digest), + ('firefox', path, 'not-a-sha256')]: + with self.subTest(engine=engine, candidate=candidate, checksum=checksum): + with self.assertRaises(ValueError): + runner.validate_executable(engine, candidate, checksum) + path.chmod(0o600) + with self.assertRaises(ValueError): + runner.validate_executable('firefox', path, digest) + + def test_override_rejects_symlink_file_and_ancestor(self): + with tempfile.TemporaryDirectory(dir=ROOT / 'target') as temp: + path, digest = self.executable_fixture(temp) + directory = path.parent / 'linked' + directory.symlink_to(path.parent, target_is_directory=True) + link = path.parent / 'alias' + link.symlink_to(path) + for candidate in (link, directory / 'firefox'): + with self.assertRaises(ValueError): + runner.validate_executable('firefox', candidate, digest) + + def test_binary_drift_refuses_launch(self): + with tempfile.TemporaryDirectory(dir=ROOT / 'target') as temp: + path, digest = self.executable_fixture(temp) + executable = runner.validate_executable('firefox', path, digest) + path.write_bytes(b'changed') + playwright = PlaywrightDouble() + with self.assertRaises(ValueError): + runner.launch_browser(playwright, 'firefox', executable) + self.assertEqual(playwright.firefox.calls, []) + + def test_post_launch_drift_closes_browser(self): + with tempfile.TemporaryDirectory(dir=ROOT / 'target') as temp: + path, digest = self.executable_fixture(temp) + executable = runner.validate_executable('firefox', path, digest) + class Browser: + closed = False + def close(self): + self.closed = True + browser = Browser() + playwright = PlaywrightDouble() + def launch(**_): + path.write_bytes(b'post-launch drift') + return browser + playwright.firefox.launch = launch + with self.assertRaises(ValueError): + runner.launch_browser(playwright, 'firefox', executable) + self.assertTrue(browser.closed) + + def test_invalid_cli_override_records_not_run_without_browser(self): + with tempfile.TemporaryDirectory(dir=ROOT / 'target') as temp: + report = Path(temp) / 'receipt.json' + with self.assertRaises(SystemExit) as error: + runner.main(['unused-distribution', '--engine', 'safari', '--report', str(report), + '--executable-path', '/missing/firefox', '--executable-sha256', '0' * 64]) + self.assertEqual(error.exception.code, 2) + receipt = json.loads(report.read_text()) + self.assertEqual(receipt['status'], 'NOT RUN') + self.assertEqual(receipt['phase'], 'executable-validation') + self.assertNotIn('/missing', json.dumps(receipt)) + + def test_installed_chrome_explicit_channel(self): + playwright = PlaywrightDouble() + self.assertIs(runner.launch_browser(playwright, 'chrome'), playwright.chromium.browser) + self.assertEqual(playwright.chromium.calls, [{'channel': 'chrome', 'headless': True}]) + self.assertEqual(playwright.firefox.calls, []) + + def test_cached_engines_preserve_launch_arguments(self): + for engine in ('chromium', 'firefox'): + with self.subTest(engine=engine): + playwright = PlaywrightDouble() + chosen = getattr(playwright, engine) + self.assertIs(runner.launch_browser(playwright, engine), chosen.browser) + self.assertEqual(chosen.calls, [{'headless': True}]) + + def test_safari_and_webkit_cannot_be_substituted(self): + for engine in ('safari', 'webkit', 'unknown'): + playwright = PlaywrightDouble() + with self.assertRaises(ValueError): + runner.launch_browser(playwright, engine) + self.assertEqual(playwright.chromium.calls, []) + self.assertEqual(playwright.firefox.calls, []) + + def test_metadata_uses_runtime_version_and_user_agent(self): + class RuntimeBrowserDouble: + version = 'synthetic-runtime-version-not-qualification' + class PageDouble: + def evaluate(self, expression): + self.expression = expression + return 'synthetic-UA-not-browser-evidence' + page = PageDouble() + metadata = runner.browser_metadata(RuntimeBrowserDouble(), page, 'chrome') + self.assertEqual(page.expression, 'navigator.userAgent') + self.assertEqual(metadata['browserVersion'], RuntimeBrowserDouble.version) + self.assertEqual(metadata['userAgent'], 'synthetic-UA-not-browser-evidence') + self.assertEqual(metadata['browserChannel'], 'chrome') + self.assertEqual(runner.browser_metadata(RuntimeBrowserDouble(), page, 'chromium')['browserChannel'], 'cached') + + def test_launch_failure_is_not_run_and_redacts_exception_message(self): + error = RuntimeError('private/path and sensitive launch environment must not be copied') + report = runner.launch_failure('chrome', error) + self.assertEqual(report['status'], 'NOT RUN') + self.assertEqual(report['phase'], 'browser-launch') + self.assertEqual(report['errorClass'], 'RuntimeError') + self.assertNotIn('private/path', json.dumps(report)) + self.assertNotIn('browserVersion', report) + + def test_reports_preserve_existing_evidence(self): + scratch = ROOT / 'target/offline-engine-unit' + scratch.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=scratch) as temp: + report = Path(temp) / 'nested/report.json' + runner.write_report(report, {'status': 'NOT RUN'}) + with self.assertRaises(FileExistsError): + runner.write_report(report, {'status': 'PASS'}) + self.assertEqual(json.loads(report.read_text()), {'status': 'NOT RUN'}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/browser/performance-rolegraph-worker.js b/tests/browser/performance-rolegraph-worker.js new file mode 100644 index 0000000..e2e96fa --- /dev/null +++ b/tests/browser/performance-rolegraph-worker.js @@ -0,0 +1,23 @@ +// Test-only wrapper: same module instance and actual production dispatch owner. +import init from './rolegraph/terraphim_rolegraph_browser.js'; +import './js/rolegraph-worker.js'; + +const send = self.postMessage.bind(self); +let exports; +let highwater = 0; +function observe(response) { + const bytes = exports.memory.buffer.byteLength; + highwater = Math.max(highwater, bytes); + send({ type: 'performance_memory', requestId: response.requestId ?? null, + bytes, highwater, operationCompleted: response.type !== 'ready' }); +} +self.postMessage = response => { + if (response.type === 'ready') { + // Production initialization already completed; init returns its cached exports. + // Do not allow a client dispatch before those exports are observable. + init().then(value => { exports = value; observe(response); send(response); }); + return; + } + if (exports) observe(response); // Immediately after synchronous Rust dispatch. + send(response); +}; diff --git a/tests/browser/performance-rolegraph.html b/tests/browser/performance-rolegraph.html new file mode 100644 index 0000000..aa21bc3 --- /dev/null +++ b/tests/browser/performance-rolegraph.html @@ -0,0 +1,276 @@ + + +RoleGraph provisional synthetic performance qualification +
Running bounded real Worker qualification…
+ + diff --git a/tests/browser/rolegraph-build.test.cjs b/tests/browser/rolegraph-build.test.cjs new file mode 100644 index 0000000..f4477e4 --- /dev/null +++ b/tests/browser/rolegraph-build.test.cjs @@ -0,0 +1,199 @@ +// Actual build-script shell regression; Cargo is an explicit external double. +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const {spawnSync} = require('node:child_process'); + +test('system Bash with no override reaches locked metadata, not an empty-array error',()=>{ + const root=path.resolve(__dirname,'../..'); + const temp=fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(),'rolegraph-shell-')); + try { + const record=path.join(temp,'args'); + fs.writeFileSync(path.join(temp,'cargo'),'#!/bin/sh\nprintf "%s\\n" "$@" > "$TE_CARGO_RECORD"\nexit 71\n',{mode:0o755}); + const env={...process.env,PATH:temp+':/usr/bin:/bin',TE_CARGO_RECORD:record}; + delete env.TE_ROLEGRAPH_SOURCE; + const result=spawnSync('/bin/bash',['scripts/build-rolegraph.sh'],{cwd:root,env,encoding:'utf8'}); + assert.equal(result.status,71,result.stderr); + const args=fs.readFileSync(record,'utf8').trim().split('\n'); + assert.equal(args[0],'metadata'); + assert.ok(args.includes('--locked')); + assert.ok(!result.stderr.includes('unbound variable')); + } finally { + // Only this test's newly created private directory. + for(const file of fs.readdirSync(temp)) fs.unlinkSync(path.join(temp,file)); + fs.rmdirSync(temp); + } +}); + +test('production rejects a source override before invoking Cargo',()=>{ + const result=spawnSync('/bin/bash',['scripts/build-rolegraph.sh'],{ + cwd:path.resolve(__dirname,'../..'), + env:{...process.env,TE_ROLEGRAPH_SOURCE:'/not/a/production/source'},encoding:'utf8' + }); + assert.notEqual(result.status,0); + assert.match(result.stderr,/source overrides are forbidden/); +}); + +test('workspace bridge uses exact custom-registry pins and no nested lock',()=>{ + const root=path.resolve(__dirname,'../..'); + assert.match(fs.readFileSync(path.join(root,'Cargo.toml'),'utf8'),/members = .*"crates\/terraphim_rolegraph_browser"/); + const manifest=fs.readFileSync(path.join(root,'crates/terraphim_rolegraph_browser/Cargo.toml'),'utf8'); + assert.ok(!manifest.includes('[workspace]')); + for(const [name,version] of [['terraphim_rolegraph','2.0.1'],['terraphim_types','1.22.3'],['terraphim_automata','2.1.0']]) { + const line=manifest.split('\n').find(line=>line.startsWith(name+' =')); + assert.ok(line.includes(`version = "=${version}"`)); + assert.ok(line.includes('registry = "terraphim"')); + assert.ok(line.includes('default-features = false')); + } + assert.ok(!fs.existsSync(path.join(root,'crates/terraphim_rolegraph_browser/Cargo.lock'))); +}); + +// Exercise the actual admission, integrity validators and publisher against a +// disposable Git repository. Asset files are fixtures; no editor execution or +// released-build qualification is claimed. No Git/Python/publisher tool doubles. +for (const scenario of ['publish', 'dry-run', 'missing-graph', 'runtime-corruption', + 'dirty-source', 'changed-head', 'source-override']) { + test(`real release pipeline ${scenario} preserves its source and destination contract`, () => { + const root = path.resolve(__dirname, '../..'); + const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'release-pipeline-'))); + const env = {...process.env, PYTHONDONTWRITEBYTECODE:'1'}; + delete env.TE_ROLEGRAPH_SOURCE; + const run = (command, args) => spawnSync(command, args, {cwd:temp, env, encoding:'utf8'}); + const git = (...args) => { + const result = run('git', args); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + const write = (name, bytes) => { + const file = path.join(temp, name); + fs.mkdirSync(path.dirname(file), {recursive:true}); + fs.writeFileSync(file, bytes); + }; + const destinations = ['dist/index.html', 'package/dist/index.html']; + try { + for (const name of ['scripts/release-dist.sh', 'scripts/check-release-source.py', + 'scripts/build-dist.sh', 'scripts/publish-editor-dist.py', + 'scripts/ci/rolegraph-production-check.py', 'scripts/ci/runtime-assets-check.py', + 'public/roles/default.json']) { + write(name, fs.readFileSync(path.join(root, name))); + } + fs.cpSync(path.join(root, 'public/vendor'), path.join(temp, 'public/vendor'), {recursive:true}); + write('.gitignore', 'target/\n'); + for (const name of destinations) write(name, 'before publication\n'); + git('init', '-q'); + git('add', '.'); + git('-c', 'user.name=Release integration test', '-c', 'user.email=test@example.invalid', + '-c', 'commit.gpgsign=false', 'commit', '-qm', 'Admit release fixture'); + const head = git('rev-parse', 'HEAD'); + const stage = path.join(temp, 'target/release-staging-integration'); + const manifest = path.join(temp, 'target/release-manifests/result.json'); + const files = ['index.html', 'example.html', 'terraphim-editor.min.js', + 'terraphim-editor.min.css', 'terraphim_editor.js', 'terraphim_editor_bg.wasm', + 'js/rolegraph-worker.js', 'rolegraph/rolegraph-worker.js', + 'rolegraph/terraphim_rolegraph_browser.js', 'rolegraph/terraphim_rolegraph_browser_bg.wasm', + 'roles/default.json', 'terraphim-editor-abcdef1234567890.js', + 'terraphim-editor-abcdef1234567890_bg.wasm']; + for (const name of files) { + const bytes = name.endsWith('.wasm') ? Buffer.from([0,97,115,109,1,0,0,0]) + : name === 'roles/default.json' ? fs.readFileSync(path.join(temp, 'public', name)) + : 'release boundary fixture\n'; + write(`target/release-staging-integration/${name}`, bytes); + } + fs.cpSync(path.join(temp, 'public/vendor'), path.join(stage, 'vendor'), {recursive:true}); + if (scenario === 'missing-graph') fs.unlinkSync(path.join(stage, 'rolegraph/terraphim_rolegraph_browser_bg.wasm')); + if (scenario === 'runtime-corruption') { + const vendor = JSON.parse(fs.readFileSync(path.join(stage, 'vendor/manifest.json'), 'utf8')); + fs.appendFileSync(path.join(stage, 'vendor', vendor.files[0].path), 'tampered'); + } + if (scenario === 'dirty-source') fs.appendFileSync(path.join(temp, 'public/roles/default.json'), '\n'); + if (scenario === 'source-override') env.TE_ROLEGRAPH_SOURCE = '/forbidden/release/source'; + const shellCase = ['dirty-source', 'source-override'].includes(scenario); + const args = ['scripts/publish-editor-dist.py', '--stage', stage, '--manifest', manifest, + '--source-head', scenario === 'changed-head' ? '0'.repeat(40) : head]; + if (scenario === 'dry-run') args.push('--dry-run'); + const result = shellCase ? run('/bin/bash', ['scripts/release-dist.sh', '--dry-run']) : run('python3', args); + if (['publish', 'dry-run'].includes(scenario)) { + assert.equal(result.status, 0, result.stderr); + const report = JSON.parse(fs.readFileSync(manifest, 'utf8')); + assert.equal(report.sourceHead, head); + assert.equal(report.status, scenario === 'publish' ? 'published' : 'preflight-only'); + assert.deepEqual(report.sourceStatus, []); + for (const name of destinations) assert.equal(fs.readFileSync(path.join(temp, name), 'utf8'), + scenario === 'publish' ? 'release boundary fixture\n' : 'before publication\n'); + if (scenario === 'publish') { + for (const destination of ['dist', 'package/dist']) { + for (const item of report.files) { + assert.deepEqual(fs.readFileSync(path.join(temp, destination, item.path)), fs.readFileSync(path.join(stage, item.path))); + } + } + } + } else { + assert.notEqual(result.status, 0, `${scenario}: release unexpectedly succeeded`); + const expected = {'missing-graph':/missing production files/, 'runtime-corruption':/hash mismatch/, + 'dirty-source':/modified or untracked files/, 'changed-head':/source HEAD changed/, + 'source-override':/TE_ROLEGRAPH_SOURCE is forbidden/}; + assert.match(result.stderr, expected[scenario]); + assert.equal(fs.existsSync(manifest), false); + for (const name of destinations) assert.equal(fs.readFileSync(path.join(temp, name), 'utf8'), 'before publication\n'); + if (shellCase) assert.deepEqual(fs.readdirSync(path.join(temp, 'target')), ['release-staging-integration']); + } + assert.equal(git('rev-parse', 'HEAD'), head); + assert.equal(git('diff', '--cached'), ''); + } finally { + fs.rmSync(temp, {recursive:true}); // Only this test's new disposable repository. + } + }); +} + +// These external Cargo/bindgen doubles test orchestration, not the graph engine. +function buildDouble({metadataExit=0,version='0.2.129',wrongSource=false}={}) { + const root=path.resolve(__dirname,'../..'); + const temp=fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(),'rolegraph-contract-')); + const packages=[['terraphim_rolegraph','2.0.1'],['terraphim_types','1.22.3'],['terraphim_automata','2.1.0'],['terraphim_rolegraph_core','0.1.1'],['terraphim-markdown-parser','1.20.5']].map(([name,version])=>({name,version,source:wrongSource?null:'sparse+https://git.terraphim.cloud/api/packages/terraphim/cargo/'})); + packages.push({name:'wasm-bindgen',version:'0.2.129'},{name:'terraphim_rolegraph_browser',id:'bridge'}); + fs.writeFileSync(path.join(temp,'metadata.json'),JSON.stringify({packages,workspace_members:['bridge'],target_directory:temp})); + fs.writeFileSync(path.join(temp,'cargo'),`#!/bin/sh\nprintf '%s\\n' "$*" >> "$TE_DOUBLE/args"\nif [ "$1" = metadata ]; then cat "$TE_DOUBLE/metadata.json"; exit ${metadataExit}; fi\n`,{mode:0o755}); + fs.writeFileSync(path.join(temp,'wasm-bindgen'),`#!/bin/sh\nif [ "$1" = --version ]; then echo 'wasm-bindgen ${version}'; exit; fi\nwhile [ "$1" != --out-dir ]; do shift; done\nshift\nprintf 'glue' > "$1/terraphim_rolegraph_browser.js"\nprintf 'wasm' > "$1/terraphim_rolegraph_browser_bg.wasm"\n`,{mode:0o755}); + const env={...process.env,PATH:temp+':/usr/bin:/bin',TE_DOUBLE:temp,WASM_BINDGEN:path.join(temp,'wasm-bindgen')}; + delete env.TE_ROLEGRAPH_SOURCE; + const result=spawnSync('/bin/bash',['scripts/build-rolegraph.sh',path.join(temp,'out')],{cwd:root,env,encoding:'utf8'}); + const args=fs.readFileSync(path.join(temp,'args'),'utf8'); + fs.rmSync(temp,{recursive:true}); // Only this test's newly allocated directory. + return {result,args}; +} + +test('stale locked resolver failure stops before build',()=>{ + const {result,args}=buildDouble({metadataExit:71}); + assert.equal(result.status,71); assert.ok(!args.includes('build')); +}); +test('wrong bindgen stops before build',()=>{ + const {result,args}=buildDouble({version:'0.2.99'}); + assert.notEqual(result.status,0); assert.match(result.stderr,/does not match locked crate/); + assert.ok(!args.includes('build')); +}); +test('nonregistry graph metadata stops before build',()=>{ + const {result,args}=buildDouble({wrongSource:true}); + assert.notEqual(result.status,0); assert.match(result.stderr,/expected one published/); + assert.ok(!args.includes('build')); +}); +test('actual shell selects locked workspace package and metadata target directory',()=>{ + const {result,args}=buildDouble(); + assert.equal(result.status,0,result.stderr); + assert.match(args,/build --locked --release --target wasm32-unknown-unknown -p terraphim_rolegraph_browser/); +}); +test('missing graph assets and referenced snippets fail completeness',()=>{ + const root=path.resolve(__dirname,'../..'); + const temp=fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(),'rolegraph-assets-')); + const check=()=>spawnSync('python3',['scripts/ci/rolegraph-production-check.py','graph-assets',temp],{cwd:root,encoding:'utf8'}); + try { + assert.notEqual(check().status,0); + fs.writeFileSync(path.join(temp,'terraphim_rolegraph_browser.js'),'import "./snippets/missing/test.js";'); + fs.writeFileSync(path.join(temp,'terraphim_rolegraph_browser_bg.wasm'),'wasm'); + assert.notEqual(check().status,0); + fs.mkdirSync(path.join(temp,'snippets/missing'),{recursive:true}); + fs.writeFileSync(path.join(temp,'snippets/missing/test.js'),'snippet'); + assert.equal(check().status,0); + } finally { fs.rmSync(temp,{recursive:true}); } +}); diff --git a/tests/browser/rolegraph-client.test.cjs b/tests/browser/rolegraph-client.test.cjs new file mode 100644 index 0000000..8b6cfea --- /dev/null +++ b/tests/browser/rolegraph-client.test.cjs @@ -0,0 +1,150 @@ +// Unit tests: messaging double, NOT an end-to-end graph engine. +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +require('../../public/js/rolegraph-client.js'); +class WorkerDouble { + constructor() { this.sent = []; this.listeners = {}; } + addEventListener(type, fn) { this.listeners[type] = fn; } + removeEventListener(type) { delete this.listeners[type]; } + postMessage(message) { this.sent.push(message); if (message.type === 'init') queueMicrotask(() => this.emit({type:'ready'})); } + emit(data) { this.listeners.message?.({data}); } + reply(result = {}, overrides = {}) { const r = this.sent.at(-1); this.emit({version:1,requestId:r.requestId,generation:r.op === 'activate_role' || r.op === 'clear_role' ? r.payload.nextGeneration : r.generation,revision:'0',ok:true,result,...overrides}); } + terminate() { this.terminated = true; } +} +async function fixture(options={}) { const w = new WorkerDouble(); const c = new TeRoleGraphClient({assetBase:'https://host/nested/',workerFactory:()=>w,...options}); await c.ready; return {c,w}; } +test('initial generation, explicit nested assets and exact-string replies', async () => { + const {c,w}=await fixture(); assert.equal(w.sent[0].glueUrl,'https://host/nested/rolegraph/terraphim_rolegraph_browser.js'); + const p=c.command('stats'); assert.equal(w.sent.at(-1).generation,'0'); w.reply({rank:'9007199254740993'}); assert.equal((await p).rank,'9007199254740993'); c.destroy(); +}); +test('queue bound, abort queued calls, destroy rejects everything',async()=>{ + const {c,w}=await fixture(); const first=c.command('stats'); const ac=new AbortController(); const queued=c.command('stats',{}, {signal:ac.signal}); ac.abort(); await assert.rejects(queued,{code:'cancelled'}); + const jobs=Array.from({length:32},()=>c.command('stats')); await assert.rejects(c.command('stats'),{code:'resource_limit'}); + c.destroy(); await assert.rejects(first,{code:'destroyed'}); await Promise.all(jobs.map(p=>assert.rejects(p,{code:'destroyed'}))); assert.ok(w.terminated); +}); +test('invalid envelope fails transport; unrelated late replies ignored',async()=>{ + const {c,w}=await fixture(); const p=c.command('stats'); w.emit({requestId:'unknown'}); w.reply({}, {generation:'99'}); await assert.rejects(p,{code:'worker_failed'}); assert.equal(c.state,'failed'); c.destroy(); +}); +test('sync role guards queries and latest sync wins',async()=>{ + const {c,w}=await fixture(); const a=c.syncRole({generation:'1',thesaurusText:'a'}); await Promise.resolve(); + await assert.rejects(c.command('query',{text:'a'}),{code:'syncing'}); + const b=c.syncRole({generation:'2',thesaurusText:'b'}); w.reply({token:'a',thesaurusText:'a'}); await assert.rejects(a,{code:'stale_generation'}); + await new Promise(setImmediate); assert.equal(w.sent.at(-1).payload.thesaurusText,'b'); w.reply({token:'b',thesaurusText:'b'}); await new Promise(setImmediate); w.reply(); await b; assert.equal(c.generation,'2'); assert.equal(c.state,'ready'); c.destroy(); +}); +test('load candidate coordinates main prepare/commit; corrupt import retains role',async()=>{ + const calls=[]; const {c,w}=await fixture({roleCoordinator:{prepare:async text=>{calls.push(text);return 'main';},commit:token=>calls.push(token),discard:async()=>{}}}); + const load=c.loadRole('valid'); await new Promise(setImmediate); w.reply({token:'candidate',thesaurusText:'valid'}); await new Promise(setImmediate); assert.equal(w.sent.at(-1).op,'activate_role'); w.reply(); await load; assert.deepEqual(calls,['valid','main']); + const bad=c.importSnapshot('corrupt'); await new Promise(setImmediate); w.reply({}, {ok:false,error:{code:'invalid_snapshot',message:'invalid'}}); await assert.rejects(bad,{code:'invalid_snapshot'}); assert.equal(c.state,'ready'); assert.equal(c.generation,'1'); c.destroy(); +}); +test('query watchdog terminates worker rather than speculatively restoring',async()=>{ + const {c,w}=await fixture({queryTimeout:10}); await assert.rejects(c.command('stats'),{code:'timeout'}); assert.ok(w.terminated); assert.equal(c.state,'failed'); c.destroy(); +}); +test('startup failure is typed and teardown safe',async()=>{ + const c=new TeRoleGraphClient({assetBase:'https://host/',workerFactory:()=>{throw Error('private detail');}}); await assert.rejects(c.ready,{code:'worker_failed'}); await assert.rejects(c.command('stats'),{code:'worker_failed'}); c.destroy(); +}); +test('authoritative sync failure never exposes previous graph and can retry',async()=>{ + const {c,w}=await fixture();const bad=c.syncRole({generation:'8',thesaurusText:'bad'});await new Promise(setImmediate);w.reply({}, {ok:false,error:{code:'invalid_input',message:'bad'}});await assert.rejects(bad,{code:'invalid_input'});assert.equal(c.state,'failed');await assert.rejects(c.command('stats'),{code:'worker_failed'}); + const next=c.syncRole({generation:'9',thesaurusText:'good'});await new Promise(setImmediate);w.reply({token:'good',thesaurusText:'good'});await new Promise(setImmediate);w.reply();await next;assert.equal(c.generation,'9');c.destroy(); +}); +test('failed main commit restores a real previous snapshot before ready',async()=>{ + const calls=[];const {c,w}=await fixture({roleCoordinator:{prepare:()=> 'main',commit:()=>{throw Error('commit');},discard:t=>calls.push(t)}}); + const sync=c.syncRole({generation:'5',thesaurusText:'old'});await new Promise(setImmediate);w.reply({token:'old',thesaurusText:'old'});await new Promise(setImmediate);w.reply();await sync; + const load=c.loadRole('new');await new Promise(setImmediate);w.reply({token:'new',thesaurusText:'new'});await new Promise(setImmediate);assert.equal(w.sent.at(-1).op,'export_snapshot');w.reply({snapshotText:'real-checkpoint'});await new Promise(setImmediate);w.reply();await new Promise(setImmediate);assert.equal(w.sent.at(-1).op,'prepare_snapshot');assert.equal(w.sent.at(-1).payload.snapshotText,'real-checkpoint');w.reply({token:'rollback',thesaurusText:'old'});await new Promise(setImmediate);w.reply();await assert.rejects(load,/commit/);assert.equal(c.generation,'5');assert.equal(c.state,'ready');assert.deepEqual(calls,['main']);c.destroy(); +}); +test('inflight cancellation invalidates transport; restore uses real validation',async()=>{ + const {c,w}=await fixture();const abort=new AbortController();const p=c.command('stats',{}, {signal:abort.signal});abort.abort();await assert.rejects(p,{code:'cancelled'});assert.ok(w.terminated);c.destroy(); + const f=await fixture({roleCoordinator:{prepare:()=> 'main',commit:()=>{},discard:()=>{}}});f.c.storage={load:async()=> 'stored opaque wrapper',close(){}}; + const restore=f.c.restoreCheckpoint();await new Promise(setImmediate);assert.equal(f.w.sent.at(-1).op,'prepare_snapshot');assert.equal(f.w.sent.at(-1).payload.snapshotText,'stored opaque wrapper');f.w.reply({}, {ok:false,error:{code:'invalid_snapshot',message:'corrupt'}});await assert.rejects(restore,{code:'invalid_snapshot'});assert.equal(f.c.generation,'0');f.c.destroy(); +}); +test('watchdog recovers only when a real checkpoint exists',async()=>{ + const {c,w}=await fixture({queryTimeout:30});const sync=c.syncRole({generation:'3',thesaurusText:'valid'});await new Promise(setImmediate);w.reply({token:'valid',thesaurusText:'valid'});await new Promise(setImmediate);w.reply();await sync; + c.storage={save:async()=>{},close(){}};const save=c.saveCheckpoint();w.reply({snapshotText:'checkpoint-from-bridge'});await save; + await assert.rejects(c.command('query',{text:'x',mode:'graph'}),{code:'timeout'});await new Promise(setImmediate);assert.equal(c.state,'syncing');assert.equal(w.sent.at(-1).op,'prepare_snapshot');assert.equal(w.sent.at(-1).payload.snapshotText,'checkpoint-from-bridge');w.reply({token:'restored',thesaurusText:'valid'});await new Promise(setImmediate);w.reply();await c.lifecycle;assert.equal(c.state,'ready');assert.equal(c.generation,'3');c.destroy(); +}); +test('revision regression and malformed response are refused',async()=>{ + const {c,w}=await fixture();const a=c.command('index_documents',{documents:[]});w.reply({}, {revision:'2'});await a;const b=c.command('stats');assert.equal(w.sent.at(-1).expectedRevision,'2');w.reply({}, {revision:'1'});await assert.rejects(b,{code:'worker_failed'});c.destroy(); + const f=await fixture();const p=f.c.command('stats');f.w.reply({}, {version:2});await assert.rejects(p,{code:'worker_failed'});f.c.destroy(); +}); +test('latest main sync discards a pending asynchronous load preparation',async()=>{ + let finishPrepare;let commits=0;const discarded=[];const {c,w}=await fixture({roleCoordinator:{prepare:()=>new Promise(resolve=>finishPrepare=resolve),commit:()=>commits++,discard:t=>discarded.push(t)}}); + const load=c.loadRole('candidate');await new Promise(setImmediate);w.reply({token:'candidate',thesaurusText:'candidate'});await new Promise(setImmediate);const sync=c.syncRole({generation:'10',thesaurusText:'authoritative'});finishPrepare('prepared-main');await assert.rejects(load,{code:'stale_generation'});await new Promise(setImmediate);w.reply({token:'authoritative',thesaurusText:'authoritative'});await new Promise(setImmediate);w.reply();await sync;assert.equal(c.generation,'10');assert.equal(commits,0);assert.deepEqual(discarded,['prepared-main']);c.destroy(); +}); + +const tick = () => new Promise(setImmediate); +async function oldRole(c,w) { + const sync=c.syncRole({generation:'5',thesaurusText:'OLD'}); + await tick();w.reply({token:'old',thesaurusText:'OLD'});await tick();w.reply();await sync; +} +async function activateCandidate(c,w) { + const outcome=c.loadRole('A').then(()=>null,e=>e); + await tick();w.reply({token:'A',thesaurusText:'A'});await tick(); + assert.equal(w.sent.at(-1).op,'export_snapshot');w.reply({snapshotText:'EXACT-OLD-CORPUS-9007199254740993'});await tick(); + assert.equal(w.sent.at(-1).op,'activate_role');return {outcome}; +} +async function reconcileOld(w) { + await tick();assert.equal(w.sent.at(-1).op,'prepare_snapshot'); + assert.equal(w.sent.at(-1).payload.snapshotText,'EXACT-OLD-CORPUS-9007199254740993'); + w.reply({token:'rollback-old',thesaurusText:'OLD'});await tick(); + assert.equal(w.sent.at(-1).payload.nextGeneration,'5');w.reply();await tick(); +} +for (const next of ['reject-prepare','reject-commit','authoritative-sync']) { + test(`superseded activation reconciles OLD corpus before ${next}`,async t=>{ + let main='OLD';const commits=[];const states=[]; + const {c,w}=await fixture({onStateChange:s=>states.push({...s}),roleCoordinator:{prepare:text=>text,commit:token=>{commits.push(token);throw Error('main stays OLD');},discard:()=>{}}}); + t.after(()=>c.destroy());await oldRole(c,w);states.length=0; + const {outcome:first}=await activateCandidate(c,w); + assert.equal(w.sent.at(-1).op,'activate_role'); + const second=(next==='authoritative-sync' ? (main='NEW',c.syncRole({generation:'10',thesaurusText:'NEW'})) : c.importSnapshot('B')).then(()=>null,e=>e); + w.reply();await reconcileOld(w);assert.equal((await first).code,'stale_generation'); + assert.ok(!states.some(s=>s.state==='ready')); + if (next==='reject-prepare') { + assert.equal(w.sent.at(-1).op,'prepare_snapshot');assert.equal(w.sent.at(-1).payload.snapshotText,'B'); + w.reply({}, {ok:false,error:{code:'invalid_snapshot',message:'bad B'}}); + assert.equal((await second).code,'invalid_snapshot');assert.equal(c.generation,'5');assert.equal(main,'OLD');assert.deepEqual(commits,[]); + } else if (next==='reject-commit') { + w.reply({token:'B',thesaurusText:'B'});await tick();assert.equal(w.sent.at(-1).op,'export_snapshot');w.reply({snapshotText:'EXACT-OLD-CORPUS-9007199254740993'}); + await tick();assert.equal(w.sent.at(-1).payload.nextGeneration,'6');w.reply();await reconcileOld(w); + assert.equal((await second).code,'invalid_input');assert.deepEqual(commits,['B']);assert.equal(c.generation,'5');assert.equal(main,'OLD'); + } else { + assert.equal(w.sent.at(-1).op,'prepare_role');assert.equal(w.sent.at(-1).payload.thesaurusText,'NEW'); + w.reply({token:'NEW',thesaurusText:'NEW'});await tick();w.reply();assert.equal(await second,null);assert.equal(c.generation,'10');assert.equal(main,'NEW');assert.deepEqual(commits,[]); + } + assert.equal(c.state,'ready');assert.ok(states.filter(s=>s.state==='ready').every(s=>s.generation===(next==='authoritative-sync'?'10':'5'))); + }); +} +for (const boundary of ['load','import','sync','clear']) { + for (const op of ['query','index_documents']) { + test(`active ${op} rejects at ${boundary} boundary but reply still reconciles`,async t=>{ + const {c,w}=await fixture({roleCoordinator:{prepare:()=> 'main',commit:()=>{},discard:()=>{}}});t.after(()=>c.destroy()); + let settled=false;const result=c.command(op,op==='query'?{text:'old',mode:'graph'}:{documents:[]}).then(value=>{settled=true;return {value};},e=>{settled=true;return {error:e};}); + const transition=(boundary==='load'?c.loadRole('NEW'):boundary==='import'?c.importSnapshot('NEW'):c.syncRole({generation:'10',thesaurusText:boundary==='clear'?null:'NEW'})).then(()=>null,e=>e); + await tick();assert.equal(settled,true);assert.equal((await result).error.code,'stale_generation'); + w.reply({oldRoleResult:true},{revision:op==='index_documents'?'1':'0'});await tick(); + assert.equal(c.state,'syncing');assert.equal(w.sent.at(-1).expectedRevision,op==='index_documents'?'1':'0'); + if (boundary!=='clear') {w.reply({token:'NEW',thesaurusText:'NEW'},{revision:op==='index_documents'?'1':'0'});await tick();} + w.reply();assert.equal(await transition,null);assert.equal(c.state,'ready');assert.equal(c.generation,boundary==='load'||boundary==='import'?'1':'10'); + }); + } +} +test('a later import cannot skip a pending authoritative synchronization',async t=>{ + const states=[];const {c,w}=await fixture({onStateChange:s=>states.push({...s}),roleCoordinator:{prepare:t=>t,commit:()=>{throw Error('refused');},discard:()=>{}}});t.after(()=>c.destroy());states.length=0; + const sync=c.syncRole({generation:'10',thesaurusText:'MAIN-NEW'}).then(()=>null,e=>e); + const load=c.importSnapshot('candidate').then(()=>null,e=>e); + await tick();assert.equal(w.sent.at(-1).payload.thesaurusText,'MAIN-NEW');w.reply({token:'main-new',thesaurusText:'MAIN-NEW'});await tick();w.reply(); + assert.equal((await sync).code,'stale_generation');await tick();assert.equal(c.hasRole,true);assert.equal(w.sent.at(-1).op,'prepare_snapshot');w.reply({token:'candidate',thesaurusText:'candidate'}); + await tick();assert.equal(w.sent.at(-1).op,'export_snapshot');w.reply({snapshotText:'EXACT-MAIN-NEW-CORPUS'});await tick();assert.equal(w.sent.at(-1).payload.nextGeneration,'11');w.reply(); + await tick();assert.equal(w.sent.at(-1).payload.snapshotText,'EXACT-MAIN-NEW-CORPUS');w.reply({token:'rollback-main-new',thesaurusText:'MAIN-NEW'});await tick();assert.equal(w.sent.at(-1).payload.nextGeneration,'10');w.reply(); + assert.equal((await load).code,'invalid_input');assert.equal(c.generation,'10');assert.equal(c.state,'ready');assert.ok(states.filter(s=>s.state==='ready').every(s=>s.generation==='10')); +}); +test('failed authoritative sync cannot expose the old graph through a queued import',async t=>{ + const {c,w}=await fixture({roleCoordinator:{prepare:t=>t,commit:()=>{},discard:()=>{}}});t.after(()=>c.destroy());await oldRole(c,w); + const sync=c.syncRole({generation:'10',thesaurusText:'MAIN-NEW'}).then(()=>null,e=>e);const load=c.importSnapshot('candidate').then(()=>null,e=>e); + await tick();w.reply({}, {ok:false,error:{code:'invalid_input',message:'rejected'}});assert.equal((await sync).code,'invalid_input');assert.equal((await load).code,'worker_failed');assert.equal(c.state,'failed'); +}); +test('failed stale-activation rollback blocks subsequent imports instead of guessing a baseline',async t=>{ + const {c,w}=await fixture({roleCoordinator:{prepare:t=>t,commit:()=>{},discard:()=>{}}});t.after(()=>c.destroy());await oldRole(c,w); + const {outcome:first}=await activateCandidate(c,w);const load=c.importSnapshot('B').then(()=>null,e=>e);w.reply();await tick();assert.equal(w.sent.at(-1).payload.snapshotText,'EXACT-OLD-CORPUS-9007199254740993'); + w.reply({}, {ok:false,error:{code:'invalid_snapshot',message:'cannot recover'}});assert.equal((await first).code,'stale_generation');assert.equal((await load).code,'worker_failed');assert.equal(c.state,'failed'); +}); +test('async main commit is rejected before candidate activation',async t=>{ + let called=false;const {c,w}=await fixture({roleCoordinator:{prepare:t=>t,commit:async()=>{called=true;},discard:()=>{}}});t.after(()=>c.destroy());await assert.rejects(c.loadRole('A'),{code:'invalid_input'});assert.equal(called,false);assert.equal(w.sent.length,1);assert.equal(c.generation,'0'); +}); diff --git a/tests/browser/rolegraph-corpus-runner.test.py b/tests/browser/rolegraph-corpus-runner.test.py new file mode 100644 index 0000000..7de006a --- /dev/null +++ b/tests/browser/rolegraph-corpus-runner.test.py @@ -0,0 +1,68 @@ +"""Capture regressions; no browser/graph substitutes.""" +import importlib.util +import json +import pathlib +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('corpus', ROOT / 'scripts/ci/rolegraph-corpus-check.py') +corpus = importlib.util.module_from_spec(spec) +spec.loader.exec_module(corpus) + + +class CaptureTests(unittest.TestCase): + def test_utf8_and_opaque_capture(self): + with tempfile.TemporaryDirectory(prefix='rolegraph-corpus-test-', dir=corpus.TEMP) as directory: + root = pathlib.Path(directory) + (root / 'private-title.md').write_bytes('知識\r\n'.encode()) + thesaurus = root / 'terms.json' + thesaurus.write_text(json.dumps({'name': 'private', 'data': {'term': {'id': 1, 'nterm': 'term'}}})) + payload, evidence = corpus.capture_profile(root, thesaurus, 'kg') + self.assertEqual(evidence['sourceBytes'], len('知識\r\n'.encode())) + self.assertEqual(payload['documents'][0]['id'], 'kg-0000') + self.assertEqual(payload['documents'][0]['title'], '') + self.assertNotIn('private', json.dumps(evidence)) + self.assertNotIn('知識', json.dumps(evidence, ensure_ascii=False)) + + def test_limits_use_native_utf8_bytes(self): + with self.assertRaises(ValueError): + corpus.validate_documents([{'id': 'x', 'title': '', 'body': '知' * (1024 * 1024 // 3 + 1)}]) + with self.assertRaises(ValueError): + corpus.validate_documents([{'id': str(i), 'title': '', 'body': ''} for i in range(1001)]) + with self.assertRaises(ValueError): + corpus.validate_documents([{'id': str(i), 'title': '', 'body': 'x' * 1024**2} for i in range(17)]) + + def test_empty_corpus_rejected(self): + with self.assertRaises(ValueError): + corpus.validate_documents([]) + + def test_recursive_capture_and_no_source_mutation(self): + with tempfile.TemporaryDirectory(prefix='rolegraph-corpus-test-', dir=corpus.TEMP) as directory: + root = pathlib.Path(directory) + (root / 'nested').mkdir() + path = root / 'nested' / 'private.md' + path.write_bytes(b'unchanged') + thesaurus = root / 'terms.json' + thesaurus.write_text(json.dumps({'name': 'private', 'data': {'term': {'id': 1, 'nterm': 'term'}}})) + payload, evidence = corpus.capture_profile(root, thesaurus, 'engineer') + self.assertEqual(evidence['documents'], 1) + self.assertEqual(path.read_bytes(), b'unchanged') + self.assertEqual(payload['documents'][0]['id'], 'engineer-0000') + + def test_staged_drift_rejected_using_actual_delivery(self): + delivery = ROOT / 'target/rolegraph-delivery' + if not delivery.exists(): + self.skipTest('already-built real delivery unavailable') + original = (delivery / 'js/rolegraph-worker.js').read_bytes() + def drift(stage): + with (stage / 'js/rolegraph-worker.js').open('ab') as stream: + stream.write(b'// drift') + with tempfile.TemporaryDirectory(prefix='rolegraph-corpus-test-', dir=corpus.TEMP) as directory: + with self.assertRaises(ValueError): + corpus.stage_assets(delivery, ROOT, pathlib.Path(directory), drift) + self.assertEqual((delivery / 'js/rolegraph-worker.js').read_bytes(), original) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/browser/rolegraph-editor.test.cjs b/tests/browser/rolegraph-editor.test.cjs new file mode 100644 index 0000000..0e3db59 --- /dev/null +++ b/tests/browser/rolegraph-editor.test.cjs @@ -0,0 +1,224 @@ +// Integration fault-path units using real editor/client JS and explicitly +// labelled Worker/main-WASM doubles. Not browser or Rust conformance evidence. +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const {execFileSync,spawnSync} = require('node:child_process'); + +function fixture() { + const sent = []; const events = []; const held = []; + let holdStats = false; + class WorkerDouble { + constructor() { this.listeners = {}; } + addEventListener(type, fn) { this.listeners[type] = fn; } + removeEventListener(type) { delete this.listeners[type]; } + terminate() {} + postMessage(request) { + sent.push(request); + const respond = () => { + if (request.type === 'init') return this.listeners.message?.({data:{type:'ready'}}); + const activation = ['activate_role','clear_role'].includes(request.op); + const result = request.op === 'prepare_role' || request.op === 'prepare_snapshot' + ? {token:'worker-token',thesaurusText:request.payload.thesaurusText || 'old'} + : request.op === 'export_snapshot' ? {snapshotText:'old-corpus'} : {}; + this.listeners.message?.({data:{version:1,requestId:request.requestId, + generation:activation ? request.payload.nextGeneration : request.generation, + revision:'0',ok:true,result}}); + }; + if (holdStats && request.op === 'stats') held.push(respond); + else queueMicrotask(respond); + } + } + let generation = 5n; let text = 'old'; let prepared; let listener; + const notify = () => {try {listener?.();} catch (_) { /* Rust isolates observer errors. */ }}; + const api = { + kg_set_role_change_listener: fn => {listener=fn;}, + kg_role_snapshot_json: () => JSON.stringify({generation:String(generation),thesaurusText:text,name:'role'}), + kg_load_thesaurus: value => { if (value==='invalid') throw Error('invalid'); text=value; ++generation; prepared=null; notify(); return {concepts:1}; }, + kg_clear_thesaurus: () => {text=null; ++generation; prepared=null; notify();}, + kg_prepare_role: value => { prepared={value,generation};return 'main-token'; }, + kg_commit_prepared_role: () => { + if (!prepared || prepared.generation !== generation) throw Error('stale'); + text=prepared.value; ++generation; prepared=null; notify(); + return JSON.stringify({generation:String(generation)}); + }, + kg_discard_prepared_role: () => {prepared=null;}, + }; + const context = vm.createContext({console,URL,setTimeout,clearTimeout,queueMicrotask, + Worker:WorkerDouble,document:{baseURI:'https://local/',addEventListener(){}}, + CustomEvent:class {constructor(type,options) {this.type=type;this.detail=options.detail;}}, + }); + context.window=context; + for (const name of ['rolegraph-client','editor']) { + vm.runInContext(fs.readFileSync(`public/js/${name}.js`,'utf8'),context); + } + const editor = Object.create(context.MarkdownEditor.prototype); + Object.assign(editor,{config:{},destroyed:false,graphClient:null, + requireDocumentApi:()=>api,documentApi:()=>api, + indicators:{flush(){throw Error('redraw failed');}}, + root:{dataset:{},dispatchEvent:event=>events.push(event)}, + }); + const changes = new Set(); + editor.surface = {getText:()=> 'original markdown',onChange:fn=>{changes.add(fn);return ()=>changes.delete(fn);}}; + return {editor,api,sent,events,changes,holdStats:()=>{holdStats=true;},release:()=>held.splice(0).forEach(fn=>fn()),listener:()=>listener}; +} + +for (const method of ['loadThesaurus','clearThesaurus']) { + test(`${method} notifies the graph even when role redraw fails`,async()=>{ + const {editor,api}=fixture(); const graph=editor.rolegraph; + await editor.graphRoleSync; + assert.doesNotThrow(()=>editor[method]('direct')); + await editor.graphRoleSync; + assert.equal(graph.generation,JSON.parse(api.kg_role_snapshot_json()).generation); + assert.equal(graph.state,'ready'); graph.destroy(); + }); +} + +for (const method of ['loadRole','importSnapshot']) { + for (const direct of [false,true]) { + test(`attachment follows ${method} after superseding ${direct?'direct':'lazy'} synchronization`,async()=>{ + const {editor,sent}=fixture(); const graph=editor.rolegraph; + await graph.ready; + if (direct) editor.loadThesaurus('direct'); + await graph[method]('new'); + await graph.attachDocument({id:'notes'}); + assert.equal(sent.filter(r=>r.op==='index_documents').length,1); + editor.detachGraphDocument(); graph.destroy(); + }); + } +} + +test('attachment propagates current lane failure and removes listeners',async()=>{ + const {editor,changes}=fixture(); const graph=editor.rolegraph; + await editor.graphRoleSync; + const failure=Object.assign(Error('current failure'),{code:'worker_failed'}); + const listeners=graph.listeners.size; + graph.lifecycle=Promise.reject(failure); graph.lifecycle.catch(()=>{}); + await assert.rejects(graph.attachDocument({id:'notes'}),error=>error===failure); + assert.equal(changes.size,0); + assert.equal(graph.listeners.size,listeners); + assert.equal(editor.graphAttachment,null); graph.destroy(); +}); + +test('attachment follows a replacement lane while waiting and rejects detached attachment',async()=>{ + const {editor,sent,changes}=fixture(); const graph=editor.rolegraph; await editor.graphRoleSync; + let rejectOld; + graph.lifecycle=new Promise((_,reject)=>{rejectOld=reject;}); + const attachment=graph.attachDocument({id:'notes'}); + const load=graph.loadRole('replacement'); + rejectOld(Object.assign(Error('superseded'),{code:'stale_generation'})); + await load; await attachment; + assert.equal(sent.filter(r=>r.op==='index_documents').length,1); + let resolveLane; graph.lifecycle=new Promise(resolve=>{resolveLane=resolve;}); + const detached=graph.attachDocument({id:'cancelled'}); + editor.detachGraphDocument(); resolveLane(); + await assert.rejects(detached,/cancelled/); + assert.equal(changes.size,0); + assert.equal(sent.filter(r=>r.op==='index_documents').length,1); graph.destroy(); +}); + +for (const mutation of ['kg_load_thesaurus','kg_clear_thesaurus','kg_commit_prepared_role']) { + test(`raw ${mutation} synchronously invalidates active and queued old jobs`,async()=>{ + const {editor,api,holdStats,release}=fixture(); const graph=editor.rolegraph; + await editor.graphRoleSync; + holdStats(); + const active=graph.command('stats'); const queued=graph.command('stats'); + const activeRefused=assert.rejects(active,{code:'stale_generation'}); + const queuedRefused=assert.rejects(queued,{code:'stale_generation'}); + if (mutation==='kg_commit_prepared_role') api.kg_prepare_role('external'); + api[mutation]('external'); + assert.equal(graph.state,'syncing'); + await Promise.all([activeRefused,queuedRefused]); release(); + await editor.graphRoleSync; + assert.equal(graph.generation,JSON.parse(api.kg_role_snapshot_json()).generation); + assert.equal(graph.hasRole,mutation!=='kg_clear_thesaurus'); + graph.destroy(); + }); +} + +test('raw invalid load does not notify or change main/graph',async()=>{ + const {editor,api}=fixture(); const graph=editor.rolegraph; await editor.graphRoleSync; + const before=api.kg_role_snapshot_json(); const epoch=graph.epoch; + assert.throws(()=>api.kg_load_thesaurus('invalid')); + assert.equal(api.kg_role_snapshot_json(),before); assert.equal(graph.epoch,epoch); + assert.equal(graph.state,'ready'); graph.destroy(); +}); + +test('editor wrappers publish exactly one authoritative sync',async()=>{ + const {editor}=fixture(); const graph=editor.rolegraph; await editor.graphRoleSync; + for (const method of ['loadThesaurus','clearThesaurus']) { + const epoch=graph.epoch; editor[method]('new'); + assert.equal(graph.epoch,epoch+1); await editor.graphRoleSync; + } + graph.destroy(); +}); + +test('observer registration is lazy and removed on editor teardown',async()=>{ + const {editor,api,listener}=fixture(); + editor.loadThesaurus('before'); assert.equal(listener(),undefined); + const graph=editor.rolegraph; await editor.graphRoleSync; assert.equal(typeof listener(),'function'); + Object.assign(editor,{abortController:{abort(){}},createdNodes:[],warningListeners:new Set(), + indicators:null,surface:null}); + editor.destroy(); assert.equal(listener(),undefined); + api.kg_clear_thesaurus(); assert.equal(graph.state,'destroyed'); +}); + +test('older WASM supports ordinary teardown but cannot start an unobserved graph',()=>{ + const {editor,api}=fixture(); delete api.kg_set_role_change_listener; + assert.throws(()=>editor.rolegraph,/coordination API/); + assert.equal(editor.graphClient,null); + Object.assign(editor,{abortController:{abort(){}},createdNodes:[],warningListeners:new Set(), + indicators:null,surface:null}); + assert.doesNotThrow(()=>editor.destroy()); +}); + +test('build script metadata refuses a stale standalone lock without rewriting it',()=>{ + const directory=fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(),'rolegraph-lock-test-')); + try { + const manifest=path.join(directory,'Cargo.toml'); + const initial='[package]\nname="lock_probe"\nversion="0.1.0"\nedition="2021"\n[lib]\npath="lib.rs"\n'; + fs.writeFileSync(manifest,initial); fs.writeFileSync(path.join(directory,'lib.rs'),''); + const cargo=execFileSync('which',['cargo'],{encoding:'utf8'}).trim(); + execFileSync(cargo,['generate-lockfile','--offline','--manifest-path',manifest],{stdio:'pipe'}); + const lock=path.join(directory,'Cargo.lock'); const before=fs.readFileSync(lock,'utf8'); + const dependency=path.join(directory,'dependency'); fs.mkdirSync(dependency); + fs.writeFileSync(path.join(dependency,'Cargo.toml'),initial.replace('lock_probe','local_dependency')); + fs.writeFileSync(path.join(dependency,'lib.rs'),''); + fs.writeFileSync(manifest,initial+'[dependencies]\nlocal_dependency={path="dependency"}\n'); + const bin=path.join(directory,'bin'); fs.mkdirSync(bin); + // Run the actual build script, redirecting only its manifest to a tiny real + // Cargo fixture. Never touch the concurrently owned bridge lockfile. + fs.writeFileSync(path.join(bin,'cargo'),'#!/bin/bash\nargs=()\nwhile [ "$#" -gt 0 ]; do\nif [ "$1" = --manifest-path ]; then args+=("$1" "$TEST_MANIFEST"); shift 2; else args+=("$1"); shift; fi\ndone\nprintf "%s\\n" "${args[@]}" > "$TEST_TRACE"\nexec "$REAL_CARGO" "${args[@]}"\n',{mode:0o755}); + const trace=path.join(directory,'trace'); + // System macOS Bash 3 treats the script's empty ARGS array as unset. + const bash=fs.existsSync('/opt/homebrew/bin/bash')?'/opt/homebrew/bin/bash':'bash'; + const result=spawnSync(bash,['scripts/build-rolegraph.sh'],{encoding:'utf8',env:{...process.env, + TE_ROLEGRAPH_SOURCE:'',PATH:bin+path.delimiter+process.env.PATH, + REAL_CARGO:cargo,TEST_MANIFEST:manifest,TEST_TRACE:trace}}); + assert.notEqual(result.status,0); + assert.ok(fs.existsSync(trace),result.stderr); + assert.equal(fs.readFileSync(lock,'utf8'),before); + assert.match(fs.readFileSync(trace,'utf8'),/\n--locked\n/); + assert.match(result.stderr,/lock file|lockfile/); + } finally {fs.rmSync(directory,{recursive:true,force:true});} +}); + +for (const method of ['loadRole','importSnapshot']) { + test(`${method} cannot roll graph back after successful main commit if redraw fails`,async()=>{ + const {editor,api,sent,events}=fixture(); const graph=editor.rolegraph; + await editor.graphRoleSync; + const epoch=graph.epoch; + const preparations=sent.filter(r=>r.op==='prepare_role').length; + await graph[method]('new'); + assert.equal(graph.epoch,epoch+1); // Own commit observer must not add a sync/reset. + assert.equal(sent.filter(r=>r.op==='prepare_role').length,preparations+(method==='loadRole'?1:0)); + assert.equal(graph.generation,JSON.parse(api.kg_role_snapshot_json()).generation); + assert.equal(graph.state,'ready'); + assert.equal(sent.filter(r=>r.op==='prepare_snapshot').length,method==='importSnapshot'?1:0); + assert.ok(events.some(e=>e.type==='te:rolegraph-error' && e.detail.code==='render_failed')); + graph.destroy(); + }); +} diff --git a/tests/browser/rolegraph-quota-runner.test.py b/tests/browser/rolegraph-quota-runner.test.py new file mode 100644 index 0000000..5ad56ff --- /dev/null +++ b/tests/browser/rolegraph-quota-runner.test.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Quota runner control-plane regressions; no mock IndexedDB/graph engines.""" +import hashlib +import importlib.util +import json +import os +import pathlib +import tempfile +import threading +import unittest +from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location('quota_runner', ROOT / 'scripts/ci/rolegraph-quota-check.py') +RUNNER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(RUNNER) + + +class QuotaRunnerRegression(unittest.TestCase): + def evidence(self): + # Pure predicate inputs, never injected into a live browser/qualification. + return {'baselineSaved': True, 'previousBytes': 1000, 'retainedBytes': 1000, + 'previousSha256': 'a'*64, 'retainedSha256': 'a'*64, + 'refusal': 'storage_failed', 'previousCheckpointRetained': True, + 'unsavedGraphAvailable': True, 'mainRoleAssociated': True, + 'database': 'te-rolegraph:test', 'cacheWaitMs': 33000, + 'quotaDiagnostics': {'afterOverride': {'overrideActive': True, 'quota': 1}, + 'afterWait': {'overrideActive': True, 'quota': 1}}, + 'transactionEvents': [{'phase': 'measured-save', 'database': 'te-rolegraph:test', + 'eventType': 'abort', 'targetKind': 'transaction', 'transactionMode': 'readwrite', + 'transactionId': 1, 'errorName': 'QuotaExceededError', 'bubbles': True}]} + + def test_commit_abort_is_real_required_telemetry(self): + self.assertTrue(RUNNER.qualify(self.evidence())) + r = self.evidence() + r['transactionEvents'][0].update(eventType='error', targetKind='request') + self.assertTrue(RUNNER.qualify(r)) + + def test_unrelated_or_malformed_events_cannot_qualify(self): + for change in [{'phase':'baseline'}, {'database':'other'}, {'transactionMode':'readonly'}, + {'errorName':'AbortError'}, {'eventType':'complete'}, {'bubbles':False}, + {'transactionId':None}, {'targetKind':'database'}]: + with self.subTest(change=change): + r = self.evidence(); r['transactionEvents'][0].update(change) + self.assertFalse(RUNNER.qualify(r)) + + def test_every_acceptance_condition_required(self): + for key, value in [('refusal', None), ('previousCheckpointRetained', False), + ('unsavedGraphAvailable', False), ('mainRoleAssociated', False), + ('cacheWaitMs', 29999), ('transactionEvents', []), ('baselineSaved', False), + ('previousBytes', 0), ('retainedBytes', 999), ('retainedSha256', 'b'*64)]: + r = self.evidence(); r[key] = value + self.assertFalse(RUNNER.qualify(r), key) + for label in ['afterOverride', 'afterWait']: + r = self.evidence(); r['quotaDiagnostics'][label]['overrideActive'] = False + self.assertFalse(RUNNER.qualify(r)) + r = self.evidence(); r['runnerErrors'] = [{'error':'cleanup failed'}] + self.assertFalse(RUNNER.qualify(r)) + + def test_actual_server_cleanup_failure_continues_and_writes_failure_evidence(self): + result = {'ok': True} + server = ThreadingHTTPServer(('127.0.0.1', 0), SimpleHTTPRequestHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start() + completed = [] + def failing_shutdown(): + server.shutdown(); completed.append('shutdown') + raise RuntimeError('injected after real server.shutdown') + RUNNER.cleanup_step(result, 'shutdown', failing_shutdown) + RUNNER.cleanup_step(result, 'socket', lambda: (server.server_close(), completed.append('socket'))) + RUNNER.cleanup_step(result, 'thread', lambda: (thread.join(), completed.append('thread'))) + self.assertEqual(completed, ['shutdown', 'socket', 'thread']) + self.assertFalse(thread.is_alive()) + with tempfile.TemporaryDirectory() as temporary: + output = pathlib.Path(temporary) / 'failure.json' + self.assertEqual(RUNNER.write_result(result, output), 1) + evidence = json.loads(output.read_text()) + self.assertFalse(evidence['ok']); self.assertEqual(evidence['exitCode'], 1) + self.assertIn('injected', evidence['runnerErrors'][0]['error']) + + def test_manifest_covers_inline_host_and_exact_staged_bytes(self): + with tempfile.TemporaryDirectory() as temporary: + source = pathlib.Path(temporary) / 'source'; source.mkdir() + (source/'index.html').write_text('') + (source/'root-hash.js').write_bytes(b'root-glue') + (source/'root-hash_bg.wasm').write_bytes(b'root-wasm') + (source/'rolegraph').mkdir(); (source/'js').mkdir() + for name in ['rolegraph/terraphim_rolegraph_browser.js', + 'rolegraph/terraphim_rolegraph_browser_bg.wasm', 'js/rolegraph-worker.js']: + (source/name).write_bytes(name.encode()) + stage = pathlib.Path(temporary) / 'stage'; stage.mkdir() + metadata = {}; RUNNER.stage_delivery(source, stage, metadata) + assets = metadata['assets'] + for name, asset in assets.items(): + self.assertEqual(asset['sha256'], hashlib.sha256((stage/name).read_bytes()).hexdigest()) + self.assertIn('index.html', assets); self.assertIn('root-hash.js', assets) + self.assertIn('rolegraph/terraphim_rolegraph_browser.js', assets) + manifest = json.dumps(assets, sort_keys=True, separators=(',', ':')).encode() + self.assertEqual(metadata['inputManifestSha256'], hashlib.sha256(manifest).hexdigest()) + def drift(directory): + (directory/'index.html').write_text('changed inline storage') + with self.assertRaisesRegex(RuntimeError, 'hash drift'): + RUNNER.stage_delivery(source, stage, {}, stage_hook=drift) + + @unittest.skipUnless(os.environ.get('ROLEGRAPH_QUOTA_LIVE_EXECUTABLE'), 'opt-in real Chrome; waits 33 seconds') + def test_live_cleanup_fault_cannot_mask_actual_quota_failure(self): + executable = os.environ['ROLEGRAPH_QUOTA_LIVE_EXECUTABLE'] + delivery = os.environ.get('ROLEGRAPH_QUOTA_LIVE_DELIVERY', str(ROOT/'target/rolegraph-delivery')) + with tempfile.TemporaryDirectory(prefix='rolegraph-quota-live-regression-') as temporary: + output = pathlib.Path(temporary)/'cleanup-failure.json' + code = RUNNER.main([delivery, '--executable', executable, '--result', str(output)], cleanup_fault='quota.reset') + evidence = json.loads(output.read_text()) + self.assertTrue(evidence['completed']) + self.assertEqual(evidence['refusal'], 'storage_failed') + self.assertTrue(evidence['previousCheckpointRetained']) + self.assertEqual(code, 1); self.assertEqual(evidence['exitCode'], 1) + self.assertFalse(evidence['ok']) + self.assertFalse(evidence['quotaDiagnostics']['afterReset']['overrideActive']) + later = {'telemetry.remove','editor.destroy','namespace.delete','context.close','browser.close', + 'server.shutdown','server.close','server.thread.join'} + self.assertTrue(later.issubset({step['step'] for step in evidence['cleanup'] if step['ok']})) + self.assertTrue(any(error['where']=='quota.reset' and 'injected' in error['error'] for error in evidence['runnerErrors'])) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/browser/rolegraph-safari-performance-runner.test.py b/tests/browser/rolegraph-safari-performance-runner.test.py new file mode 100644 index 0000000..764cccb --- /dev/null +++ b/tests/browser/rolegraph-safari-performance-runner.test.py @@ -0,0 +1,235 @@ +"""Runner-only regressions; no mocked engine is claimed as Safari measurement.""" +import importlib.util +import json +import pathlib +import tempfile +import unittest +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location('safari_perf', ROOT / 'scripts/ci/rolegraph-safari-performance-check.py') +RUNNER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(RUNNER) + + +class RunnerTests(unittest.TestCase): + def test_native_capability_helpers_reused(self): + self.assertEqual(RUNNER.SAFARI.session_request()['capabilities']['alwaysMatch'], + {'browserName': 'safari', 'platformName': 'mac'}) + for platform in ('mac', 'macOS'): + RUNNER.SAFARI.validate_capabilities({'browserName': 'Safari', 'platformName': platform}) + for caps in ({'browserName': 'webkit', 'platformName': 'mac'}, + {'browserName': 'Safari', 'platformName': 'ios'}, {'browserName': 'Safari'}): + with self.assertRaises(RuntimeError): + RUNNER.SAFARI.validate_capabilities(caps) + + def test_invalid_timeouts_rejected_before_resources(self): + with mock.patch.object(RUNNER.subprocess, 'check_output') as launch: + for option in ('--timeout', '--ready-timeout', '--hidden-dwell'): + for value in ('nan', 'inf', '-inf', '0', '-1'): + with self.assertRaises(SystemExit) as error: + RUNNER.main(['unused', '--result', 'unused', '--profile-label', 'test', option + '=' + value]) + self.assertEqual(error.exception.code, 2) + launch.assert_not_called() + with self.assertRaises(SystemExit): + RUNNER.main(['unused', '--result', 'unused', '--profile-label', 'test', '--hidden-dwell=121']) + + def test_finalization_never_waives_safari_long_tasks(self): + def result(): + return {'completed': True, 'runnerErrors': [], 'longTaskSupported': True, + 'budgets': {'query': True, 'workerWasm': True, 'mainThread': True, 'foreground': True}, + 'visibility': {'qualifiedForeground': True}} + value = result() + self.assertEqual(RUNNER.finalize(value), 0) + value = result() + value.update(longTaskSupported=False) + value['budgets']['mainThread'] = None + self.assertEqual(RUNNER.finalize(value), 2) + self.assertFalse(value['ok']) + for change in ({'completed': False}, {'error': 'harness failure'}, + {'runnerErrors': [{'where': 'cleanup'}]}, {'longTaskSupported': None}): + value = result() + value.update(change) + self.assertEqual(RUNNER.finalize(value), 1) + for budget in ('query', 'workerWasm', 'mainThread', 'foreground'): + value = result() + value['budgets'][budget] = False + self.assertEqual(RUNNER.finalize(value), 1) + value = result() + value.update(longTaskSupported=False, budgets={'query': True, 'workerWasm': True}) + self.assertEqual(RUNNER.finalize(value), 1) + value['budgets'] = None + self.assertEqual(RUNNER.finalize(value), 1) + + def test_visibility_gate_cannot_be_replaced_by_requested_context(self): + visible = {'elapsedMs': 1, 'visibilityState': 'visible', 'hasFocus': False} + hidden = {'elapsedMs': 2, 'visibilityState': 'hidden', 'hasFocus': False} + def value(): + return {'completed': True, 'runnerErrors': [], 'visibility': { + 'invocation': 'visible-request', 'checkpoints': {'queriesBegin': visible, 'queriesEnd': visible}, + 'samples': [{'index': i, 'before': visible, 'after': visible} for i in range(110)], + 'changes': [], 'droppedChanges': 0}} + self.assertTrue(RUNNER.visibility_summary(value())['qualifiedForeground']) + for indices in ([0], list(range(110))): + result = value() + for index in indices: + result['visibility']['samples'][index]['after'] = hidden + self.assertFalse(RUNNER.visibility_summary(result)['qualifiedForeground']) + result = value() + result['visibility']['changes'] = [{'phase': 'queries', **hidden}] + self.assertFalse(RUNNER.visibility_summary(result)['qualifiedForeground']) + result = value() + result['visibility']['samples'].pop() + self.assertFalse(RUNNER.visibility_summary(result)['qualifiedForeground']) + result = value() + result['visibility']['droppedChanges'] = 1 + self.assertFalse(RUNNER.visibility_summary(result)['qualifiedForeground']) + result = value() + result['visibility']['invocation'] = 'hidden-request' + self.assertFalse(RUNNER.visibility_summary(result)['qualifiedForeground']) + result = value() + result['visibility']['checkpoints']['queriesEnd'] = hidden + self.assertFalse(RUNNER.visibility_summary(result)['qualifiedForeground']) + # Summary emits only aggregate state/counts, never unrelated page values. + result = value() + result['visibility']['privateQuery'] = 'PRIVATE' + self.assertNotIn('PRIVATE', json.dumps(RUNNER.visibility_summary(result))) + + def test_instrumentation_is_ordinal_and_outside_query_timer(self): + text = (ROOT / 'tests/browser/performance-rolegraph.html').read_text() + self.assertIn('window.startPerformance', text) + self.assertLess(text.index('const visibilityBefore = observeVisibility();'), text.index('const documents = await client.query')) + self.assertLess(text.index('const ms = performance.now() - started;'), text.index('const visibilityAfter = observeVisibility();')) + self.assertIn('samples.push({index: i, before: visibilityBefore, after: visibilityAfter})', text) + self.assertNotIn('visibilityBefore.term', text) + + def test_poll_timeout_and_request_helper(self): + with mock.patch.object(RUNNER.time, 'monotonic', side_effect=[0, 0, 2, 2]), \ + mock.patch.object(RUNNER.time, 'sleep'), \ + mock.patch.object(RUNNER.SAFARI, 'webdriver', return_value=None) as request: + with self.assertRaisesRegex(RuntimeError, 'polling timeout'): + RUNNER.wait_value('http://127.0.0.1:1', '/session/test', 'return null;', 1) + self.assertEqual(request.call_count, 1) + with mock.patch.object(RUNNER.SAFARI, 'webdriver', side_effect=RuntimeError('HTTP failed')): + with self.assertRaisesRegex(RuntimeError, 'HTTP failed'): + RUNNER.wait_value('http://127.0.0.1:1', '/session/test', 'return null;', 1) + + def test_all_cleanup_actions_attempted(self): + server, thread = mock.Mock(), mock.Mock() + server.shutdown.side_effect = RuntimeError('shutdown failure') + server.server_close.side_effect = RuntimeError('close failure') + thread.is_alive.return_value = True + errors = [] + RUNNER.stop_server(server, thread, lambda where, error: errors.append(where)) + self.assertEqual(errors, ['server.shutdown', 'server.close', 'server.join']) + thread.join.assert_called_once_with(timeout=10) + + def test_driver_timeout_kills_and_reports_failure(self): + driver = mock.Mock() + driver.poll.return_value = None + driver.wait.side_effect = [RUNNER.subprocess.TimeoutExpired('driver', 10), 0] + with self.assertRaisesRegex(RuntimeError, 'required kill'): + RUNNER.stop_driver(driver) + driver.terminate.assert_called_once() + driver.kill.assert_called_once() + self.assertEqual(driver.wait.call_count, 2) + + def test_driver_terminate_failure_still_attempts_kill(self): + driver = mock.Mock() + driver.poll.return_value = None + driver.terminate.side_effect = RuntimeError('terminate failed') + with self.assertRaisesRegex(RuntimeError, 'terminate failed'): + RUNNER.stop_driver(driver) + driver.kill.assert_called_once() + driver.wait.assert_called_once_with(timeout=10) + + def test_failure_writes_non_green_evidence(self): + with tempfile.TemporaryDirectory() as directory: + output = pathlib.Path(directory) / 'result.json' + with mock.patch.object(RUNNER.platform, 'platform', return_value='test macOS'), \ + mock.patch.object(RUNNER.subprocess, 'check_output', side_effect=RuntimeError('driver unavailable')): + code = RUNNER.main(['unused', '--result', str(output), '--profile-label', 'mixed dev/release provisional synthetic']) + value = json.loads(output.read_text()) + self.assertEqual(code, 1) + self.assertEqual(value['exitCode'], 1) + self.assertFalse(value['ok']) + self.assertTrue(value['runnerErrors']) + + def test_main_resource_failures_cleanup_and_write_evidence(self): + # Entirely runner semantics: these doubles are NOT measured engine passes. + for fault in ('cleanup', 'capability', 'post-measurement-drift', 'window-operation'): + with self.subTest(fault=fault), tempfile.TemporaryDirectory() as directory: + output = pathlib.Path(directory) / 'result.json' + driver, server, thread = mock.Mock(), mock.Mock(), mock.Mock() + driver.poll.return_value = None + server.server_port = 123 + thread.is_alive.return_value = False + calls = [] + def request(base, method, route, payload=None): + calls.append((method, route)) + if fault == 'window-operation' and route.endswith('/window/new'): + raise RuntimeError('unsupported new window') + if route == '/session': + return {'sessionId': 'test', 'capabilities': {'browserName': 'safari', + 'platformName': 'ios' if fault == 'capability' else 'mac'}} + return {} + def cleanup_fault(): + raise RuntimeError('injected cleanup failure') + with mock.patch.object(RUNNER.platform, 'platform', return_value='test macOS'), \ + mock.patch.object(RUNNER.subprocess, 'check_output', return_value='test driver'), \ + mock.patch.object(RUNNER.subprocess, 'Popen', return_value=driver), \ + mock.patch.object(RUNNER, 'stage_assets'), \ + mock.patch.object(RUNNER.http.server, 'ThreadingHTTPServer', return_value=server), \ + mock.patch.object(RUNNER.threading, 'Thread', return_value=thread), \ + mock.patch.object(RUNNER.SAFARI, 'webdriver', side_effect=request), \ + mock.patch.object(RUNNER.SAFARI, 'verify_stage', side_effect= + RuntimeError('staged drift') if fault == 'post-measurement-drift' else None), \ + mock.patch.object(RUNNER, 'wait_value', return_value={'completed': True, + 'longTaskSupported': False, 'budgets': {'query': True, 'workerWasm': True, 'mainThread': None}}): + code = RUNNER.main(['unused', '--result', str(output), '--profile-label', 'runner test', + '--visibility-context', 'hidden-request' if fault == 'window-operation' else 'visible'], + cleanup_hook=cleanup_fault if fault == 'cleanup' else None) + value = json.loads(output.read_text()) + self.assertEqual(code, 1) + self.assertFalse(value['ok']) + self.assertTrue(value['runnerErrors']) + self.assertIn(('DELETE', '/session/test'), calls) + driver.terminate.assert_called_once() + driver.wait.assert_called_once_with(timeout=10) + server.shutdown.assert_called_once() + server.server_close.assert_called_once() + thread.join.assert_called_once_with(timeout=10) + + def test_staging_manifest_identical_to_existing_performance_runner(self): + with tempfile.TemporaryDirectory() as directory: + base = pathlib.Path(directory) + delivery = base / 'delivery' + for name in ('js/rolegraph-worker.js', 'terraphim-editor.min.js', + 'rolegraph/terraphim_rolegraph_browser.js', 'rolegraph/terraphim_rolegraph_browser_bg.wasm'): + path = delivery / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'runner unit test asset, not an engine') + first, second = {}, {} + RUNNER.PERF.stage_assets(delivery, ROOT, base / 'existing', first) + RUNNER.stage_assets(delivery, base / 'safari', second) + self.assertEqual(first['assets'], second['assets']) + self.assertEqual(first['inputManifestSha256'], second['inputManifestSha256']) + self.assertEqual(set(second['helperDependencies']), + {'rolegraph-safari-check.py', 'rolegraph-performance-check.py'}) + for name, asset in second['assets'].items(): + self.assertEqual(RUNNER.hashlib.sha256((base / 'safari' / name).read_bytes()).hexdigest(), asset['sha256']) + def drift(stage): + (stage / 'js/rolegraph-worker.js').write_bytes(b'drift') + with self.assertRaisesRegex(RuntimeError, 'Staged input hash drift'): + RUNNER.stage_assets(delivery, base / 'drift', {}, drift) + + def test_harness_source_drift_rejected(self): + with mock.patch.object(RUNNER.PERF, 'stage_assets'), \ + mock.patch.object(RUNNER, 'identity', side_effect=[(1,), (1,), + *[item['identity'] for item in RUNNER.DEPENDENCIES.values()], (2,)]): + with self.assertRaisesRegex(RuntimeError, 'harness changed'): + RUNNER.stage_assets(pathlib.Path('unused'), pathlib.Path('unused'), {}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/browser/rolegraph-safari-runner.test.py b/tests/browser/rolegraph-safari-runner.test.py new file mode 100644 index 0000000..099a40a --- /dev/null +++ b/tests/browser/rolegraph-safari-runner.test.py @@ -0,0 +1,131 @@ +"""Runner regressions only; these do not stand in for actual Safari evidence.""" +import importlib.util +import json +import pathlib +import tempfile +import unittest +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location('safari_runner', ROOT / 'scripts/ci/rolegraph-safari-check.py') +RUNNER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(RUNNER) + + +class RunnerTests(unittest.TestCase): + def test_session_request_requires_mac_without_remote_host(self): + self.assertEqual(RUNNER.session_request(), {'capabilities': { + 'alwaysMatch': {'browserName': 'safari', 'platformName': 'mac'}}}) + + def test_returned_platform_required_and_validated(self): + for name in ['mac', 'Mac', 'macOS', 'MACOS']: + RUNNER.validate_capabilities({'browserName': 'Safari', 'platformName': name}) + for name in [None, '', 'ios', 'linux', 'windows', 'mac OS X', 7]: + with self.assertRaises(RuntimeError): + RUNNER.validate_capabilities({'browserName': 'safari', 'platformName': name}) + with self.assertRaises(RuntimeError): + RUNNER.validate_capabilities({'browserName': 'webkit', 'platformName': 'mac'}) + with self.assertRaises(RuntimeError): + RUNNER.validate_capabilities({'browserName': 'safari'}) + + def test_nonfinite_and_nonpositive_timeout_rejected_before_resources(self): + with mock.patch.object(RUNNER, 'stage_assets') as stage, \ + mock.patch.object(RUNNER.subprocess, 'check_output') as driver, \ + mock.patch.object(RUNNER.subprocess, 'Popen', side_effect=RuntimeError('unexpected launch')) as launch: + for timeout in ['nan', 'inf', '-inf', '0', '-1']: + with self.assertRaises(SystemExit) as error: + RUNNER.main(['unused', '--result', 'unused.json', '--profile-label', 'test', + '--timeout=' + timeout]) + self.assertEqual(error.exception.code, 2) + stage.assert_not_called() + driver.assert_not_called() + launch.assert_not_called() + + def test_failed_case_retains_only_controlled_diagnostics(self): + cases = {} + value = {'ok': False, 'checks': [], 'code': 'storage_failed', + 'error': 'Error: arbitrary PRIVATE corpus and snapshot', + 'userAgent': 'arbitrary PRIVATE', 'snapshot': 'PRIVATE'} + with self.assertRaises(RuntimeError): + RUNNER.record_case(cases, 'delivery', value, 27) + self.assertEqual(cases['delivery'], {'ok': False, 'count': 0, 'expectedCount': 27, + 'fixture': 'delivery', 'code': 'storage_failed', 'errorClass': 'typed-storage_failed'}) + self.assertNotIn('PRIVATE', json.dumps(cases)) + with self.assertRaises(RuntimeError): + RUNNER.record_case(cases, 'originalWorker', {'ok': False, + 'error': 'Error: WASM initialization failed', 'code': 'PRIVATE'}, 33) + self.assertEqual(cases['originalWorker']['errorClass'], 'wasm-initialization') + self.assertEqual(cases['originalWorker']['code'], 'unrecognized') + with self.assertRaises(RuntimeError): + RUNNER.record_case(cases, 'reviewWorker', {'ok': False, 'error': 'Error: step 12 query /ok PRIVATE'}, 123) + self.assertEqual(cases['reviewWorker']['errorClass'], 'worker-assertion') + self.assertNotIn('PRIVATE', json.dumps(cases)) + with self.assertRaises(RuntimeError): + RUNNER.record_case(cases, 'originalWorker', {'ok': False, 'steps': 'PRIVATE', + 'error': 'PRIVATE' * 10000}, 33) + self.assertEqual(cases['originalWorker']['errorClass'], 'harness-failure') + self.assertIsNone(cases['originalWorker']['count']) + self.assertLess(len(json.dumps(cases['originalWorker'])), 200) + self.assertNotIn('PRIVATE', json.dumps(cases)) + + def test_counts_and_failed_results_cannot_pass(self): + for label, _, _, count in RUNNER.CASES: + value = {'ok': True, 'steps': count, 'checks': ['check'] * count} + self.assertTrue(RUNNER.aggregate(label, value, count)['ok']) + with self.assertRaises(RuntimeError): + RUNNER.aggregate(label, value, count + 1) + value['ok'] = False + with self.assertRaises(RuntimeError): + RUNNER.aggregate(label, value, count) + + def test_cleanup_failure_does_not_skip_other_resources(self): + calls, errors = [], [] + def failing(): + calls.append('first') + raise RuntimeError('cleanup failed') + RUNNER.cleanup([('first', failing), ('second', lambda: calls.append('second'))], + lambda where, error: errors.append(where)) + self.assertEqual(calls, ['first', 'second']) + self.assertEqual(errors, ['first']) + + def test_cleanup_error_overrides_completed_green_cases(self): + result = {'completed': True, 'runnerErrors': [], 'cases': { + label: {'ok': True, 'count': count, 'expectedCount': count} + for label, _, _, count in RUNNER.CASES}} + self.assertEqual(RUNNER.finalize(result), 0) + def failing(): + raise RuntimeError('cleanup failed after successful execution') + RUNNER.cleanup([('session.delete', failing)], + lambda where, error: result['runnerErrors'].append({'where': where, 'error': str(error)})) + self.assertEqual(RUNNER.finalize(result), 1) + self.assertFalse(result['ok']) + + def test_staging_hash_drift_rejected_without_delivery_changes(self): + with tempfile.TemporaryDirectory() as directory: + base = pathlib.Path(directory) + delivery = base / 'delivery' + names = ['terraphim_editor_bg.wasm', 'terraphim-editor.min.js', 'js/rolegraph-worker.js', + 'rolegraph/terraphim_rolegraph_browser.js', 'rolegraph/terraphim_rolegraph_browser_bg.wasm'] + for name in names: + path = delivery / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'qualification test bytes') + before = {name: (delivery / name).read_bytes() for name in names} + def drift(stage): + (stage / names[0]).write_bytes(b'drift') + with self.assertRaisesRegex(RuntimeError, 'Staged input hash drift'): + RUNNER.stage_assets(delivery, ROOT, base / 'stage', {}, drift) + self.assertEqual(before, {name: (delivery / name).read_bytes() for name in names}) + + def test_missing_delivery_is_non_green(self): + with tempfile.TemporaryDirectory() as directory: + result = pathlib.Path(directory) / 'result.json' + code = RUNNER.main([directory, '--result', str(result), '--profile-label', 'regression']) + evidence = json.loads(result.read_text()) + self.assertEqual(code, 1) + self.assertFalse(evidence['ok']) + self.assertTrue(evidence['runnerErrors']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/browser/rolegraph-storage.test.cjs b/tests/browser/rolegraph-storage.test.cjs new file mode 100644 index 0000000..6a45287 --- /dev/null +++ b/tests/browser/rolegraph-storage.test.cjs @@ -0,0 +1,31 @@ +// Unit tests: IndexedDB transaction double, NOT real-browser durability evidence. +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +require('../../public/js/rolegraph-storage.js'); +function databaseDouble() { + const entries=new Map(); const transactions=[]; let opens=0; + const db={objectStoreNames:{contains:()=>true},close(){this.closed=true;},transaction(){ + const tx={objectStore(){return {put(value,key){tx.write={value,key};},get(key){const request={}; queueMicrotask(()=>{request.result=entries.get(key);request.onsuccess?.();});return request;}}},abort(){tx.onabort?.();}}; transactions.push(tx); return tx; + }}; + const indexedDB={open(){opens++; const request={}; queueMicrotask(()=>{request.result=db;request.onsuccess();});return request;}}; + return {db,indexedDB,transactions,entries,get opens(){return opens;},complete(){const tx=transactions.at(-1);if(tx.write)entries.set(tx.write.key,tx.write.value);tx.oncomplete();},fail(){transactions.at(-1).onabort();}}; +} +test('opt-in and explicit namespace required; disabled never opens DB',async()=>{ + const d=databaseDouble();const s=new TeRoleGraphStorage({enabled:false,indexedDB:d.indexedDB}); await assert.rejects(s.save('x'),{code:'storage_failed'});assert.equal(d.opens,0); + assert.throws(()=>new TeRoleGraphStorage({enabled:true,indexedDB:d.indexedDB}),{code:'invalid_input'}); +}); +test('opaque text saves only on transaction completion, loads exact bytes',async()=>{ + const d=databaseDouble();const s=new TeRoleGraphStorage({enabled:true,namespace:'embed-a',indexedDB:d.indexedDB}); + const text='{"graphJson":"{\"id\":9007199254740993}"}';let saved=false;const p=s.save(text).then(()=>saved=true);await new Promise(setImmediate);assert.equal(saved,false);d.complete();await p; + const load=s.load();await new Promise(setImmediate);d.complete();assert.equal(await load,text);s.close();assert.ok(d.db.closed);assert.equal(d.entries.size,1); +}); +test('aborted overwrite preserves previous checkpoint',async()=>{ + const d=databaseDouble();const s=new TeRoleGraphStorage({enabled:true,namespace:'embed-b',indexedDB:d.indexedDB}); + const p=s.save('previous');await new Promise(setImmediate);d.complete();await p; + const bad=s.save('next');await new Promise(setImmediate);d.fail();await assert.rejects(bad,{code:'storage_failed'});assert.deepEqual([...d.entries.values()],['previous']);s.close(); +}); +test('open denial is typed; close aborts unfinished transactions',async()=>{ + const denied={open(){const r={};queueMicrotask(()=>r.onerror());return r;}}; + await assert.rejects(new TeRoleGraphStorage({enabled:true,namespace:'denied',indexedDB:denied}).load(),{code:'storage_failed'}); + const d=databaseDouble();const s=new TeRoleGraphStorage({enabled:true,namespace:'close',indexedDB:d.indexedDB});const p=s.save('data');await new Promise(setImmediate);s.close();await assert.rejects(p,{code:'storage_failed'});assert.equal(d.entries.size,0); +}); diff --git a/tests/browser/rolegraph-worker.test.cjs b/tests/browser/rolegraph-worker.test.cjs new file mode 100644 index 0000000..7c1807c --- /dev/null +++ b/tests/browser/rolegraph-worker.test.cjs @@ -0,0 +1,21 @@ +// Unit tests: bridge double only. Real WASM + browser Worker qualification is separate. +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const {Worker}=require('node:worker_threads'); +const {pathToFileURL}=require('node:url'); +const path=require('node:path'); +const {once}=require('node:events'); +function worker() { + const url=pathToFileURL(path.resolve(__dirname,'../../public/js/rolegraph-worker.js')).href; + return new Worker(`const {parentPort}=require('node:worker_threads');globalThis.self={addEventListener:(_,fn)=>parentPort.on('message',data=>fn({data})),postMessage:data=>parentPort.postMessage(data)};import(${JSON.stringify(url)}).then(()=>parentPort.postMessage({type:'boot'}));`,{eval:true}); +} +test('module worker dynamically initializes glue and delegates opaque dispatch',async t=>{ + const w=worker();t.after(()=>w.terminate());await once(w,'message'); + const glue=`export default async function init(config){if(config.module_or_path!=='explicit-wasm-url')throw Error('url');}export class BrowserRoleGraph{dispatch(text){const r=JSON.parse(text);return JSON.stringify({version:1,requestId:r.requestId,generation:r.generation,revision:'0',ok:true,result:{snapshotText:'{opaque large IDs 9007199254740993}',op:r.op}});}}`; + let next=once(w,'message');w.postMessage({type:'init',glueUrl:'data:text/javascript;base64,'+Buffer.from(glue).toString('base64'),wasmUrl:'explicit-wasm-url'});assert.deepEqual((await next)[0],{type:'ready'}); + next=once(w,'message');w.postMessage({version:1,requestId:'1',generation:'0',expectedRevision:null,op:'export_snapshot',payload:{}});const reply=(await next)[0];assert.equal(reply.result.op,'export_snapshot');assert.equal(reply.result.snapshotText,'{opaque large IDs 9007199254740993}'); +}); +test('initialization and missing bridge failures are typed without raw exception text',async t=>{ + const w=worker();t.after(()=>w.terminate());await once(w,'message');let next=once(w,'message');w.postMessage({version:1,requestId:'early',generation:'0',expectedRevision:null,op:'stats',payload:{}});assert.equal((await next)[0].error.code,'worker_failed'); + next=once(w,'message');w.postMessage({type:'init',glueUrl:'data:text/javascript,throw new Error("secret corpus")',wasmUrl:'x'});const reply=(await next)[0];assert.equal(reply.error.code,'worker_failed');assert.ok(!JSON.stringify(reply).includes('secret corpus')); +}); diff --git a/tests/browser/run-synonym-sharing.cjs b/tests/browser/run-synonym-sharing.cjs new file mode 100644 index 0000000..a7db5b5 --- /dev/null +++ b/tests/browser/run-synonym-sharing.cjs @@ -0,0 +1,43 @@ +// Run the existing real user-flow fixture against immutable packaged assets. +'use strict'; +const fs = require('node:fs/promises'); +const path = require('node:path'); +const http = require('node:http'); +const {spawn} = require('node:child_process'); +const {createHash} = require('node:crypto'); +const assets = path.resolve(process.env.TERRAPHIM_AUTHORITY_ASSETS || ''); +const output = path.resolve(process.env.TERRAPHIM_STORY_OUT || ''); +if (!process.env.TERRAPHIM_AUTHORITY_ASSETS || !process.env.TERRAPHIM_STORY_OUT) throw Error('Explicit assets and output paths required'); +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const served = {}; +const html = '
'; +const types = {'.js':'text/javascript','.css':'text/css','.json':'application/json','.wasm':'application/wasm','.svg':'image/svg+xml','.woff2':'font/woff2'}; +const server = http.createServer(async (request,response) => { + try { + const name = decodeURIComponent(new URL(request.url,'http://localhost').pathname); + if (name === '/') { response.writeHead(200,{'content-type':'text/html; charset=utf-8'}); response.end(html); return; } + const file = path.resolve(assets,'.'+name); + if (!file.startsWith(assets+path.sep)) throw Error('Outside frozen assets'); + const bytes = await fs.readFile(file); + served[path.relative(assets,file)] = hash(bytes); + response.writeHead(200,{'content-type':types[path.extname(file)]||'application/octet-stream'}); + response.end(bytes); + } catch {response.writeHead(404);response.end();} +}); +(async () => { + await fs.mkdir(output,{recursive:true}); + await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);}); + let exitCode; + try { + const origin = `http://127.0.0.1:${server.address().port}/`; + const script = process.env.TERRAPHIM_STORY_SCRIPT || 'synonym-sharing.cjs'; + if (!/^[a-z0-9-]+\.cjs$/.test(script)) throw Error('A browser fixture filename is required'); + const child = spawn(process.execPath,[path.join(__dirname,script)],{stdio:'inherit',env:{...process.env,TERRAPHIM_STORY_URL:origin}}); + exitCode = await new Promise((resolve,reject)=>{child.once('error',reject);child.once('exit',(code,signal)=>resolve(signal?1:code));}); + const manifest = JSON.parse(await fs.readFile(path.join(assets,'host-assets.json'),'utf8')); + const mismatches = Object.entries(served).filter(([name,digest])=>manifest.assets[name]!==digest); + await fs.writeFile(path.join(output,'runtime-assets.json'),JSON.stringify({status:exitCode===0&&mismatches.length===0?'PASS':'FAIL',exitCode,assets,manifestSha256:hash(await fs.readFile(path.join(assets,'host-assets.json'))),served,mismatches},null,2)+'\n'); + if(mismatches.length)exitCode=1; + } finally {await new Promise(resolve=>server.close(resolve));} + process.exitCode=exitCode; +})().catch(error=>{console.error(error);server.close();process.exitCode=1;}); diff --git a/tests/browser/runtime-assets-staging.test.cjs b/tests/browser/runtime-assets-staging.test.cjs new file mode 100644 index 0000000..c779728 --- /dev/null +++ b/tests/browser/runtime-assets-staging.test.cjs @@ -0,0 +1,36 @@ +// Git ignore-rule regression, not a build or browser acceptance test. +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const {spawnSync} = require('node:child_process'); +const fs = require('node:fs'); + +function ignored(paths) { + const result = spawnSync('git', ['check-ignore', '--no-index', '--stdin', '-z'], { + input: paths.join('\0') + '\0', encoding: 'utf8', + }); + assert.ok(result.status === 0 || result.status === 1, result.stderr); + return result.stdout.split('\0').filter(Boolean); +} + +test('all pinned source and release vendor members can be staged normally', () => { + const manifest = JSON.parse(fs.readFileSync('public/vendor/manifest.json', 'utf8')); + const members = ['manifest.json', ...manifest.files.map(file => file.path)]; + const paths = ['public/vendor/', 'dist/vendor/', 'package/dist/vendor/'] + .flatMap(base => members.map(member => base + member)); + assert.deepEqual(ignored(paths), []); +}); + +test('worker and snippet release paths can be staged normally', () => { + assert.deepEqual(ignored([ + 'dist/js/rolegraph-worker.js', + 'dist/rolegraph/terraphim_rolegraph_browser.js', + 'dist/rolegraph/terraphim_rolegraph_browser_bg.wasm', + 'dist/snippets/terraphim-editor-fixture/inline0.js', + ]), []); +}); + +test('Cargo vendor output, credentials, caches and stray dist files remain ignored', () => { + const paths = ['vendor/cargo-fixture/lib.rs', '.cargo/credentials.toml', + 'target/runtime-fixture.json', 'dist/private-fixture.json']; + assert.deepEqual(ignored(paths), paths); +}); diff --git a/tests/browser/runtime-assets.test.cjs b/tests/browser/runtime-assets.test.cjs new file mode 100644 index 0000000..5837dac --- /dev/null +++ b/tests/browser/runtime-assets.test.cjs @@ -0,0 +1,74 @@ +// Orchestration unit tests with DOM doubles; NOT browser/offline acceptance. +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +function harness() { + const links = []; + const window = {}; + const context = { window, URL, Set, Map, Promise, Error, TypeError, + document: { baseURI: 'http://localhost/nested/editor/index.html', + querySelectorAll: () => links, + createElement: () => ({ addEventListener(name, fn) { this[name] = fn; }, + remove() { links.splice(links.indexOf(this), 1); } }), + head: { appendChild(link) { links.push(link); } } }, + customElements: { get() { return undefined; } }, setTimeout, clearTimeout, + }; + vm.runInNewContext(fs.readFileSync('public/js/runtime-assets.js', 'utf8'), context); + return { assets: window.TerraphimRuntimeAssets, links, context }; +} +test('asset base validation preserves nested script base and rejects unsafe origins', () => { + const { assets } = harness(); + assert.equal(assets.resolveBase({}, 'http://localhost/nested/editor/bundle.js'), 'http://localhost/nested/editor/'); + for (const assetBase of ['file:///tmp/', 'data:text/plain,a', 'https://user:pass@localhost/', 'https://cdn.example/']) { + assert.throws(() => assets.resolveBase({ assetBase }, 'http://localhost/a.js')); + } + assert.equal(assets.resolveBase({assetBase: 'https://assets.example/base/', allowExternalAssets: true}, 'http://localhost/a.js'), 'https://assets.example/base/'); +}); +test('styles wait for actual load, deduplicate, and failed stylesheet can retry', async () => { + const { assets, links } = harness(); + const url = 'http://localhost/nested/editor/vendor/a.css'; + const first = assets.loadStyle(url); + assert.equal(assets.loadStyle(url), first); + let ready = false; + first.then(() => { ready = true; }); + await Promise.resolve(); + assert.equal(ready, false); + links[0].load(); + await first; + const bad = assets.loadStyle(url + '?bad'); + links[1].error(); + await assert.rejects(bad, /stylesheet/); + const retry = assets.loadStyle(url + '?bad'); + assert.equal(links.length, 2); + links[1].load(); + await retry; +}); +test('finite icon resolver never falls back remotely or accepts path injection', () => { + const { assets } = harness(); + const resolver = assets.iconResolver('http://localhost/nested/vendor/'); + assert.equal(resolver('question-circle'), 'http://localhost/nested/vendor/assets/icons/question-circle.svg'); + for (const name of ['../../a', 'https://example/a', 'unknown', '', 'constructor']) { + assert.equal(resolver(name), ''); + } +}); +test('host-owned existing custom elements require explicit opt-out', async () => { + const { assets, context } = harness(); + context.customElements.get = () => function HostElement() {}; + await assert.rejects(assets.load({assetBase: 'http://localhost/nested/editor/'}), /loadShoelace: false/); +}); +test('concurrent boots deduplicate and incompatible page-global bases fail explicitly', async () => { + const { assets, links } = harness(); + const options = {assetBase: 'http://localhost/nested/editor/'}; + const first = assets.load(options); + assert.equal(assets.load(options), first); + assert.equal(links.length, 5); + await assert.rejects(assets.load({assetBase: 'http://localhost/other/'}), /Incompatible/); + links.slice().forEach(link => link.error()); + await assert.rejects(first, /stylesheet/); + const retry = assets.load(options); + assert.notEqual(retry, first); + assert.equal(links.length, 5); + links.slice().forEach(link => link.error()); + await assert.rejects(retry, /stylesheet/); +}); diff --git a/tests/browser/runtime_assets_check_test.py b/tests/browser/runtime_assets_check_test.py new file mode 100644 index 0000000..9f1d698 --- /dev/null +++ b/tests/browser/runtime_assets_check_test.py @@ -0,0 +1,49 @@ +"""Real filesystem negative tests, no browser acceptance claim.""" +import importlib.util +from pathlib import Path +import shutil +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('checker', ROOT / 'scripts/ci/runtime-assets-check.py') +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +class AssetIntegrity(unittest.TestCase): + def test_real_source(self): + self.assertEqual(checker.check(ROOT / 'public/vendor'), 107) + + def test_tampering_and_missing_assets_fail(self): + paths = ['shoelace/2.12.0/cdn/components/input/input.js', + 'fontawesome/6.5.2/webfonts/fa-solid-900.woff2', + 'shoelace/2.12.0/cdn/assets/icons/code.svg', + 'fontawesome/6.5.2/LICENSE.txt', 'manifest.json'] + for path in paths: + with self.subTest(path=path), tempfile.TemporaryDirectory() as temp: + vendor = Path(temp) / 'vendor' + shutil.copytree(ROOT / 'public/vendor', vendor) + (vendor / path).write_bytes(b'tampered') + with self.assertRaises(ValueError): + checker.check(vendor) + (vendor / path).unlink() + with self.assertRaises((ValueError, OSError)): + checker.check(vendor) + + def test_unexpected_file_and_symlink_fail(self): + with tempfile.TemporaryDirectory() as temp: + vendor = Path(temp) / 'vendor' + shutil.copytree(ROOT / 'public/vendor', vendor) + extra = vendor / 'extra' + extra.write_bytes(b'not approved') + with self.assertRaises(ValueError): + checker.check(vendor) + extra.unlink() + extra.symlink_to(ROOT / 'index.html') + with self.assertRaises(ValueError): + checker.check(vendor) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/browser/safari_offline_check_test.py b/tests/browser/safari_offline_check_test.py new file mode 100644 index 0000000..64c87b0 --- /dev/null +++ b/tests/browser/safari_offline_check_test.py @@ -0,0 +1,138 @@ +"""Native Safari runner scaffolding tests; protocol doubles are NOT browser proof.""" +import importlib.util +import json +from pathlib import Path +import shutil +import tempfile +import threading +import unittest +from unittest import mock +import urllib.error +import urllib.request + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('safari_offline', ROOT / 'scripts/ci/editor-safari-offline-check.py') +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) + + +class NativeOfflineScaffolding(unittest.TestCase): + def test_policy_and_path_boundary(self): + for directive in ["default-src 'self'", "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'", + "style-src 'self' 'unsafe-inline'", "font-src 'self'", "connect-src 'self'", + "worker-src 'self'", "img-src 'self' data:", "base-uri 'self'", "object-src 'none'"]: + self.assertIn(directive, runner.CSP) + self.assertNotIn('https:', runner.CSP) + self.assertNotIn("'unsafe-eval'", runner.CSP) + self.assertTrue(runner.safe_path('/abc/nested/editor/index.html')) + for path in ['/../index.html', '/abc/../a', '//abc/a', '/abc/%2e%2e/a', + '/abc\\a', '/abc//a', '/abc/./a', 'index.html']: + self.assertFalse(runner.safe_path(path), path) + + def test_real_local_http_headers_and_exact_allowlist(self): + server = runner.PolicyServer({'/token/index.html': b'synthetic fixture'}) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + origin = f'http://127.0.0.1:{server.server_port}' + with urllib.request.urlopen(origin + '/token/index.html?ignored=1', timeout=3) as response: + self.assertEqual(response.headers['Content-Security-Policy'], runner.CSP) + self.assertEqual(response.headers['Cache-Control'], 'no-store, max-age=0') + self.assertEqual(response.headers['X-Content-Type-Options'], 'nosniff') + self.assertIn(b'synthetic fixture', response.read()) + for path in ['/unknown', '/token/%2e%2e/index.html', '/token/../index.html']: + with self.assertRaises(urllib.error.HTTPError) as caught: + urllib.request.urlopen(origin + path, timeout=3) + self.assertEqual(caught.exception.code, 404) + self.assertEqual(caught.exception.headers['Content-Security-Policy'], runner.CSP) + caught.exception.close() + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + self.assertFalse(thread.is_alive()) + + def test_capture_preserves_real_vendor_and_hashes_instrumented_pages(self): + # Synthetic delivery glue/index: validates capture, not WASM readiness. + scratch = ROOT / 'target/safari-offline-unit' + scratch.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=scratch) as temp: + delivery = Path(temp) / 'delivery' + delivery.mkdir() + shutil.copytree(ROOT / 'public/vendor', delivery / 'vendor') + for name in ('terraphim-editor.min.js', 'terraphim_editor.js', 'terraphim_editor_bg.wasm', + 'terraphim-editor.min.css'): + (delivery / name).write_bytes(b'synthetic protocol fixture') + (delivery / 'index.html').write_text('') + (delivery / 'private.txt').write_bytes(b'not in served allowlist') + payload, source, staged = runner.capture(delivery, 'a' * 32) + prefix = '/' + 'a' * 32 + self.assertNotIn('private.txt', source) + self.assertNotIn(prefix + '/nested/editor/private.txt', payload) + self.assertIn(runner.EARLY.encode(), payload[prefix + '/nested/editor/index.html']) + self.assertEqual(payload[prefix + '/fixture/thesaurus.json'], + (ROOT / 'tests/fixtures/kg/thesaurus.json').read_bytes()) + for name, data in payload.items(): + self.assertEqual(staged[name]['sha256'], runner.hashlib.sha256(data).hexdigest()) + self.assertNotEqual(source['index.html']['sha256'], staged[prefix + '/nested/editor/index.html']['sha256']) + (delivery / 'linked.js').symlink_to(ROOT / 'public/js/config.js') + with self.assertRaisesRegex(ValueError, 'symlink'): + runner.capture(delivery, 'b' * 32) + + def test_native_capabilities_and_async_wire_contract(self): + safari = runner.module('native_scaffolding', ROOT / 'scripts/ci/rolegraph-safari-check.py') + self.assertEqual(safari.session_request()['capabilities']['alwaysMatch'], + {'browserName': 'safari', 'platformName': 'mac'}) + safari.validate_capabilities({'browserName': 'Safari', 'platformName': 'macOS'}) + for capability in [{'browserName': 'webkit', 'platformName': 'mac'}, + {'browserName': 'safari', 'platformName': 'iOS'}, {'browserName': 'safari'}]: + with self.assertRaises(RuntimeError): + safari.validate_capabilities(capability) + calls = [] + class WireDouble: + @staticmethod + def webdriver(base, method, route, payload): + calls.append((base, method, route, payload)) + return {'ok': True, 'value': {'synthetic': True}} + result = runner.async_function(WireDouble, 'http://127.0.0.1:1', '/session/owned', runner.NEGATIVE) + self.assertEqual(runner.checked_value(result), {'synthetic': True}) + self.assertEqual(calls[0][2], '/session/owned/execute/async') + self.assertIn('security', runner.EARLY) + self.assertIn('missing-csp-denial-event', calls[0][3]['script']) + self.assertIn('requestRejected:true', calls[0][3]['script']) + with self.assertRaises(RuntimeError): + runner.checked_value({'ok': False, 'error': 'must not copy page contents'}) + + def test_negative_controls_require_observable_events_not_dns_failure(self): + # Source/protocol guards only: actual denial requires native browser run. + for name in ('connect.denied.invalid', 'script.denied.invalid', 'style.denied.invalid'): + self.assertIn(name, runner.NEGATIVE) + self.assertIn('e.directive.startsWith(control.directive)', runner.NEGATIVE) + self.assertIn('e.blockedURI.startsWith(new URL(control.url).origin)', runner.NEGATIVE) + self.assertIn('if (!rejected)', runner.NEGATIVE) + self.assertIn('window.__offlineViolations.length', runner.FORMAT) + self.assertIn('button.click()', runner.FORMAT) + self.assertIn('font-glyph-style', runner.FORMAT) + self.assertIn('help.click()', runner.FORMAT) + self.assertIn('e.trusted === true', runner.NEGATIVE) + + def test_missing_asset_fails_real_preflight_before_native_driver(self): + scratch = ROOT / 'target/safari-offline-unit' + scratch.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=scratch) as temp: + delivery = Path(temp) / 'delivery' + delivery.mkdir() # Missing vendor: real checker must fail first. + report = Path(temp) / 'result.json' + with mock.patch.object(runner.subprocess, 'check_output') as version: + status = runner.main([str(delivery), '--result', str(report), + '--profile-label', 'synthetic-missing-asset', '--scratch', str(Path(temp) / 'scratch')]) + self.assertEqual(status, 1) + version.assert_not_called() + result = json.loads(report.read_text()) + self.assertFalse(result['ok']) + self.assertEqual(result['cases'], []) + self.assertEqual(result['errors'][0]['errorClass'], 'CalledProcessError') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/browser/safari_offline_diagnostics_test.py b/tests/browser/safari_offline_diagnostics_test.py new file mode 100644 index 0000000..b0d3477 --- /dev/null +++ b/tests/browser/safari_offline_diagnostics_test.py @@ -0,0 +1,399 @@ +"""Diagnostic schema/protocol units; no native browser proof.""" +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('safari_diagnostics', ROOT / 'scripts/ci/editor-safari-offline-check.py') +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) + + +def sample_with(wire): + return lambda base, route, timeout: wire.webdriver(base, 'POST', route + '/execute/sync', + {'script': runner.VISIBILITY_SCRIPT, 'args': []}) + + +class SafeDiagnostics(unittest.TestCase): + def test_readonly_visibility_transport_uses_remaining_socket_bound(self): + response = io.BytesIO(json.dumps({'value': {'visibility': 'visible', 'documentFocused': True}}).encode()) + with patch.object(runner.urllib.request, 'urlopen', return_value=response) as request: + self.assertEqual(runner.visibility_request('http://127.0.0.1:1', '/session/PRIVATE', .025), + {'visibility': 'visible', 'documentFocused': True}) + args, kwargs = request.call_args + self.assertEqual(kwargs['timeout'], .025) + self.assertEqual(json.loads(args[0].data), {'script': runner.VISIBILITY_SCRIPT, 'args': []}) + self.assertNotIn('focus()', runner.VISIBILITY_SCRIPT) + with patch.object(runner.urllib.request, 'urlopen', side_effect=TimeoutError('PRIVATE')): + with self.assertRaises(runner.RequestError) as caught: + runner.visibility_request('http://127.0.0.1:1', '/session/PRIVATE', .025) + self.assertEqual(runner.error_record(caught.exception, 'visibility-precondition')['code'], 'webdriver-timeout') + self.assertNotIn('PRIVATE', str(caught.exception)) + + def test_invalid_visibility_and_request_errors_are_not_retried(self): + with patch.object(runner, 'visibility_request', return_value={'visibility': 'PRIVATE'}) as request, patch('builtins.print'): + with self.assertRaises(runner.OracleError) as caught: + runner.wait_visible_window('', '', runner.time.monotonic() + 60) + self.assertEqual(caught.exception.code, 'environment-visibility-unavailable') + self.assertEqual(request.call_count, 1) + error = runner.RequestError('visibility-snapshot', RuntimeError('WebDriver: unknown error: PRIVATE')) + with patch.object(runner, 'visibility_request', side_effect=error) as request, patch('builtins.print'): + with self.assertRaises(runner.RequestError): + runner.wait_visible_window('', '', runner.time.monotonic() + 60) + self.assertEqual(request.call_count, 1) + + def test_readiness_notice_precedes_poll_and_success_is_bounded(self): + events = [] + clock = [100.0] + class Wire: + @staticmethod + def webdriver(base, method, route, payload=None): + events.append('sample') + assert 'focus()' not in payload['script'] + return {'visibility': 'visible' if clock[0] > 100 else 'hidden', + 'documentFocused': clock[0] > 100} + def sleep(duration): + self.assertLessEqual(duration, .1) + clock[0] += duration + with patch.object(runner.time, 'monotonic', side_effect=lambda: clock[0]), \ + patch.object(runner, 'visibility_request', side_effect=sample_with(Wire)), \ + patch.object(runner.time, 'sleep', side_effect=sleep), \ + patch('builtins.print', side_effect=lambda *args, **kwargs: events.append(('notice', args, kwargs))): + result = runner.wait_visible_window('', '/session/PRIVATE', 160) + self.assertEqual(events[0], ('notice', (runner.VISIBILITY_NOTICE,), {'flush': True})) + self.assertEqual(events[1:], ['sample', 'sample']) + self.assertEqual(result, {'visibility': 'visible', 'documentFocused': True}) + self.assertNotIn('PRIVATE', str(events)) + + def test_visibility_wait_exhaustion_respects_remaining_budget_and_cap(self): + for remaining, expected in [(.05, .05), (60, 10)]: + clock = [100.0] + class Wire: + @staticmethod + def webdriver(base, method, route, payload=None): + return {'visibility': 'hidden', 'documentFocused': False, 'body': 'PRIVATE'} + with patch.object(runner.time, 'monotonic', side_effect=lambda: clock[0]), \ + patch.object(runner, 'visibility_request', side_effect=sample_with(Wire)), \ + patch.object(runner.time, 'sleep', side_effect=lambda duration: clock.__setitem__(0, clock[0] + duration)), \ + patch('builtins.print'): + with self.assertRaises(runner.OracleError) as caught: + runner.wait_visible_window('', '', 100 + remaining) + self.assertEqual(caught.exception.code, 'environment-visibility-budget-exhausted') + self.assertAlmostEqual(clock[0], 100 + expected) + self.assertEqual(caught.exception.diagnostics['visibility'], 'hidden') + self.assertNotIn('PRIVATE', str(caught.exception.diagnostics)) + + def test_visibility_wait_never_accepts_late_success_or_samples_expired_budget(self): + clock = [100.0] + class Wire: + calls = 0 + @classmethod + def webdriver(cls, base, method, route, payload=None): + cls.calls += 1 + clock[0] = 111.0 + return {'visibility': 'visible', 'documentFocused': True} + with patch.object(runner.time, 'monotonic', side_effect=lambda: clock[0]), \ + patch.object(runner, 'visibility_request', side_effect=sample_with(Wire)), patch('builtins.print'): + with self.assertRaises(runner.OracleError): + runner.wait_visible_window('', '', 160) + self.assertEqual(Wire.calls, 1) + with self.assertRaises(runner.OracleError): + runner.wait_visible_window('', '', 110) + self.assertEqual(Wire.calls, 1) + + def test_owned_window_requires_exact_single_session_handle(self): + self.assertEqual(runner.validate_owned_window('PRIVATE-HANDLE', ['PRIVATE-HANDLE']), 'PRIVATE-HANDLE') + for current, handles in [(None, []), ('PRIVATE-HANDLE', ['OTHER']), + ('PRIVATE-HANDLE', ['PRIVATE-HANDLE', 'PERSONAL']), + ('PRIVATE-HANDLE', ['PRIVATE-HANDLE'] * 2), ('', ['']), + ('x', 'x')]: + with self.assertRaises(runner.OracleError) as caught: + runner.validate_owned_window(current, handles) + record = runner.error_record(caught.exception, 'window-ownership') + self.assertEqual(record['code'], 'environment-window-ownership') + self.assertNotIn('PRIVATE', str(record)) + self.assertNotIn('PERSONAL', str(record)) + + def test_visibility_requires_visible_and_true_focus(self): + self.assertEqual(runner.visibility_code({'visibility': 'visible', 'documentFocused': True}), None) + for value, code in [({'visibility': 'hidden', 'documentFocused': True}, 'environment-document-hidden'), + ({'visibility': 'visible', 'documentFocused': False}, 'environment-document-unfocused'), + ({'visibility': 'visible', 'documentFocused': 1}, 'environment-visibility-unavailable'), + ({'visibility': 'PRIVATE', 'documentFocused': False}, 'environment-visibility-unavailable'), + (None, 'environment-visibility-unavailable')]: + self.assertEqual(runner.visibility_code(value), code) + + def test_window_precondition_targets_only_owned_handle_and_gates_visibility(self): + class Wire: + calls = [] + @classmethod + def webdriver(cls, base, method, route, payload=None): + cls.calls.append((method, route, payload)) + if route.endswith('/window/handles'): return ['PRIVATE-HANDLE'] + if method == 'GET' and route.endswith('/window'): return 'PRIVATE-HANDLE' + if route.endswith('/execute/sync'): + return {'visibility': 'hidden', 'documentFocused': False, 'body': 'PRIVATE-BODY'} + return None + with patch.object(runner, 'visibility_request', side_effect=sample_with(Wire)), patch('builtins.print'): + with self.assertRaises(runner.OracleError) as caught: + runner.visible_window_precondition(Wire, '', '/session/test', 'PRIVATE-HANDLE', True, runner.time.monotonic() + .001) + self.assertEqual(caught.exception.code, 'environment-visibility-budget-exhausted') + self.assertNotIn('PRIVATE', str(caught.exception.diagnostics)) + switch = [c for c in Wire.calls if c[0] == 'POST' and c[1].endswith('/window')] + self.assertEqual(switch[0][2], {'handle': 'PRIVATE-HANDLE'}) + rect = [c for c in Wire.calls if c[1].endswith('/window/rect')] + self.assertEqual(rect[0][2], {'width': 1280, 'height': 900}) + self.assertFalse(any('maximize' in c[1] or 'screenshot' in c[1] or 'execute/async' in c[1] for c in Wire.calls)) + Wire.calls.clear() + with patch.object(runner, 'visibility_request', side_effect=sample_with(Wire)), patch('builtins.print'): + with self.assertRaises(runner.OracleError): + runner.visible_window_precondition(Wire, '', '/session/test', 'PRIVATE-HANDLE', False, runner.time.monotonic() + .001) + self.assertFalse(any(c[1].endswith('/window/rect') for c in Wire.calls)) + + def test_handle_mismatch_stops_before_switch_rect_or_script(self): + class Wire: + calls = [] + @classmethod + def webdriver(cls, base, method, route, payload=None): + cls.calls.append(method) + return ['FOREIGN-HANDLE'] if route.endswith('/handles') else 'FOREIGN-HANDLE' + with self.assertRaises(runner.OracleError): + runner.visible_window_precondition(Wire, '', '/session/test', 'OWNED-HANDLE', True, runner.time.monotonic() + 60) + self.assertTrue(all(method == 'GET' for method in Wire.calls)) + + def test_visible_owned_window_passes_only_curated_state(self): + class Wire: + @staticmethod + def webdriver(base, method, route, payload=None): + if route.endswith('/handles'): return ['OWNED'] + if method == 'GET': return 'OWNED' + if route.endswith('/execute/sync'): + return {'visibility': 'visible', 'documentFocused': True, 'body': 'PRIVATE'} + return None + with patch.object(runner, 'visibility_request', side_effect=sample_with(Wire)), patch('builtins.print'): + self.assertEqual(runner.visible_window_precondition(Wire, '', '/session/test', 'OWNED', False, runner.time.monotonic() + 60), + {'visibility': 'visible', 'documentFocused': True}) + subprocess.run(['node', '--check'], input=runner.VISIBILITY_SCRIPT.replace('return {', 'const state = {'), + text=True, capture_output=True, timeout=10, check=True) + + def test_window_command_error_is_finite_and_visibility_precedes_positive(self): + class Wire: + @staticmethod + def webdriver(base, method, route, payload=None): + raise RuntimeError('WebDriver HTTP 500: unsupported operation: PRIVATE-HANDLE URL BODY') + with self.assertRaises(runner.RequestError) as caught: + runner.session_window(Wire, '', '/session/private') + record = runner.error_record(caught.exception, 'window-ownership') + self.assertEqual(record['request'], 'get-window-handle') + self.assertEqual(record['code'], 'webdriver-unsupported-operation') + self.assertNotIn('PRIVATE', str(record)) + source = (ROOT / 'scripts/ci/editor-safari-offline-check.py').read_text() + self.assertLess(source.index('visibility = visible_window_precondition'), + source.index('positive = bounded_probe')) + + def test_test_server_policy_allows_only_self_and_nonnetwork_data_connections(self): + directives = {parts[0]: parts[1:] for clause in runner.CSP.split(';') + if (parts := clause.strip().split())} + self.assertEqual(directives['connect-src'], ["'self'", 'data:']) + self.assertEqual(directives, { + 'default-src': ["'self'"], + 'script-src': ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'"], + 'style-src': ["'self'", "'unsafe-inline'"], + 'font-src': ["'self'"], 'connect-src': ["'self'", 'data:'], + 'worker-src': ["'self'"], 'img-src': ["'self'", 'data:'], + 'base-uri': ["'self'"], 'object-src': ["'none'"], + }) + for forbidden in ('https:', 'http:', '*', 'blob:', 'javascript:'): + self.assertNotIn(forbidden, directives['connect-src']) + + def test_positive_subphase_copy_preserves_original_operations(self): + probes = runner.module('positive_probe_unit', ROOT / 'scripts/ci/editor-offline-check.py') + original = probes.PROBE + instrumented = runner.instrument_positive_probe(original) + self.assertEqual(probes.PROBE, original) + self.assertIn("'positive-dialog-show'", instrumented) + self.assertIn("'positive-dialog-show-resolved'", instrumented) + self.assertIn("'positive-dialog-hide'", instrumented) + self.assertEqual(instrumented.count('await dialog.show();'), 1) + self.assertEqual(instrumented.count('await dialog.hide();'), 1) + self.assertIn('observeDiagnosticDialog(dialog)', instrumented) + with self.assertRaisesRegex(ValueError, 'positive-diagnostic-source-contract'): + runner.instrument_positive_probe(original.replace('await dialog.show();', 'changedOperation();')) + + def test_exact_failed_webdriver_request_is_retained_without_session_url(self): + class TimeoutWire: + @staticmethod + def webdriver(base, method, route, payload): + raise RuntimeError('WebDriver: timeout: PRIVATE SESSION URL') + with self.assertRaises(runner.RequestError) as caught: + runner.async_function(TimeoutWire, 'http://127.0.0.1:1', '/session/private-id', runner.FORMAT) + record = runner.error_record(caught.exception, 'positive', 'fullPage') + self.assertEqual(record['request'], 'execute-async') + self.assertEqual(record['code'], 'webdriver-timeout') + self.assertNotIn('private-id', str(record)) + self.assertNotIn('PRIVATE', str(record)) + + def test_dialog_snapshot_is_finite_and_nontextual(self): + projected = runner.sanitize_snapshot({'asyncBridge': 'entered', 'visibility': 'visible', + 'documentFocused': False, 'dialogEvents': {'show': 1, 'afterShow': 0, 'private': 'BODY'}, + 'dialogAnimationStates': ['running', 'finished', 'private BODY'], 'dialogPanelHidden': False}) + self.assertEqual(projected['dialogEvents'], {'show': 1, 'afterShow': 0}) + self.assertEqual(projected['dialogAnimationStates'], ['running', 'finished']) + self.assertEqual(projected['asyncBridge'], 'entered') + self.assertNotIn('BODY', str(projected)) + + def test_script_timeout_configuration_request_is_distinguished(self): + class TimeoutWire: + @staticmethod + def webdriver(base, method, route, payload): + raise RuntimeError('WebDriver: timeout: PRIVATE') + with self.assertRaises(runner.RequestError) as caught: + runner.bounded_probe(TimeoutWire, '', '/private', runner.FORMAT, runner.time.monotonic() + 60) + self.assertEqual(runner.error_record(caught.exception, 'positive')['request'], 'set-script-timeout') + + def test_passive_dialog_event_observer_and_generated_scripts(self): + script = r'''const vm=require('node:vm'),fs=require('node:fs'),assert=require('node:assert/strict'); +const input=JSON.parse(fs.readFileSync(0,'utf8')), window={}, listeners={}; +const dialog={addEventListener(name,fn){listeners[name]=fn;}}; +vm.runInNewContext(input.observer+';observeDiagnosticDialog(dialog);',{window,dialog}); +listeners['sl-show']({target:dialog}); listeners['sl-after-show']({target:{}}); +listeners['sl-after-show']({target:dialog}); listeners['sl-hide']({target:dialog}); +listeners['sl-after-hide']({target:dialog}); +assert.deepEqual(JSON.parse(JSON.stringify(window.__offlineDialogEvents)),{show:1,afterShow:1,hide:1,afterHide:1}); +for (const fn of input.functions) new vm.Script('('+fn+')'); +new vm.Script(input.bridge); +''' + class Wire: + @staticmethod + def webdriver(base, method, route, payload): + return payload['script'] + probes = runner.module('positive_probe_syntax', ROOT / 'scripts/ci/editor-offline-check.py') + positive = runner.instrument_positive_probe(probes.PROBE) + subprocess.run(['node', '-e', script], input=json.dumps({ + 'observer': runner.DIALOG_DIAGNOSTIC_JS, + 'functions': [positive, runner.SNAPSHOT, runner.FORMAT, runner.NEGATIVE], + 'bridge': runner.async_function(Wire, '', '', positive)}), + text=True, capture_output=True, timeout=10, check=True) + + def test_known_page_code_and_phase_survive_bridge(self): + with self.assertRaises(runner.OracleError) as caught: + runner.checked_value({'ok': False, 'code': 'font-glyph-style', + 'diagnostics': {'readyState': 'complete', 'body': 'PRIVATE CONTENT'}}) + error = runner.error_record(caught.exception, 'formatting', 'fullPage') + self.assertEqual(error['code'], 'font-glyph-style') + self.assertEqual(error['phase'], 'formatting') + self.assertEqual(error['case'], 'fullPage') + self.assertNotIn('PRIVATE', str(error)) + + def test_unknown_error_content_never_escapes(self): + error = runner.error_record(RuntimeError('private/path credential body alias'), 'positive', 'embed') + self.assertEqual(error['code'], 'unclassified') + self.assertNotIn('private', str(error)) + with self.assertRaises(runner.OracleError) as caught: + runner.checked_value({'ok': False, 'code': 'private secret', 'message': 'PRIVATE BODY'}) + self.assertEqual(caught.exception.code, 'unknown-page-error') + + def test_snapshot_allowlist_rejects_arbitrary_strings_and_fields(self): + value = runner.sanitize_snapshot({'readyState': 'complete', 'probeStep': 'negative-module', + 'editorReady': True, 'surfaceText': 'PRIVATE', 'readyVariable': 'secret', + 'components': {'input': True, 'private-name': True}, + 'csp': [{'directive': 'connect-src', 'blocked': 'negative-control', 'trusted': True}, + {'directive': 'private', 'blocked': 'https://private.invalid/body', 'trusted': True}]}) + self.assertEqual(value['components'], {'input': True}) + self.assertEqual(value['csp'], [{'directive': 'connect-src', 'blocked': 'negative-control', 'trusted': True}]) + self.assertEqual(value['probeStep'], 'negative-module') + self.assertNotIn('PRIVATE', str(value)) + self.assertNotIn('private', str(value)) + + def test_wire_script_classifies_without_returning_message_or_stack(self): + calls = [] + class WireDouble: + @staticmethod + def webdriver(base, method, route, payload): + calls.append(payload['script']) + return {'ok': False, 'code': 'unknown-page-error'} + runner.async_function(WireDouble, 'http://127.0.0.1:1', '/session/owned', runner.FORMAT) + self.assertIn('code:', calls[0]) + self.assertIn('diagnostics:', calls[0]) + self.assertNotIn('stack:', calls[0]) + self.assertNotIn('message:error', calls[0]) + + def test_webdriver_typed_error_classification_is_bounded(self): + error = runner.error_record(RuntimeError('WebDriver: script timeout: private body'), 'positive', 'fullPage') + self.assertEqual(error['code'], 'webdriver-script-timeout') + self.assertNotIn('private', str(error)) + + def test_malformed_page_values_remain_sanitized(self): + self.assertEqual(runner.sanitize_snapshot({'readyState': [], 'probeStep': {}, + 'csp': [{'directive': [], 'blocked': {}, 'trusted': True}]}), {'csp': []}) + with self.assertRaises(runner.OracleError) as caught: + runner.checked_value({'ok': False, 'code': ['private'], 'diagnostics': 'private'}) + self.assertEqual(caught.exception.code, 'unknown-page-error') + + def test_scheme_only_csp_target_normalizes_without_raw_uri(self): + # JS snapshot unit in confined VM, not a Safari observation. + script = r'''const vm=require('node:vm'), fs=require('node:fs'), assert=require('node:assert/strict'); +const snapshot=JSON.parse(fs.readFileSync(0,'utf8')).snapshot; +const value=vm.runInNewContext('('+snapshot+')()',{ + window:{__offlineViolations:[{directive:'connect-src',blockedURI:'data',trusted:true}]}, + document:{readyState:'complete',getElementById(){return null;},hasFocus(){return false;}}, + customElements:{get(){return undefined;}},location:{origin:'http://127.0.0.1:1'},URL, +}); +assert.equal(value.csp[0].blocked,'data'); +assert.equal('blockedURI' in value.csp[0],false); +''' + subprocess.run(['node', '-e', script], input=json.dumps({'snapshot': runner.SNAPSHOT}), + text=True, capture_output=True, timeout=10, check=True) + + def test_finite_uri_classifier_and_fetch_delegate(self): + script = r'''const vm=require('node:vm'),fs=require('node:fs'),assert=require('node:assert/strict'); +const code=JSON.parse(fs.readFileSync(0,'utf8')).code; +class RequestDouble {constructor(url){this.url=url;}} +const {classifyURI,instrumentFetch}=vm.runInNewContext(code+';({classifyURI,instrumentFetch})',{URL,Request:RequestDouble}); +const base='http://127.0.0.1:1/uuid/nested/editor/index.html'; +for(const [value,expected] of [['data','data'],['DATA:','data'],['data:image/svg+xml,PRIVATE','data'], + ['blob','blob'],['blob:private','blob'],['inline','inline'],['eval','eval'],['','empty'], + ['databaseexample','other'],['private alias','other'],[null,'other'],['data:'+ 'x'.repeat(33000),'other'], + ['https://remote.invalid/PRIVATE?secret=1','external-network'],['http://127.0.0.1:1/PRIVATE','self']]) { + assert.equal(classifyURI(value,base),expected); +} +const promise=Promise.resolve('synthetic');let captured; +const window={fetch:function(...args){captured={receiver:this,args};return promise;}}; +instrumentFetch(window,{baseURI:base}); +const input=new RequestDouble('data:image/svg+xml,PRIVATE');const options={fixture:true}; +assert.equal(window.fetch(input,options),promise);assert.equal(captured.receiver,window); +assert.equal(captured.args[0],input);assert.equal(captured.args[1],options); +assert.equal(window.__offlineFetchCounts.production.data,1); +window.__offlineProbeStep='negative-fetch';window.fetch('https://connect.denied.invalid/probe'); +assert.equal(window.__offlineFetchCounts.negative['negative-control'],1); +assert.equal(JSON.stringify(window.__offlineFetchCounts).includes('PRIVATE'),false); +assert.equal(JSON.stringify(window.__offlineFetchCounts).includes('https://'),false); +''' + subprocess.run(['node', '-e', script], input=json.dumps({'code': runner.URI_DIAGNOSTIC_JS}), + text=True, capture_output=True, timeout=10, check=True) + + def test_event_target_retains_only_category_and_fixed_public_origin(self): + script = r'''const vm=require('node:vm'),fs=require('node:fs'),assert=require('node:assert/strict'); +const code=JSON.parse(fs.readFileSync(0,'utf8')).early;const listeners={}; +const window={fetch(){return Promise.resolve();}}; +vm.runInNewContext(code,{window,document:{baseURI:'http://127.0.0.1:1/index.html'},URL,Request:class {}, + addEventListener(name,fn){listeners[name]=fn;}}); +listeners.securitypolicyviolation({effectiveDirective:'connect-src',blockedURI:'data:image/svg+xml,PRIVATE',isTrusted:true}); +listeners.securitypolicyviolation({effectiveDirective:'connect-src',blockedURI:'https://connect.denied.invalid/PRIVATE?credential=1',isTrusted:true}); +assert.equal(window.__offlineViolations[0].blocked,'data'); +assert.equal(window.__offlineViolations[0].blockedURI,''); +assert.equal(window.__offlineViolations[1].blockedURI,'https://connect.denied.invalid'); +assert.equal(JSON.stringify(window.__offlineViolations).includes('PRIVATE'),false); +assert.equal(JSON.stringify(window.__offlineViolations).includes('credential'),false); +''' + early = runner.EARLY.removeprefix('') + subprocess.run(['node', '-e', script], input=json.dumps({'early': early}), + text=True, capture_output=True, timeout=10, check=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/browser/synonym-sharing.cjs b/tests/browser/synonym-sharing.cjs index 78d105a..1d66e66 100644 --- a/tests/browser/synonym-sharing.cjs +++ b/tests/browser/synonym-sharing.cjs @@ -1,10 +1,25 @@ -const {chromium}=require(process.env.TERRAPHIM_PLAYWRIGHT_MODULE || 'playwright');const fs=require('fs'),assert=require('assert'),crypto=require('crypto'); +const {chromium,firefox,webkit}=require(process.env.TERRAPHIM_PLAYWRIGHT_MODULE || 'playwright');const fs=require('fs'),assert=require('assert'),crypto=require('crypto'); const out=process.env.TERRAPHIM_STORY_OUT || fs.mkdtempSync(require('os').tmpdir()+'/terraphim-synonym-sharing-');fs.mkdirSync(out+'/corpus',{recursive:true}); const brief='# Community garden launch\n\nGoal: Open the garden for spring planting.\n\nRisk: Timber may arrive late. Decision: Keep a backup supplier.\n\nRisk: Rain may delay planting. Decision: Reserve a covered area.\n\nRisk: Volunteers may cancel. Decision: Keep a reserve list.'; async function choose(p,s,word){const box=await s.evaluate((el,word)=>{const w=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);while(w.nextNode()){const n=w.currentNode;const at=n.textContent.indexOf(word+':');if(at>=0){const r=document.createRange();r.setStart(n,at);r.setEnd(n,at+word.length);const b=r.getBoundingClientRect();return {x:b.x+b.width/2,y:b.y+b.height/2};}}},word);assert(box);await p.mouse.dblclick(box.x,box.y);await p.keyboard.press('Control+Shift+A');} -(async()=>{const b=await chromium.launch({channel:'chrome',headless:true});const p=await b.newPage({viewport:{width:1440,height:900},acceptDownloads:true});const errors=[];p.on('pageerror',e=>errors.push(e.message));try{await p.goto(process.env.TERRAPHIM_STORY_URL||'http://127.0.0.1:8093/');await p.getByRole('button',{name:/words.*chars/}).waitFor();const s=p.getByRole('textbox',{name:'Markdown source',exact:true});await s.fill(brief);await p.getByRole('button',{name:/words.*chars/}).click();await choose(p,s,'Risk');const add=p.getByRole('textbox',{name:'New alternative: type it and press Enter'});await add.fill('Concern');await add.press('Enter');assert((await s.innerText()).includes('Concern:'));await p.keyboard.press('Escape');await s.press('Control+z');let undo=await p.evaluate(()=>JSON.parse(window.terraphimEditor.exportKnowledgeGraph()));assert.deepEqual(undo.data,{});assert((await s.innerText()).includes('Risk:'));await s.press('Control+Shift+z');let redo=await p.evaluate(()=>JSON.parse(window.terraphimEditor.exportKnowledgeGraph()));assert.equal(redo.data.risk.id,redo.data.concern.id);assert((await s.innerText()).includes('Concern:')); +(async()=>{const kind=process.env.TERRAPHIM_STORY_BROWSER||'chrome';if(!['chrome','firefox','webkit'].includes(kind))throw Error('Unsupported story browser');const engine=kind==='firefox'?firefox:kind==='webkit'?webkit:chromium;const b=await engine.launch({...(kind==='chrome'?{channel:'chrome'}:{}),headless:true});const p=await b.newPage({viewport:{width:1440,height:900},acceptDownloads:true});const errors=[];p.on('pageerror',e=>errors.push(e.message));try{await p.goto(process.env.TERRAPHIM_STORY_URL||'http://127.0.0.1:8093/');await p.getByRole('button',{name:/words.*chars/}).waitFor();const s=p.getByRole('textbox',{name:'Markdown source',exact:true});await s.fill(brief);await p.getByRole('button',{name:/words.*chars/}).click();await choose(p,s,'Risk');const add=p.getByRole('textbox',{name:'New alternative: type it and press Enter'});await add.fill('Concern');await add.press('Enter');assert((await s.innerText()).includes('Concern:'));await p.keyboard.press('Escape');await s.press('Control+z');let undo=await p.evaluate(()=>JSON.parse(window.terraphimEditor.exportKnowledgeGraph()));assert.deepEqual(undo.data,{});assert((await s.innerText()).includes('Risk:'));await s.press('Control+Shift+z');let redo=await p.evaluate(()=>JSON.parse(window.terraphimEditor.exportKnowledgeGraph()));assert.equal(redo.data.risk.id,redo.data.concern.id);assert((await s.innerText()).includes('Concern:')); await choose(p,s,'Concern');const second=p.getByRole('textbox',{name:/Alternative 2 of 2/});await second.press('ArrowUp');assert((await s.innerText()).includes('Risk:'));await p.getByRole('textbox',{name:/Alternative 1 of 2/}).press('ArrowDown');assert((await s.innerText()).includes('Concern:'));await p.getByRole('textbox',{name:/Alternative 2 of 2/}).press('ArrowUp');assert((await s.innerText()).includes('Risk:'));await p.keyboard.press('Escape'); const saved=await p.evaluate(()=>window.terraphimEditor.saveDocument());fs.writeFileSync(out+'/annotated.md',saved);await p.evaluate(saved=>window.terraphimEditor.openDocument(saved,'project-brief.md'),saved);assert.deepEqual(await p.evaluate(()=>JSON.parse(window.terraphimEditor.exportKnowledgeGraph())),redo); -if(!(await p.locator('.te-chrome-markdown').isVisible()))await p.getByRole('button',{name:/words.*chars/}).click();await p.locator('.te-chrome-markdown').click();await p.getByRole('button',{name:'Knowledge graph',exact:true}).click();const dlg=p.getByRole('dialog',{name:'Knowledge graph',exact:true});assert((await dlg.innerText()).includes('Risk ↔ Concern'));await dlg.getByText('View synonym definitions',{exact:true}).click();assert((await dlg.innerText()).includes('synonyms:: Risk, Concern'));await p.screenshot({path:out+'/knowledge-graph.png'}); +if(!(await p.locator('.te-chrome-markdown').isVisible()))await p.getByRole('button',{name:/words.*chars/}).click();await p.locator('.te-chrome-markdown').click();await p.locator('.te-files-button[data-action="knowledge-graph"]').click();const dlg=p.getByRole('dialog',{name:'Knowledge graph',exact:true});assert((await dlg.innerText()).includes('Risk ↔ Concern'));await dlg.getByText('View synonym definitions',{exact:true}).click();assert((await dlg.innerText()).includes('synonyms:: Risk, Concern'));await p.screenshot({path:out+'/knowledge-graph.png'}); let download=p.waitForEvent('download');await dlg.getByRole('button',{name:'Download graph',exact:true}).click();await(await download).saveAs(out+'/knowledge-graph.json');download=p.waitForEvent('download');await dlg.getByRole('button',{name:'Download text',exact:true}).click();await(await download).saveAs(out+'/corpus/project-brief.md');const graph=JSON.parse(fs.readFileSync(out+'/knowledge-graph.json'));assert.deepEqual(graph,redo);const clean=fs.readFileSync(out+'/corpus/project-brief.md','utf8');assert(!/concern/i.test(clean));assert(clean.includes('Risk:'));assert(clean.includes('Decision:')); -await dlg.getByRole('button',{name:'Close knowledge graph'}).click();await p.screenshot({path:out+'/saved-brief.png'});assert.deepEqual(errors,[]);fs.writeFileSync(out+'/browser-receipt.json',JSON.stringify({status:'PASS',nativeKeyboardUndoRedo:true,saveReopen:true,actualDownloads:true,graph,clean,sha256:crypto.createHash('sha256').update(fs.readFileSync(out+'/knowledge-graph.json')).digest('hex'),pageErrors:errors},null,2));console.log('PASS: real UI add/swap, native keyboard undo/redo, save/reopen, exact graph+text downloads');}finally{await b.close();}})().catch(e=>{console.error(e);process.exit(1)}); +await dlg.getByRole('button',{name:'Close knowledge graph'}).click();await p.screenshot({path:out+'/saved-brief.png'});assert.deepEqual(errors,[]); +let downloadSaveReopen=false; +if(kind==='firefox'){ + const fallback=await p.evaluate(()=>({showOpenFilePicker:typeof showOpenFilePicker,showSaveFilePicker:typeof showSaveFilePicker})); + assert.equal(fallback.showSaveFilePicker,'undefined'); + const [written]=await Promise.all([p.waitForEvent('download',{timeout:15000}),p.locator('.te-chrome-save').click()]);await written.saveAs(out+'/downloaded-annotated.md'); + assert.equal(fs.readFileSync(out+'/downloaded-annotated.md','utf8'),saved); + const reopened=await b.newPage({viewport:{width:1440,height:900},acceptDownloads:true}); + try{await reopened.goto(process.env.TERRAPHIM_STORY_URL||'http://127.0.0.1:8093/');await reopened.getByRole('button',{name:/words.*chars/}).click(); + const [chooser]=await Promise.all([reopened.waitForEvent('filechooser',{timeout:15000}),reopened.locator('.te-chrome-open').click()]);await chooser.setFiles(out+'/downloaded-annotated.md'); + await reopened.getByRole('textbox',{name:'Markdown source',exact:true}).filter({hasText:'Community garden launch'}).waitFor(); + assert.equal(await reopened.evaluate(()=>window.terraphimEditor.saveDocument()),saved);assert.deepEqual(await reopened.evaluate(()=>JSON.parse(window.terraphimEditor.exportKnowledgeGraph())),redo); + await reopened.screenshot({path:out+'/download-fallback-reopened.png'});fs.writeFileSync(out+'/download-fallback-capabilities.json',JSON.stringify(fallback,null,2));downloadSaveReopen=true; + }finally{await reopened.close();} +} +fs.writeFileSync(out+'/browser-receipt.json',JSON.stringify({status:'PASS',browser:kind,browserVersion:b.version(),scope:kind==='webkit'?'Playwright WebKit engine; not native Safari qualification':'Actual standalone browser runtime with frozen editor assets',nativeKeyboardUndoRedo:true,saveReopen:true,downloadSaveReopen,actualDownloads:true,graph,clean,sha256:crypto.createHash('sha256').update(fs.readFileSync(out+'/knowledge-graph.json')).digest('hex'),pageErrors:errors},null,2));console.log('PASS: real UI add/swap, native keyboard undo/redo, save/reopen, exact graph+text downloads');}catch(error){await p.screenshot({path:out+'/failure.png'}).catch(()=>{});fs.writeFileSync(out+'/failure.txt',String(error.stack||error)+'\n'+await p.locator('body').innerText().catch(()=>''));throw error;}finally{await b.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/browser/trim-protection.test.cjs b/tests/browser/trim-protection.test.cjs new file mode 100644 index 0000000..a86765d --- /dev/null +++ b/tests/browser/trim-protection.test.cjs @@ -0,0 +1,97 @@ +// Production JS fault-path units with explicit DOM/main-WASM doubles. +// These are NOT browser execution, matcher conformance or Rust evidence. +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function fixture() { + const events = []; let observer; let registrations = 0; + let snapshot = {generation:'1',loaded:true,protectionReady:true}; + const context = vm.createContext({console,URL,setTimeout,clearTimeout,AbortController, + document:{activeElement:null,baseURI:'https://local/',addEventListener(){}}, + CustomEvent:class {constructor(type,options) {this.type=type;this.detail=options.detail;}}}); + context.window = context; + for (const name of ['editor','trim']) vm.runInContext(fs.readFileSync(`public/js/${name}.js`,'utf8'),context); + const api = { + kg_role_snapshot_json:()=>JSON.stringify(snapshot), + kg_set_role_change_listener:fn=>{ observer=fn; registrations++; }, + trim_plan_json:()=>({roleGeneration:snapshot.generation,kgProtected:true,cuts:[]}), + trim_status:()=>({roleGeneration:snapshot.generation,kgProtected:true,active:[]}), + trim_make_cuts:()=>({text:'text',edits:[]}), + }; + const editor = Object.create(context.MarkdownEditor.prototype); + Object.assign(editor,{documentApi:()=>api,root:{dataset:{},dispatchEvent:e=>events.push(e)}, + destroyed:false,graphClient:null,syncGraphRole:()=>events.push('graph'),alignDocumentModel(){}}); + const trim = Object.create(context.TeLabTrim.prototype); + const layer = []; + Object.assign(trim,{editor,destroyed:false,level:'original',plan:null,planText:null,kept:new Set(),active:[], + status:null,walkIndex:-1,cardOpen:false,stale:false,resetTimer:null, + surface:{getText:()=> 'text',root:{dataset:{}},destroyed:false}, + registry:{get:()=>layer,clear:()=>{layer.length=0;}}, + card:{contains:()=>false,hidden:true},walk:{hidden:true},live:{textContent:''}, + lab:{isOpen:false,pill:()=>null},protectionNote:{textContent:''}, + levelButtons:['original','slight','tighten','sharper','half'].map(level=>({dataset:{level},setAttribute(){}})), + }); + return {editor,trim,api,events,layer,notify:()=>observer?.(),registrations:()=>registrations, + setRole:value=>{snapshot=value;}}; +} + +test('one eager observer publishes editor metadata before coordinated graph suppression',()=>{ + const f=fixture(); f.editor.observeRolePublications(); f.editor.observeRolePublications(); + assert.equal(f.registrations(),1); + f.editor.graphRoleCommitInProgress=true; f.notify(); + assert.equal(f.events.length,1); assert.equal(f.events[0].type,'te:role-change'); + assert.equal(f.events[0].detail.editor,f.editor); + assert.deepEqual(Object.keys(f.events[0].detail).sort(),['editor','generation','loaded','protectionReady']); + assert.equal(f.editor.graphClient,null); + f.editor.graphRoleCommitInProgress=false; f.notify(); + assert.equal(f.events[1].type,'te:role-change'); assert.equal(f.events[2],'graph'); +}); + +test('initial loaded snapshot is ready; absent/empty/unsupported role and missing export disable non-Original',()=>{ + const f=fixture(); assert.equal(f.trim.updateProtectionAvailability(),true); + for (const snapshot of [null,{generation:'2',loaded:false,protectionReady:false}, + {generation:'3',loaded:true,protectionReady:false,protectionError:'unsupported_normalization'}]) { + f.setRole(snapshot); assert.equal(f.trim.updateProtectionAvailability(),false); + assert.equal(f.trim.levelButtons[0].disabled,false); + assert.ok(f.trim.levelButtons.slice(1).every(b=>b.disabled)); + assert.equal(f.trim.select('half'),null); + } + delete f.api.kg_role_snapshot_json; + assert.equal(f.trim.updateProtectionAvailability(),false); +}); + +test('same authoritative event retires plan, preview and keeps without model/history edits',()=>{ + const f=fixture(); f.layer.push({start:1,end:2}); f.trim.level='half'; + f.trim.plan={roleGeneration:'1'}; f.trim.kept.add(1); f.trim.cardOpen=true; + f.editor.root.dispatchEvent=e=>{f.events.push(e); f.trim.onRoleChange();}; + f.editor.observeRolePublications(); f.setRole({generation:'2',loaded:false,protectionReady:false}); f.notify(); + assert.equal(f.trim.plan,null); assert.equal(f.trim.level,'original'); + assert.equal(f.layer.length,0); assert.equal(f.trim.kept.size,0); assert.equal(f.trim.card.hidden,true); + assert.ok(f.trim.levelButtons.slice(1).every(b=>b.disabled)); +}); + +test('cached body is insufficient: generation and protection flags must match',()=>{ + const f=fixture(); f.trim.plan={roleGeneration:'1',kgProtected:true}; f.trim.planText='text'; + f.setRole({generation:'2',loaded:true,protectionReady:true}); + assert.equal(f.trim.ensurePlan(f.api).roleGeneration,'2'); + f.trim.plan=null; f.api.trim_plan_json=()=>({roleGeneration:'2'}); + assert.throws(()=>f.trim.ensurePlan(f.api),/KG protection/); assert.equal(f.trim.plan,null); +}); + +test('unsupported document refusal disables actions and clears all effects atomically',()=>{ + const f=fixture(); f.trim.level='half'; f.layer.push({start:1,end:2}); + f.api.trim_plan_json=()=>{throw Error('unsupported_normalization');}; + assert.equal(f.trim.refresh(),null); assert.equal(f.trim.level,'original'); + assert.equal(f.layer.length,0); assert.equal(f.trim.card.hidden,true); + assert.match(f.trim.live.textContent,/unsupported_normalization/); + assert.ok(f.trim.levelButtons.slice(1).every(b=>b.disabled)); +}); + +test('observer exceptions cannot block graph sync and destroyed editor ignores publications',()=>{ + const f=fixture(); f.editor.observeRolePublications(); + f.editor.root.dispatchEvent=()=>{throw Error('DOM double failure');}; f.notify(); + assert.deepEqual(f.events,['graph']); f.editor.destroyed=true; f.notify(); + assert.deepEqual(f.events,['graph']); +}); diff --git a/tests/browser/typing_benchmark_diagnostics_test.py b/tests/browser/typing_benchmark_diagnostics_test.py new file mode 100644 index 0000000..dce51d1 --- /dev/null +++ b/tests/browser/typing_benchmark_diagnostics_test.py @@ -0,0 +1,217 @@ +"""Helper safety regressions only: synthetic files, no browser/build launch.""" + +import hashlib +import contextlib +import io +import os +from pathlib import Path +import tempfile +import types +import unittest +from unittest.mock import patch + + +REPO = Path(__file__).resolve().parents[2] +SCRATCH = Path('/private/var/folders/rb/l4_6j2nx0lnfhysk974ldpg40000gn/T/opencode') +HELPER = REPO / 'scripts/ci/typing-benchmark-diagnostics.py' +LOG = 'docs/validation/evidence/rolegraph-browser/20261009/production/rolegraph-production-editor-browser-bench-controlled-rerun.log' +PASS = 'web_bench: ok (29s wall, finished in 15.81s, load 4.06) test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out\n' +FAKE = 'FAKE_ONLY_PRIVATE_MARKER' + + +class DiagnosticsTests(unittest.TestCase): + def setUp(self): + self.module = types.ModuleType('typing_diagnostics_test_module') + self.module.__file__ = str(HELPER) + exec(compile(HELPER.read_text(), str(HELPER), 'exec'), self.module.__dict__) + self.temp = tempfile.TemporaryDirectory(prefix='typing-diagnostics-test-', dir=SCRATCH) + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.root = self.base / 'repo' + self.root.mkdir() + self.module.ROOT = self.root + self.module.SOURCE_FILES = frozenset({'src/example.rs'}) + + def approved_log(self, text, expected=None): + path = self.root / LOG + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + self.module.APPROVED_LOGS = {LOG: expected or hashlib.sha256(text.encode()).hexdigest()} + return path + + def reject(self, callback): + with self.assertRaises(ValueError) as caught: + callback() + self.assertNotIn(FAKE, str(caught.exception)) + self.assertNotIn(str(self.base), str(caught.exception)) + + def test_source_ancestor_symlink_no_content_read(self): + private = self.base / 'fake-private' + private.mkdir() + (private / 'example.rs').write_text(FAKE) + (self.root / 'src').symlink_to(private, target_is_directory=True) + with patch.object(self.module.os, 'read', wraps=os.read) as read: + self.reject(lambda: self.module.identity(self.root / 'src/example.rs')) + read.assert_not_called() + + def test_source_internal_ancestor_symlink_rejected(self): + actual = self.root / 'actual' + actual.mkdir() + (actual / 'example.rs').write_text(FAKE) + (self.root / 'src').symlink_to(actual, target_is_directory=True) + self.reject(lambda: self.module.identity(self.root / 'src/example.rs')) + + def test_source_leaf_symlink_rejected(self): + (self.root / 'src').mkdir() + fake = self.base / 'fake.rs' + fake.write_text(FAKE) + (self.root / 'src/example.rs').symlink_to(fake) + self.reject(lambda: self.module.identity(self.root / 'src/example.rs')) + + def test_source_unapproved_json_rejected_before_read(self): + path = self.root / 'crates/fake/kg/private.json' + path.parent.mkdir(parents=True) + path.write_text(FAKE) + with patch.object(self.module.os, 'read', wraps=os.read) as read: + self.reject(lambda: self.module.identity(path)) + read.assert_not_called() + + def test_source_outside_root_rejected(self): + path = self.base / 'example.rs' + path.write_text(FAKE) + self.reject(lambda: self.module.identity(path)) + + def test_confined_source_hash(self): + path = self.root / 'src/example.rs' + path.parent.mkdir() + path.write_text('public synthetic source') + self.assertEqual(self.module.identity(path)['sha256'], hashlib.sha256(path.read_bytes()).hexdigest()) + + def test_unapproved_log_with_valid_prefix_rejected_before_read(self): + path = self.root / 'docs/validation/evidence/web_bench-unapproved.log' + path.parent.mkdir(parents=True) + path.write_text(PASS + FAKE) + with patch.object(self.module.os, 'read', wraps=os.read) as read: + self.reject(lambda: self.module.retained_log(str(path))) + read.assert_not_called() + + def test_approved_hash_change_rejected(self): + path = self.approved_log(PASS) + path.write_text(PASS + FAKE) + self.reject(lambda: self.module.retained_log(LOG)) + + def test_log_suffix_injection_rejected_even_matching_manifest_hash(self): + self.approved_log(PASS.rstrip('\n') + ' ' + FAKE + '\n') + self.reject(lambda: self.module.retained_log(LOG)) + + def test_metric_prefix_injection_rejected(self): + self.approved_log('Typing latency (asserted) ' + FAKE + '\n') + self.reject(lambda: self.module.retained_log(LOG)) + + def test_oversized_log_rejected_before_content_read(self): + self.approved_log('x' * (1024 * 1024 + 1)) + with patch.object(self.module.os, 'read', wraps=os.read) as read: + self.reject(lambda: self.module.retained_log(LOG)) + read.assert_not_called() + + def test_evidence_ancestor_symlink_rejected(self): + path = self.approved_log(PASS) + actual = self.base / 'fake-evidence' + path.parent.rename(actual) + path.parent.symlink_to(actual, target_is_directory=True) + self.reject(lambda: self.module.retained_log(LOG)) + + def test_parsed_log_contains_no_raw_lines(self): + self.approved_log(PASS) + result = self.module.retained_log(LOG) + self.assertNotIn('benchmark_lines', result) + self.assertEqual(result['metrics'][0]['harness_seconds'], 15.81) + + def test_unavailable_ps_typed(self): + with patch.object(self.module, 'command', return_value=None): + result = self.module.telemetry() + self.assertEqual(result['process_aggregate']['status'], 'NOT_AVAILABLE') + self.assertIsNone(result['process_aggregate']['metrics']) + + def test_empty_ps_not_zero_measurement(self): + with patch.object(self.module, 'command', return_value=''): + result = self.module.telemetry() + self.assertEqual(result['process_aggregate']['status'], 'NOT_AVAILABLE') + self.assertIsNone(result['process_aggregate']['metrics']) + + def test_malformed_ps_not_zero_measurement(self): + with patch.object(self.module, 'command', return_value='malformed'): + result = self.module.telemetry() + self.assertEqual(result['process_aggregate']['status'], 'NOT_AVAILABLE') + + def test_ps_aggregate_numeric_only(self): + with patch.object(self.module, 'command', return_value='12.5 100\n0.0 200\n'): + result = self.module.telemetry() + self.assertEqual(result['process_aggregate']['metrics']['pcpu_sum'], 12.5) + self.assertEqual(result['process_aggregate']['metrics']['rss_kib_sum'], 300) + + def test_vm_stat_numeric_parser(self): + raw = 'Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 12.\nPages active: 34.\n' + with patch.object(self.module.platform, 'system', return_value='Darwin'), patch.object(self.module, 'command', side_effect=['0.1 100\n', raw, '17179869184\n']): + result = self.module.telemetry() + self.assertEqual(result['vm_stat']['metrics']['page_size_bytes'], 16384) + self.assertEqual(result['vm_stat']['metrics']['pages_free'], 12) + + def test_vm_stat_unavailable_typed(self): + with patch.object(self.module.platform, 'system', return_value='Darwin'), patch.object(self.module, 'command', return_value=None): + result = self.module.telemetry() + self.assertEqual(result['vm_stat']['status'], 'NOT_AVAILABLE') + + def test_wait_noise_validated_not_emitted(self): + self.approved_log(' web_bench: waiting for load 5.98 to drop below 5 (0s of 300s); busiest: Telegram 125.7%, WindowServer 52.9%\n' + PASS) + result = self.module.retained_log(LOG) + self.assertEqual(len(result['metrics']), 1) + self.assertNotIn('Telegram', str(result)) + + def test_root_ancestor_symlink_rejected(self): + actual = self.base / 'actual-root' + self.root.rename(actual) + self.root.symlink_to(actual, target_is_directory=True) + (actual / 'src').mkdir() + (actual / 'src/example.rs').write_text(FAKE) + self.reject(lambda: self.module.identity(self.root / 'src/example.rs')) + + def test_symlink_substitution_after_metadata_rejected(self): + path = self.root / 'src/example.rs' + path.parent.mkdir() + path.write_text('public synthetic source') + private = self.base / 'fake-private' + private.mkdir() + (private / 'example.rs').write_text(FAKE) + original_open = os.open + def substitute(name, flags, *args, **kwargs): + if name == 'src': + path.parent.rename(self.root / 'original-src') + path.parent.symlink_to(private, target_is_directory=True) + return original_open(name, flags, *args, **kwargs) + with patch.object(self.module.os, 'open', side_effect=substitute), patch.object(self.module.os, 'read', wraps=os.read) as read: + self.reject(lambda: self.module.identity(path)) + read.assert_not_called() + + def test_cli_unapproved_log_no_commands_or_stdout(self): + output = io.StringIO() + with patch.object(self.module.sys, 'argv', ['helper', '--run-id', 'synthetic', '--log', 'web_bench;fake.log']), patch.object(self.module, 'command') as command, contextlib.redirect_stdout(output): + self.reject(self.module.main) + command.assert_not_called() + self.assertEqual(output.getvalue(), '') + + def test_incomplete_telemetry_nonzero_collection_status(self): + output = io.StringIO() + with patch.object(self.module.sys, 'argv', ['helper', '--run-id', 'synthetic']), patch.object(self.module, 'snapshot', return_value=({}, {'complete': False})), patch.object(self.module, 'command', return_value=None), contextlib.redirect_stdout(output): + self.assertEqual(self.module.main(), 3) + self.assertIn('METADATA_INCOMPLETE', output.getvalue()) + + def test_untrusted_cli_argument_error_does_not_echo(self): + output = io.StringIO() + with patch.object(self.module.sys, 'argv', ['helper', '--run-id', 'synthetic', '--allow-path', FAKE]), contextlib.redirect_stderr(output): + self.reject(self.module.main) + self.assertEqual(output.getvalue(), '') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/browser/verify-wasm-binaries.py b/tests/browser/verify-wasm-binaries.py new file mode 100644 index 0000000..fb9d237 --- /dev/null +++ b/tests/browser/verify-wasm-binaries.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Run every real browser test binary, retaining failures rather than stopping early.""" +import argparse +import datetime +import hashlib +import json +import os +from pathlib import Path +import re +import signal +import subprocess +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--browser', choices=['chrome', 'firefox'], required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--only-failures-from', type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[2] + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + binaries = sorted(p.stem for p in (root / 'tests').glob('*.rs') + if 'run_in_browser' in p.read_text()) + parent = None + if args.only_failures_from: + raw = args.only_failures_from.read_bytes() + prior = json.loads(raw) + selected = [run['binary'] for run in prior['runs'] if run['status'] != 'PASS'] + if prior['browser'] != args.browser or any(name not in binaries for name in selected): + raise ValueError('Diagnostic selection must use failures from the same browser') + binaries = selected + parent = {'path':str(args.only_failures_from.resolve()), + 'sha256':hashlib.sha256(raw).hexdigest()} + receipt = {'scope': 'Real source-level WASM browser tests; not native extension or Safari qualification', + 'browser': args.browser, 'status': 'RUNNING', 'binaries': binaries, 'runs': [], + 'startedAt': datetime.datetime.now(datetime.timezone.utc).isoformat()} + if parent: + receipt['scope'] = 'Selected failed-binary diagnostic; does not replace the unfiltered audit' + receipt['parentAudit'] = parent + (output / 'executed-runner.py').write_bytes(Path(__file__).read_bytes()) + env = dict(os.environ) + env.pop('WASM_BINDGEN_TEST_TIMEOUT', None) + for binary in binaries: + command = ['wasm-pack', 'test', '--headless', '--' + args.browser, + '--', '--locked', '--test', binary] + log = output / (binary + '.log') + started = time.monotonic() + with log.open('w') as stream: + child = subprocess.Popen(command, cwd=root, env=env, stdout=stream, + stderr=subprocess.STDOUT, start_new_session=True) + try: + code = child.wait(timeout=180) + status = 'PASS' if code == 0 else 'FAIL' + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGTERM) + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + child.wait() + code = child.returncode + status = 'TIMEOUT' + finally: + # Stop only this owned test process group, including browser + # children left behind when a driver exits or times out. + try: + os.killpg(child.pid, signal.SIGTERM) + except ProcessLookupError: + pass + text = log.read_text() + counts = re.findall(r'test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored', text) + if not counts and status == 'PASS': + status = 'NO_RESULT' + run = {'binary': binary, 'command': command, 'processGroup':child.pid, + 'status': status, 'exitCode': code, + 'seconds': round(time.monotonic() - started, 3), 'reportedCounts': counts, + 'logSha256': hashlib.sha256(log.read_bytes()).hexdigest()} + receipt['runs'].append(run) + (output / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') + print(json.dumps(run), flush=True) + receipt['status'] = 'PASS' if all(r['status'] == 'PASS' for r in receipt['runs']) else 'FAIL' + receipt['finishedAt'] = datetime.datetime.now(datetime.timezone.utc).isoformat() + (output / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') + return 0 if receipt['status'] == 'PASS' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/browser/worker-rolegraph.html b/tests/browser/worker-rolegraph.html new file mode 100644 index 0000000..cef445d --- /dev/null +++ b/tests/browser/worker-rolegraph.html @@ -0,0 +1,53 @@ + + +Real RoleGraph Worker conformance +
Running shared native/WASM fixture through actual Worker…
+ diff --git a/tests/fixtures/native-save-regression.md b/tests/fixtures/native-save-regression.md new file mode 100644 index 0000000..a057fee --- /dev/null +++ b/tests/fixtures/native-save-regression.md @@ -0,0 +1,47 @@ +Every choice is a judgment. A large language model can draft it, but the decision is yours. +A café visit is an honour. Drop this aside. +Pass me a binder clip and a rubber. + +I think the plan is perhaps quite good, really. We recieve the report ,and it is basically fine. + +```terraphim-alternatives +{ + "version": 1, + "spans": [ + { + "id": "s1", + "kind": "word", + "anchor": { + "start": 146, + "end": 157, + "text": "binder clip", + "before": " Drop this aside.\nPass me a ", + "after": " and a rubber.\n\nI think the plan" + }, + "active": 1, + "alts": [ + { + "text": "paperclip", + "source": "original" + }, + { + "text": "binder clip", + "source": "human" + } + ] + } + ], + "ghosts": [ + { + "id": "g1", + "anchor": { + "start": 119, + "end": 135, + "text": "Drop this aside.", + "before": "\nA café visit is an honour. ", + "after": "\nPass me a binder clip and a " + } + } + ] +} +``` diff --git a/tests/fixtures/native-trim-done-regression.md b/tests/fixtures/native-trim-done-regression.md new file mode 100644 index 0000000..1c0cadc --- /dev/null +++ b/tests/fixtures/native-trim-done-regression.md @@ -0,0 +1,15 @@ +# Why our release notes are too long + +I think most of our release notes are basically longer than they need to be. Readers open them to answer one question, which is whether the update changes anything they rely on, and they leave as soon as they have the answer. Every extra paragraph makes that answer harder to find. + +The draft for the last release is a good example. It opened with three sentences about the history of the project (which nobody had asked about) before it reached the first change. It then described the new search index in detail, really quite a lot of detail, although only a handful of users ever configure it by hand. The breaking change to the configuration file, the one thing that could actually stop a deployment, appeared near the end, in the second half of a long sentence. + +Perhaps the habit comes from how the notes are written. Each engineer adds a paragraph for their own work, usually at the end of a long day, and nobody reads the whole document from the top before it ships. The result is a list of updates in the order they were merged rather than in the order that matters to a reader. It is honestly not anyone's fault, and it is fairly easy to fix. + +We could start with a simple rule. The first paragraph should say, in plain words, what a reader has to do after upgrading, and it should say nothing else. If the answer is "nothing", the paragraph can simply say so — readers will be grateful for the time it saves them. Everything that follows is reference material, and reference material can be as detailed as it needs to be, because people only read the parts they search for. + +A second rule would help as well. Every change should be described once, in one place, with one sentence of context at most. At the moment the same change is often mentioned in the summary, again in the list of fixes and again in the upgrade guide, each time with slightly different wording, which makes readers wonder whether there are really three separate changes. + +It would also be worth keeping a short glossary. Terms such as "haystack", "role" and "thesaurus" are obvious to the people who build the system, but they are not obvious to someone who installed it last week. A glossary of a dozen entries, linked from the notes, would answer most of the questions that currently arrive in the support channel. + +None of this needs new tooling. It needs one person to read each draft from start to finish, ask what the reader must do, and delete the rest without too much regret. In my experience the notes get shorter, the questions get fewer, and the people who write the notes end up spending less time on them, not more. diff --git a/tests/ghost_transaction_native.rs b/tests/ghost_transaction_native.rs new file mode 100644 index 0000000..f005fc6 --- /dev/null +++ b/tests/ghost_transaction_native.rs @@ -0,0 +1,145 @@ +//! Native public-session contract; no browser or history-capacity claim. +use serde_json::json; +use terraphim_editor::DocumentSession; + +#[test] +fn adjacent_independent_ghost_export_excludes_whitespace_prefixed_suffix() { + let mut session = DocumentSession::new(); + session.open("a bc"); + session + .ghost_ranges_atomic("a bc", &[(0, 1), (1, 4)]) + .unwrap() + .unwrap(); + let before = session.clone(); + assert_eq!(session.document().annotations.ghosts.len(), 2); + assert_eq!(session.export(), ""); + assert_eq!(session, before); +} + +#[test] +fn manual_and_promoted_whitespace_adjacency_round_trips_without_identity_merge() { + for (body, manual, promoted, exported, undo_export) in [ + ("a bc", (1, 4), vec![(0, 1)], "", "a"), + ("a bc", (0, 1), vec![(1, 4)], "", "bc"), + ("a\n\nbc", (1, 5), vec![(0, 1)], "", "a"), + ("a 😀X", (1, 4), vec![(0, 1)], "X", "aX"), + ( + "a bc KEEP de f", + (0, 1), + vec![(1, 4), (10, 12), (12, 14)], + "KEEP", + "bc KEEP de f", + ), + ] { + let mut session = DocumentSession::new(); + session.open(body); + session.ghost(manual.0, manual.1).unwrap(); + let manual_ghosts = session.document().annotations.ghosts.clone(); + let change = session + .ghost_ranges_atomic(body, &promoted) + .unwrap() + .unwrap(); + let after = session.clone(); + let receipt = change.to_json(); + let saved = session.save(); + assert_eq!(session.export(), exported, "{body:?}"); + assert_eq!(session, after, "export/save do not mutate session state"); + assert_eq!( + session.document().annotations.ghosts.len(), + promoted.len() + 1 + ); + let mut reopened = DocumentSession::new(); + reopened.open(&saved); + assert_eq!(reopened.export(), exported); + assert_eq!(reopened.body(), body); + assert_eq!(reopened.save(), saved); + assert_eq!( + reopened.document().annotations.ghosts, + session.document().annotations.ghosts + ); + session.replay_ghost_change(&change, true).unwrap(); + assert_eq!(session.document().annotations.ghosts, manual_ghosts); + assert_eq!(session.export(), undo_export); + assert_eq!( + session.ghost_snapshot().reserved_ids, + change.after.reserved_ids + ); + session.replay_ghost_change(&change, false).unwrap(); + assert_eq!(session, after); + assert_eq!(session.export(), exported); + assert_eq!(session.save(), saved); + assert_eq!(change.to_json(), receipt); + let before = session.clone(); + assert!(session + .ghost_ranges_atomic(body, &[(manual.0, manual.1)]) + .is_err()); + assert_eq!( + session, before, + "overlap is refused, not merged to hide export bugs" + ); + } +} + +#[test] +fn public_transaction_receipt_is_exact_and_survives_annotation_save() { + let mut session = DocumentSession::new(); + session.open("abcdef"); + session.ghost(4, 6).unwrap(); + let manual = + json!({"id":"g1", "anchor":{"start":4,"end":6,"text":"ef","before":"abcd","after":""}}); + let first = + json!({"id":"g2", "anchor":{"start":0,"end":2,"text":"ab","before":"","after":"cdef"}}); + let second = + json!({"id":"g3", "anchor":{"start":2,"end":4,"text":"cd","before":"ab","after":"ef"}}); + let change = session + .ghost_ranges_atomic("abcdef", &[(0, 2), (2, 4)]) + .unwrap() + .unwrap(); + assert_eq!( + change.to_json(), + json!({ + "body":"abcdef", + "before":{"live":[manual.clone()],"setAside":[],"reservedIds":["g1"]}, + "after":{"live":[first,second,manual],"setAside":[],"reservedIds":["g1","g2","g3"]} + }) + ); + let saved = session.save(); + assert!(saved.contains("\"version\": 1")); + let mut reopened = DocumentSession::new(); + reopened.open(&saved); + assert_eq!(reopened.body(), "abcdef"); + assert_eq!(reopened.document().annotations.ghosts.len(), 3); + assert_eq!(reopened.export(), ""); + assert_eq!(reopened.save(), saved); + session.preflight_ghost_change(&change, true).unwrap(); + session.replay_ghost_change(&change, true).unwrap(); + assert_eq!(session.export(), "abcd"); + session.replay_ghost_change(&change, false).unwrap(); + assert_eq!(session.save(), saved); + let before = reopened.clone(); + assert!( + reopened.replay_ghost_change(&change, true).is_err(), + "receipt belongs to issuing open session" + ); + assert_eq!(reopened, before); +} + +#[test] +fn failed_public_commit_and_guard_leave_session_exactly_unchanged() { + let mut session = DocumentSession::new(); + session.open("a😀bc"); + let before = session.clone(); + assert!(session + .ghost_ranges_atomic("a😀bc", &[(0, 1), (2, 3)]) + .is_err()); + assert_eq!(session, before); + let change = session + .ghost_ranges_atomic("a😀bc", &[(1, 3)]) + .unwrap() + .unwrap(); + session.ghost(4, 5).unwrap(); + let before = session.clone(); + assert!(session.preflight_ghost_change(&change, true).is_err()); + assert!(session.replay_ghost_change(&change, true).is_err()); + assert_eq!(session, before); +} diff --git a/tests/history_checkpoints_native.rs b/tests/history_checkpoints_native.rs new file mode 100644 index 0000000..f3ee69c --- /dev/null +++ b/tests/history_checkpoints_native.rs @@ -0,0 +1,177 @@ +//! Real public native checkpoint APIs; not JS history/browser acceptance. +use serde_json::json; +use terraphim_alternatives::{parse, write, Anchor, Document, Ghost}; +use terraphim_editor::{ + abort_history_transaction_json, begin_history_replay_json, finish_history_transaction_json, + parse_history_operations, with_session, +}; + +#[test] +fn public_mixed_checkpoint_preserves_large_string_ids_and_exact_abort_state() { + let mut doc = Document::new("abcdef"); + doc.annotations.ghosts.push(Ghost { + id: "g9007199254740993".into(), + anchor: Anchor::new(4, 6, "ef"), + }); + let change = with_session(|s| { + s.open(&write(&doc)); + let change = s.ghost_ranges_atomic("abcdef", &[(0, 2)]).unwrap().unwrap(); + s.apply_edit(6, 0, "X").unwrap(); + change + }); + assert_eq!(change.after.live[0].id, "g9007199254740994"); + let before = with_session(|s| s.clone()); + let operations = json!([ + {"kind":"edit","body":"abcdef","start":6,"deletedText":"X","insertedText":""}, + {"kind":"ghost","body":"abcdef","change":change.to_json(),"undo":true} + ]) + .to_string(); + let started = begin_history_replay_json("abcdefX", "abcdef", &operations).unwrap(); + assert_eq!( + with_session(|s| s.document().annotations.ghosts[0].id.clone()), + "g9007199254740993" + ); + let token = started["token"].as_str().unwrap(); + assert_eq!( + abort_history_transaction_json(token).unwrap()["rolledBack"], + true + ); + assert_eq!(with_session(|s| s.clone()), before); + let started = begin_history_replay_json("abcdefX", "abcdef", &operations).unwrap(); + let token = started["token"].as_str().unwrap(); + assert_eq!( + finish_history_transaction_json(token).unwrap()["finalized"], + true + ); + assert!(abort_history_transaction_json(token).is_err()); + assert!(with_session(|s| s + .ghost_snapshot() + .reserved_ids + .contains(&"g9007199254740994".into()))); +} + +fn alternative_target(id: &str) -> serde_json::Value { + json!({"id":id,"kind":"word","anchor":{"start":1,"end":2,"text":"b"},"active":0, + "alts":[{"text":"b","source":"original"},{"text":"bee","source":"human"}]}) +} + +#[test] +fn public_issued_ghost_then_colliding_alternative_refuses_entire_group() { + let receipt = with_session(|s| { + s.open("abc"); + s.set_overflow("notes").unwrap(); + let receipt = s.ghost_ranges_atomic("abc", &[(0, 1)]).unwrap().unwrap(); + s.replay_ghost_change(&receipt, true).unwrap(); + receipt + }); + let before = with_session(|s| s.clone()); + let operations = json!([ + {"kind":"edit","body":"abcX","start":3,"deletedText":"","insertedText":"X"}, + {"kind":"edit","body":"abc","start":3,"deletedText":"X","insertedText":""}, + {"kind":"ghost","body":"abc","change":receipt.to_json(),"undo":false}, + {"kind":"alt","body":"abc","start":0,"deletedText":"","insertedText":"","span":"g1","target":alternative_target("g1")} + ]).to_string(); + let result = begin_history_replay_json("abc", "abc", &operations); + if let Ok(started) = &result { + let saved = with_session(|s| s.save()); + let error = parse(&saved).expect_err("reproduction publishes duplicate IDs"); + eprintln!("P1 reproduced: accepted group saves an invalid annotation block: {error}"); + abort_history_transaction_json(started["token"].as_str().unwrap()).unwrap(); + } + assert!( + result.is_err(), + "P1: mixed replay must refuse cross-layer duplicate identity" + ); + assert_eq!(with_session(|s| s.clone()), before); + assert!(parse(&with_session(|s| s.save())).is_ok()); + // Failed simulation must not issue or leave a pending checkpoint. + let started = begin_history_replay_json("abc", "abc", "[]").unwrap(); + assert_eq!(started["token"], "history-1"); + abort_history_transaction_json(started["token"].as_str().unwrap()).unwrap(); +} + +#[test] +fn public_alternative_edit_reattachment_collision_and_standalone_restore_are_atomic() { + with_session(|s| { + s.open("abc"); + s.ghost_ranges_atomic("abc", &[(0, 1)]).unwrap().unwrap(); + s.apply_edit(0, 1, "").unwrap(); + s.set_overflow("notes").unwrap(); + assert_eq!(s.set_aside().ghosts[0].id, "g1"); + }); + let before = with_session(|s| s.clone()); + let operations = json!([ + {"kind":"edit","body":"bcX","start":2,"deletedText":"","insertedText":"X"}, + {"kind":"edit","body":"bc","start":2,"deletedText":"X","insertedText":""}, + {"kind":"alt","body":"abc","start":0,"deletedText":"","insertedText":"a","span":"g1","target":alternative_target("g1")} + ]).to_string(); + assert_eq!( + begin_history_replay_json("bc", "abc", &operations) + .unwrap_err() + .kind, + "model-refusal" + ); + assert_eq!(with_session(|s| s.clone()), before); + let target = serde_json::from_value(alternative_target("g1")).unwrap(); + assert!(with_session(|s| s.alt_restore("g1", Some(target), 0, 0, "a")).is_err()); + assert_eq!(with_session(|s| s.clone()), before); + assert!(parse(&with_session(|s| s.save())).is_ok()); + let started = begin_history_replay_json("bc", "bc", "[]").unwrap(); + assert_eq!(started["token"], "history-1"); + abort_history_transaction_json(started["token"].as_str().unwrap()).unwrap(); +} + +#[test] +fn public_distinct_historical_alternative_and_ghost_round_trip() { + let (receipt, target) = with_session(|s| { + s.open("abc"); + let target = serde_json::from_value(alternative_target("historical-alt")).unwrap(); + s.alt_restore("historical-alt", Some(target), 0, 0, "") + .unwrap(); + let target = s.document().annotations.spans[0].clone(); + let receipt = s.ghost_ranges_atomic("abc", &[(0, 1)]).unwrap().unwrap(); + s.replay_ghost_change(&receipt, true).unwrap(); + s.alt_restore("historical-alt", None, 0, 0, "").unwrap(); + assert!(s + .ghost_snapshot() + .reserved_ids + .contains(&"historical-alt".into())); + (receipt, target) + }); + let before = with_session(|s| s.clone()); + let operations = json!([ + {"kind":"ghost","body":"abc","change":receipt.to_json(),"undo":false}, + {"kind":"alt","body":"abc","start":0,"deletedText":"","insertedText":"","span":"historical-alt","target":target} + ]).to_string(); + let started = begin_history_replay_json("abc", "abc", &operations).unwrap(); + let doc = parse(&with_session(|s| s.save())).unwrap(); + assert_eq!(doc.annotations.spans[0].id, "historical-alt"); + assert_eq!(doc.annotations.ghosts[0].id, "g1"); + abort_history_transaction_json(started["token"].as_str().unwrap()).unwrap(); + assert_eq!(with_session(|s| s.clone()), before); + let started = begin_history_replay_json("abc", "abc", &operations).unwrap(); + finish_history_transaction_json(started["token"].as_str().unwrap()).unwrap(); + assert_eq!(parse(&with_session(|s| s.save())).unwrap(), doc); +} + +#[test] +fn public_strict_group_refusal_preserves_every_session_field() { + with_session(|s| { + s.open("a😀b"); + }); + let before = with_session(|s| s.clone()); + let operations = json!([ + {"kind":"edit","body":"a😀bX","start":4,"deletedText":"","insertedText":"X"}, + {"kind":"edit","body":"bad","start":2,"deletedText":"","insertedText":"Y"} + ]) + .to_string(); + assert!(begin_history_replay_json("a😀b", "bad", &operations).is_err()); + assert_eq!(with_session(|s| s.clone()), before); + assert!(parse_history_operations( + r#"[{"kind":"edit","body":"a😀b","start":0,"deletedText":"","insertedText":"","ghost":{}}]"# + ) + .is_err()); + assert_eq!(with_session(|s| s.clone()), before); + let started = begin_history_replay_json("a😀b", "a😀b", "[]").unwrap(); + abort_history_transaction_json(started["token"].as_str().unwrap()).unwrap(); +} diff --git a/tests/kg_role.rs b/tests/kg_role.rs new file mode 100644 index 0000000..2a28b77 --- /dev/null +++ b/tests/kg_role.rs @@ -0,0 +1,67 @@ +//! Atomic role publication for the browser graph coordinator; real KG engine. +#![cfg(not(target_arch = "wasm32"))] + +use serde_json::{json, Value}; +use terraphim_editor::{ + clear_thesaurus, commit_prepared_thesaurus, discard_prepared_thesaurus, load_thesaurus, + prepare_thesaurus, role_snapshot_json, +}; + +const ROLE: &str = include_str!("fixtures/kg/thesaurus.json"); + +fn snapshot() -> Value { + serde_json::from_str(&role_snapshot_json()).unwrap() +} + +#[test] +fn preparation_is_read_only_and_publication_is_single_use() { + clear_thesaurus(); + let before = snapshot(); + let token = prepare_thesaurus(ROLE).unwrap(); + assert_eq!(snapshot(), before); + let summary = commit_prepared_thesaurus(&token).unwrap(); + assert_eq!(summary["concepts"], 7); + let after = snapshot(); + assert_eq!(after["name"], "writing-fixture"); + assert_eq!(after["thesaurusText"], ROLE); + assert_ne!(after["generation"], before["generation"]); + assert!(after["generation"].is_string()); + assert!(commit_prepared_thesaurus(&token).is_err()); + assert_eq!(snapshot(), after); +} + +#[test] +fn bad_load_discard_and_stale_preparation_keep_current_role() { + load_thesaurus(ROLE).unwrap(); + let before = snapshot(); + assert!(load_thesaurus("{not json").is_err()); + assert_eq!(snapshot(), before); + assert!(prepare_thesaurus("{not json").is_err()); + assert_eq!(snapshot(), before); + let discarded = prepare_thesaurus(ROLE).unwrap(); + discard_prepared_thesaurus(&discarded); + assert!(commit_prepared_thesaurus(&discarded).is_err()); + assert_eq!(snapshot(), before); + + let stale = prepare_thesaurus(ROLE).unwrap(); + load_thesaurus(ROLE).unwrap(); + let reloaded = snapshot(); + assert!(commit_prepared_thesaurus(&stale).is_err()); + assert_eq!(snapshot(), reloaded); + let stale = prepare_thesaurus(ROLE).unwrap(); + clear_thesaurus(); + let cleared = snapshot(); + assert_eq!(cleared["thesaurusText"], json!(null)); + assert!(commit_prepared_thesaurus(&stale).is_err()); + assert_eq!(snapshot(), cleared); +} + +#[test] +fn only_the_latest_preparation_can_commit() { + clear_thesaurus(); + let old = prepare_thesaurus(ROLE).unwrap(); + let latest = prepare_thesaurus(ROLE).unwrap(); + discard_prepared_thesaurus(&old); + assert!(commit_prepared_thesaurus(&old).is_err()); + assert!(commit_prepared_thesaurus(&latest).is_ok()); +} diff --git a/tests/trim_done_runtime_diagnostic.rs b/tests/trim_done_runtime_diagnostic.rs new file mode 100644 index 0000000..269680d --- /dev/null +++ b/tests/trim_done_runtime_diagnostic.rs @@ -0,0 +1,217 @@ +//! Public-fixture native diagnosis only; no JS/browser qualification or projection fix. +use terraphim_editor::{ + abort_history_transaction_json, begin_history_replay_json, begin_trim_ghost_transaction_json, + with_session, +}; +use terraphim_lab::{trim_plan, CutId, LabConfig, TrimLevel}; + +const BODY: &str = include_str!("fixtures/lab/lab.md"); +const ROLE: &str = + r#"{"name":"trim-public-test","data":{"unmentioned":{"id":1,"nterm":"unmentioned"}}}"#; +const OVERLAP_ERROR: &str = "independent ghost range overlaps existing ghost or another range"; + +fn probe_checkpoint(body: &str, expected_token: &str) { + let before = with_session(|s| s.clone()); + let started = begin_history_replay_json(body, body, "[]").expect("empty checkpoint probe"); + assert!(started["ok"] == true); + let token = started["token"].as_str().expect("opaque token"); + assert_eq!(token, expected_token, "refusal must not issue a token"); + let ack = abort_history_transaction_json(token).expect("real abort"); + assert!(ack["ok"] == true && ack["rolledBack"] == true && ack["token"] == token); + assert!( + with_session(|s| s.clone()) == before, + "probe must restore exact session" + ); +} + +fn fixture_case(first_keep: bool, manual: bool) { + with_session(|s| { + s.open(BODY); + if manual { + s.ghost_ranges_atomic(BODY, &[(0, 1)]).unwrap(); + } + }); + terraphim_editor::load_thesaurus(ROLE).expect("public absent role"); + let role = serde_json::from_str(ROLE).unwrap(); + let protection = + terraphim_editor::protection::ConceptProtection::from_thesaurus(&role).unwrap(); + assert!(protection.occurrences(BODY).unwrap().is_empty()); + let config = LabConfig::with_defaults().unwrap(); + let protected_plan = protection.plan(BODY, &config).unwrap(); + let original = trim_plan(BODY, &config); + let initial = original.active(TrimLevel::Sharper, &[]); + let kept: Vec = if first_keep { + vec![initial[0].id] + } else { + vec![] + }; + let kept_json = serde_json::to_string(&kept).unwrap(); + let expected = original.active(TrimLevel::Sharper, &kept); + let active = protected_plan.active(TrimLevel::Sharper, &kept); + let tuples = |cuts: &[terraphim_lab::Cut]| { + cuts.iter() + .map(|c| (c.id.0, c.start, c.end)) + .collect::>() + }; + assert_eq!( + tuples(&active), + tuples(&expected), + "protected preview preserves original oracle" + ); + protection.validate_ranges(BODY, &active).unwrap(); + protection + .validate_retention(BODY, &terraphim_lab::make_cuts(BODY, &active)) + .unwrap(); + let ranges: Vec<_> = active.iter().map(|c| (c.start, c.end)).collect(); + assert!(ranges.windows(2).all(|w| w[0] <= w[1])); + let boundaries: Vec = std::iter::once(0) + .chain(BODY.chars().scan(0, |offset, ch| { + *offset += ch.len_utf16(); + Some(*offset) + })) + .collect(); + assert!(ranges + .iter() + .all(|&(s, e)| s < e && boundaries.contains(&s) && boundaries.contains(&e))); + assert!( + ranges.iter().all(|&(s, _)| s >= 1), + "manual heading ghost is disjoint" + ); + let mut overlaps = Vec::new(); + for (i, &(s, e)) in ranges.iter().enumerate() { + for (j, &(a, b)) in ranges.iter().enumerate().take(i) { + if s < b && a < e { + let containment = (a <= s && e <= b) || (s <= a && b <= e); + overlaps.push(( + j, + i, + if containment { + "containment" + } else { + "crossing" + }, + )); + } + } + } + println!( + "level=sharper kept={:?} manual={} active={:?} overlaps={:?}", + kept.iter().map(|id| id.0).collect::>(), + manual, + tuples(&active), + overlaps + ); + assert!( + !overlaps.is_empty(), + "exact fixture must exhibit the suspected overlap" + ); + assert_eq!(overlaps.len(), 4, "exact fixture has four overlap pairs"); + assert!(overlaps.iter().all(|(_, _, kind)| *kind == "containment")); + // An acknowledged empty probe proves checkpoint availability, establishes + // the opaque watermark, and leaves the complete session unchanged. + probe_checkpoint(BODY, "history-1"); + let before = with_session(|s| s.clone()); + let reservations = with_session(|s| s.ghost_snapshot().reserved_ids); + let batch_error = with_session(|s| s.ghost_ranges_atomic(BODY, &ranges)) + .expect_err("exact protected active batch must refuse overlap"); + assert_eq!(batch_error, OVERLAP_ERROR); + assert!(with_session(|s| s.clone()) == before); + println!("direct_active_batch_refusal=overlap"); + // Cache publication is private; do not pretend the independently computed + // real B plan above has entered the production protected-plan cache. + let seam_error = terraphim_editor::protected_trim_ranges("sharper", &kept_json) + .expect_err("native integration cannot publish private cached plan"); + assert_eq!(seam_error, "no trim plan: compute one first"); + let result = begin_trim_ghost_transaction_json(BODY, "sharper", &kept_json); + let error = match result { + Err(error) => error, + Ok(Some(started)) => { + let token = started["token"].as_str().expect("successful begin token"); + let ack = abort_history_transaction_json(token).expect("retire unexpected success"); + assert!(ack["ok"] == true && ack["rolledBack"] == true && ack["token"] == token); + assert!(with_session(|s| s.clone()) == before); + panic!( + "fixture unexpectedly accepted overlapping promotion; actual abort acknowledged" + ); + } + Ok(None) => panic!("nonempty fixture unexpectedly produced no promotion"), + }; + println!("refusal_kind={}", error.kind); + assert_eq!(error.kind, "protection-refusal"); + assert_eq!(error.message, "no trim plan: compute one first"); + assert!( + with_session(|s| s.clone()) == before, + "refusal changes no session fields" + ); + assert_eq!( + with_session(|s| s.ghost_snapshot().reserved_ids), + reservations + ); + probe_checkpoint(BODY, "history-2"); +} + +#[test] +fn fixture_no_keeps() { + fixture_case(false, false); +} +#[test] +fn fixture_first_keep() { + fixture_case(true, false); +} +#[test] +fn fixture_no_keeps_manual_heading_ghost() { + fixture_case(false, true); +} +#[test] +fn fixture_first_keep_manual_heading_ghost() { + fixture_case(true, true); +} + +#[test] +fn synthetic_strict_batch_nested_crossing_adjacent() { + let body = "abcdefghij"; + for (label, ranges) in [ + ("nested", vec![(0, 10), (2, 4)]), + ("crossing", vec![(0, 6), (4, 10)]), + ] { + with_session(|s| { + s.open(body); + }); + let before = with_session(|s| s.clone()); + let error = with_session(|s| s.ghost_ranges_atomic(body, &ranges)) + .expect_err("strict overlap refusal"); + assert_eq!(error, OVERLAP_ERROR); + assert!( + with_session(|s| s.clone()) == before, + "strict batch refusal preserves exact session" + ); + println!("synthetic={label} ranges={ranges:?} refusal=overlap"); + } + with_session(|s| { + s.open(body); + }); + let before = with_session(|s| s.clone()); + let change = with_session(|s| s.ghost_ranges_atomic(body, &[(0, 2), (2, 4)])) + .unwrap() + .unwrap(); + assert_eq!(change.after.live.len(), 2); + assert_ne!(change.after.live[0].id, change.after.live[1].id); + assert_eq!( + change + .after + .live + .iter() + .map(|g| (g.anchor.start, g.anchor.end)) + .collect::>(), + [(0, 2), (2, 4)] + ); + let started = begin_history_replay_json(body, body, "[]").unwrap(); + let token = started["token"].as_str().unwrap(); + let ack = abort_history_transaction_json(token).unwrap(); + assert!(ack["ok"] == true && ack["rolledBack"] == true && ack["token"] == token); + // This abort preserves the already-issued adjacent receipt, not a pretend + // rollback of the standalone promotion. + assert!(with_session(|s| s.ghost_snapshot()) == change.after); + assert!(with_session(|s| s.clone()) != before); + println!("synthetic=adjacent ranges=[(0,2),(2,4)] independent_ghosts=2 abort_ack=true"); +} diff --git a/tests/web_chrome.rs b/tests/web_chrome.rs index eb82156..e4e6c26 100644 --- a/tests/web_chrome.rs +++ b/tests/web_chrome.rs @@ -324,45 +324,59 @@ fn test_write_on_mode_persists_per_document() { let result = js_string( r##"(() => { const out = []; - const reinit = (config, source) => { + const input = () => document.querySelector('.markdown-input'); + const reinit = (config, text) => { window.__teEditor.destroy(); - // Stage a host's replacement after teardown. Teardown may render - // the old canonical surface while removing its decoration layers. - if (source !== undefined) document.querySelector('.markdown-input').textContent = source; + // Install the next document only after the live surface has + // cleared its decorations; teardown may re-render its old text. + if (text !== undefined) input().textContent = text; const ed = new MarkdownEditor(config || window.EditorConfig); ed.initialize(); window.__teEditor = ed; return ed; }; const counter = () => document.querySelector('.te-chrome-counter'); - const docA = 'Document A\n\nsome words'; - const docB = 'Document B\n\nother words'; - let ed = reinit(undefined, docA); - if (ed.surface.getText() !== docA) out.push('A source not mounted'); + const docA = '# Document A\n\nsome words'; + const docB = '## Document B\n\nother words'; + const checkBody = (text, label) => { + if (ed.headings) ed.headings.flush(); + if (ed.surface.getText() !== text) out.push(label + ' surface text changed'); + if (input().textContent !== text) out.push(label + ' DOM text changed'); + if (window.wasmBindings.document_body() !== text) out.push(label + ' model body changed'); + }; + let ed = reinit(null, docA); + checkBody(docA, 'A initial'); if (ed.chrome.isWriteOn()) out.push('A started in write-on'); const keyA = ed.chrome.storageKey(); if (!keyA.startsWith(window.WRITE_ON_STORAGE_PREFIX + 'h:')) out.push('hash key ' + keyA); counter().click(); + checkBody(docA, 'A write-on'); if (localStorage.getItem(keyA) !== '1') out.push('not stored ' + localStorage.getItem(keyA)); // Same document after a re-init: Write_On is restored. - ed = reinit(undefined, docA); + ed = reinit(null, docA); + checkBody(docA, 'A restored'); if (!ed.chrome.isWriteOn()) out.push('A not restored after re-init'); if (document.body.dataset.mode !== 'write-on') out.push('body mode not restored'); if (ed.chrome.storageKey() !== keyA) out.push('A key changed'); // Another document is not affected. - ed = reinit(undefined, docB); - if (ed.surface.getText() !== docB) out.push('B source not mounted'); + ed = reinit(null, docB); + checkBody(docB, 'B initial'); if (ed.chrome.isWriteOn()) out.push('B inherited write-on'); if (document.body.dataset.mode !== undefined) out.push('B body mode ' + document.body.dataset.mode); if (ed.chrome.storageKey() === keyA) out.push('B shares the A key'); // Loading a different document re-keys via documentChanged(). ed.surface.setText(docA); + checkBody(docA, 'A loaded'); ed.chrome.documentChanged(); + if (ed.chrome.storageKey() !== keyA) out.push('documentChanged changed A key'); + checkBody(docA, 'A re-keyed'); if (!ed.chrome.isWriteOn()) out.push('documentChanged did not apply A'); // Toggling A back to plain is stored too. counter().click(); + checkBody(docA, 'A plain'); if (localStorage.getItem(keyA) !== '0') out.push('plain not stored'); - ed = reinit(undefined, docA); + ed = reinit(null, docA); + checkBody(docA, 'A plain restored'); if (ed.chrome.isWriteOn()) out.push('A plain not restored'); // A save key or file name takes precedence over the content hash. const named = Object.assign({}, window.EditorConfig, { documentKey: 'notes.md' }); diff --git a/tests/web_embed.rs b/tests/web_embed.rs index 81b4e1f..9c915a4 100644 --- a/tests/web_embed.rs +++ b/tests/web_embed.rs @@ -25,6 +25,47 @@ use support::*; wasm_bindgen_test_configure!(run_in_browser); +#[wasm_bindgen_test] +async fn test_embed_plain_headings_follow_local_tools_without_touching_host() { + fresh_embed("# Embedded title\n\nBody text.").await; + assert_eq!( + js_string( + r##"(() => { + const ed = window.__teEditor, s = ed.surface; + const body = document.body.getAttribute('data-mode'); + const lang = document.documentElement.getAttribute('lang'); + const mains = document.querySelectorAll('main,[role="main"]').length; + const decoy = document.getElementById('te-decoy').outerHTML; + const text = s.getText(), counts = JSON.stringify(ed.counts()); + ed.chrome.setMode('plain'); ed.chrome.setTools(false); ed.headings.flush(); + s.focus(); s.setSelectionOffsets(text.indexOf('Body')); + ed.headings.applyShown(true); + const title = s.root.querySelector('.te-heading:not(.te-heading-marker)'); + const marker = s.root.querySelector('.te-heading-marker'); + if (!title || !marker) return 'plain embed headings missing'; + if (getComputedStyle(marker).fontSize !== '0px') return 'marker visible'; + const ratio = parseFloat(getComputedStyle(title).fontSize) / parseFloat(getComputedStyle(s.root).fontSize); + if (Math.abs(ratio - 1.15) > 0.01) return 'title size ' + ratio; + if (ed.indicators.active) return 'plain alternatives visible'; + ed.chrome.setTools(true); + if (s.root.querySelector('.te-heading')) return 'tools heading styled'; + ed.chrome.setMode('write-on'); + if (!s.root.querySelector('.te-heading')) return 'write-on heading missing'; + ed.chrome.setMode('plain'); ed.chrome.setTools(false); + if (!s.root.querySelector('.te-heading')) return 'plain heading not restored'; + if (s.getText() !== text || s.root.textContent !== text || ed.exportDocument() !== text) return 'text/export changed'; + if (JSON.stringify(ed.counts()) !== counts) return 'counts changed'; + if (s.root.querySelector('h1,h2,h3,[role="heading"]')) return 'invented heading semantics'; + if (document.body.getAttribute('data-mode') !== body || document.documentElement.getAttribute('lang') !== lang) return 'host attributes changed'; + if (document.querySelectorAll('main,[role="main"]').length !== mains) return 'host landmarks changed'; + if (document.getElementById('te-decoy').outerHTML !== decoy) return 'decoy changed'; + return ''; + })()"## + ), + "" + ); +} + /// The decoy: template look-alike markup that the editor must never touch. const DECOY_HTML: &str = r##"
decoy text

decoy preview

"##; diff --git a/tests/web_ghost_heading.rs b/tests/web_ghost_heading.rs new file mode 100644 index 0000000..d45e1c5 --- /dev/null +++ b/tests/web_ghost_heading.rs @@ -0,0 +1,80 @@ +//! Actual-DOM witness for the shown heading-marker / manual ghost cascade. +//! Compile-only preparation; browser qualification is a separate serial gate. +#![cfg(target_arch = "wasm32")] + +mod support; +use support::*; + +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn shown_manual_heading_ghost_preserves_fade_and_marker_lifecycle() { + let _document = fresh_full_editor(); + let result = run_steps(&[ + r##"T.errors = []; + T.body = '# heading\n'; + ed.openDocument(T.body, 'heading-ghost-control'); + ed.chrome.setMode('write-on'); + // Production selection and heading layer determine shown classes. + T.select = at => { + s.focus(); s.setSelectionOffsets(at, at); + ed.headings.flush(); + }; + T.marker = () => s.root.querySelector('[data-te-decoration~="headings:m0"]'); + T.alpha = color => { + const slash = color.match(/\/\s*([\d.]+)\s*\)$/); + const rgba = color.match(/^rgba\(([^)]+)\)$/); + return slash ? Number(slash[1]) : rgba ? Number(rgba[1].split(',')[3]) : 1; + }; + T.check = (phase, ghost, shown) => { + const el = T.marker(), errors = T.errors; + if (!el) { errors.push(phase + ': marker missing'); return; } + const style = getComputedStyle(el), rect = el.getBoundingClientRect(); + // Retain only class/geometry/color metadata, not document text. + console.log('heading-ghost ' + JSON.stringify({phase, + classes: Array.from(el.classList), connected: el.isConnected, + rects: el.getClientRects().length, width: rect.width, height: rect.height, + color: style.color, alpha: T.alpha(style.color), fontSize: style.fontSize})); + if (!el.classList.contains('te-heading') || !el.classList.contains('te-heading-marker') || + el.classList.contains('te-heading-marker--shown') !== shown || + el.classList.contains('te-ghost') !== ghost) errors.push(phase + ': same-span classes'); + if (ghost && !(el.getAttribute('data-te-decoration') || '').split(/\s+/).includes('ghosts:' + T.id)) errors.push(phase + ': native ghost token'); + if (!el.isConnected || !el.getClientRects().length) errors.push(phase + ': detached marker'); + if (shown) { + if (rect.width <= 0 || rect.height <= 0 || style.visibility !== 'visible' || parseFloat(style.fontSize) <= 0) errors.push(phase + ': visible geometry'); + if (ghost && Math.abs(T.alpha(style.color) - 0.1) > 0.02) errors.push(phase + ': expected alpha 0.1 +/- 0.02, got ' + style.color); + if (!ghost) { + // Resolve the real dim token through CSSOM, without writing classes. + const probe = document.createElement('span'); + probe.style.color = 'var(--te-color-text-dim)'; s.root.parentElement.appendChild(probe); + const dim = getComputedStyle(probe).color; probe.remove(); + if (style.color !== dim || T.alpha(style.color) !== 1) errors.push(phase + ': ordinary marker not opaque dim'); + } + } else if (parseFloat(style.fontSize) !== 0 || rect.width !== 0) errors.push(phase + ': hidden marker occupies width'); + }; + T.select(3); + T.check('ordinary-shown', false, true); + // Real native ghost API, not synthetic decoration/class injection. + ed.ghosts.ghost(0, 1); ed.ghosts.flush(); + const ghosts = ed.annotations().ghosts; + if (ghosts.length !== 1 || ghosts[0].anchor.start !== 0 || ghosts[0].anchor.end !== 1 || ghosts[0].anchor.text !== '#' || typeof ghosts[0].id !== 'string') return 'native manual ghost setup'; + T.id = ghosts[0].id; + T.annotations = JSON.stringify(ghosts); + T.select(3);"##, + r##"T.check('ghost-shown', true, true); + T.saved = ed.saveDocument(); + // The trailing empty line is beyond the heading's inclusive end. + T.select(T.body.length);"##, + r##"T.check('ghost-hidden-after-move', true, false); + if (s.getText() !== T.body || JSON.stringify(ed.annotations().ghosts) !== T.annotations) T.errors.push('selection changed native body/ghost'); + ed.openDocument(T.saved, 'heading-ghost-reopen'); + ed.chrome.setMode('write-on'); + ed.ghosts.flush(); + if (s.getText() !== T.body || ed.documentApi().document_body() !== T.body || JSON.stringify(ed.annotations().ghosts) !== T.annotations || ed.saveDocument() !== T.saved) T.errors.push('save/reopen changed body/ghost identity'); + T.select(3);"##, + r##"T.check('reopen-ghost-shown', true, true); + return T.errors.join('; ');"##, + ]) + .await; + assert_eq!(result, ""); +} diff --git a/tests/web_ghost_transaction.rs b/tests/web_ghost_transaction.rs new file mode 100644 index 0000000..ec7d119 --- /dev/null +++ b/tests/web_ghost_transaction.rs @@ -0,0 +1,181 @@ +//! Actual production JS and real WASM checkpoint closures. Compile-only here; +//! execution/Firefox/Chromium and human acceptance require the parent gate. +#![cfg(target_arch = "wasm32")] +mod support; +use support::*; +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn disjoint_alternative_ghost_deletion_mixed_replay_publishes_final_status() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(),trim=ed.lab.trim; + api.kg_load_thesaurus('{"name":"status-public","data":{"clip":{"id":1,"nterm":"clip"}}}'); + ed.openDocument('# clip\n\nThe editor is basically the only target here today.\n','mixed-status'); + const body=s.getText(),at=body.indexOf('clip'); + const alternative=ed.alternativeOp('create_span','word',at,at+4,'snippet','human'); + trim.select('sharper'); + if(!trim.active.length || trim.active.some(r=>r.startspan.id===alternative.span)) out.push('undo model'); + if(ann.setAside.spans.length || ann.setAside.ghosts.length || ed.setAsideCount!==0 || ed.warningKind!==null || ed.warning!==null) out.push('undo stale status'); + if(ed.warningElement && !ed.warningElement.hidden) out.push('undo visible warning'); + if(!s.redo()) out.push('mixed redo refused'); + ann=api.document_annotations(); + const count=ann.setAside.spans.length+ann.setAside.ghosts.length; + if(count!==1 || !ann.setAside.spans.some(span=>span.id===alternative.span) || !ann.ghosts.length) out.push('redo model'); + if(ed.setAsideCount!==count || ed.warningKind!=='set-aside' || !ed.warning.includes('1 annotation')) out.push('redo stale status'); + if(!ed.warningElement || ed.warningElement.hidden || !ed.warningElement.querySelector('.te-warning-text').textContent.includes('1 annotation')) out.push('redo accessible status'); + "#]).await, ""); +} + +#[wasm_bindgen_test] +async fn persistent_ghost_ids_above_js_safe_integer_are_not_coerced() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(),trim=ed.lab.trim;trim.select('sharper'); + const body=s.getText(),at=0; // fixture heading marker, never a trim cut + const block={version:1,spans:[],ghosts:[{id:'g9007199254740993',anchor:{start:at,end:at+1,text:body.slice(at,at+1)}}],overflow:''}; + ed.openDocument(body+'\n\n```terraphim-alternatives\n'+JSON.stringify(block)+'\n```\n','large-ids'); + trim.select('sharper');if(trim.done()!==true) return 'large-ID Done setup failed: '+trim.live.textContent; + const ids=api.document_annotations().ghosts.map(g=>g.id); + if(!ids.includes('g9007199254740994') || !ids.includes('g9007199254740993')) out.push('identity rounded'); + if(!s.undo() || api.document_annotations().ghosts[0].id!=='g9007199254740993') out.push('large-ID undo'); + if(!s.redo()) out.push('large-ID redo'); + ed.openDocument(api.save_document(),'large-ids-reopen'); + if(JSON.stringify(api.document_annotations().ghosts.map(g=>g.id))!==JSON.stringify(ids)) out.push('large-ID persistence'); + "#]).await, ""); +} + +#[wasm_bindgen_test] +async fn protected_done_is_one_persistent_undo_step_and_preserves_manual_ghosts() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(), trim=ed.lab.trim; + trim.select('sharper'); + const ranges=trim.active; + if (!ranges.length) out.push('missing cuts'); + const body=s.getText(); + // A disjoint manual ghost is not promoted or removed by Done/undo. + const at=0; // fixture heading marker, outside all protected cut ranges + api.ghost_range(at,at+1); + const manual=api.document_annotations().ghosts[0].id; + const history=s.history.length; + if(trim.done()!==true) return 'Done setup failed: '+trim.live.textContent; + if(s.getText()!==body || s.history.length!==history+1) out.push('body/history'); + if(ed.decorations.get('lab-trim').length || trim.plan) out.push('transients retained'); + if(!s.undo()) out.push('undo refused'); + if(JSON.stringify(api.document_annotations().ghosts.map(g=>g.id))!==JSON.stringify([manual])) out.push('manual lost'); + if(!s.redo()) out.push('redo refused'); + const saved=api.save_document(); + const ids=api.document_annotations().ghosts.map(g=>g.id); + ed.openDocument(saved,'ghost-reopen'); + if(JSON.stringify(api.document_annotations().ghosts.map(g=>g.id))!==JSON.stringify(ids)) out.push('persistence identities'); + "#]).await, ""); +} + +#[wasm_bindgen_test] +async fn capacities_and_partial_record_failure_restore_exact_native_receipt_state() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(),trim=ed.lab.trim; + trim.select('sharper');s.historyLimit=1; + const saved=api.save_document(), history=JSON.stringify(s.history), index=s.historyIndex; + if(trim.done()!==false || api.save_document()!==saved || JSON.stringify(s.history)!==history || s.historyIndex!==index) out.push('capacity mutated'); + if(!trim.plan || !ed.decorations.get('lab-trim').length) out.push('capacity retired plan'); + s.historyLimit=2; + const record=s.record;let recordEntered=false; + s.record=function(){recordEntered=true;this.history.length=0;this.historyIndex=-1;throw Error('injected record fault');}; + if(trim.done()!==false) out.push('record fault accepted'); + s.record=record; + if(!recordEntered) return 'record fault setup not reached: '+trim.live.textContent; + if(api.save_document()!==saved || JSON.stringify(s.history)!==history || s.historyIndex!==index) out.push('failed record changed checkpoints'); + if(trim.done()!==true || !s.canUndo()) return 'capacity2 setup failed: '+trim.live.textContent; + const ids=api.document_annotations().ghosts.map(g=>g.id); + if(ids[0]!=='g1') out.push('failed record retained allocation'); + if(!s.undo() || !s.redo()) out.push('capacity2 history'); + "#]).await, ""); +} + +#[wasm_bindgen_test] +async fn mixed_typing_and_ghost_group_uses_intermediate_body_and_atomic_last_refusal() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(),trim=ed.lab.trim; + trim.select('sharper');if(trim.done()!==true) return 'mixed Done setup failed: '+trim.live.textContent; + const body=s.getText();s.replaceRange(body.length,body.length,'X',{source:'typing'}); + if(!s.squashHistory(2)) out.push('squash'); + if(!s.undo() || s.getText()!==body || api.document_annotations().ghosts.length) out.push('mixed undo'); + if(!s.redo() || s.getText()!==body+'X') out.push('mixed redo'); + // Interleave real native manual metadata; last ghost replay must refuse + // even though its earlier text deletion would have been valid. + api.ghost_range(0,1); + const history=JSON.stringify(s.history),index=s.historyIndex,text=s.getText(),saved=api.save_document(); + if(s.undo()!==false) out.push('receipt conflict accepted'); + if(s.getText()!==text || api.save_document()!==saved || JSON.stringify(s.history)!==history || s.historyIndex!==index) out.push('partial mixed mutation'); + "#]).await, ""); +} + +#[wasm_bindgen_test] +async fn role_invalidation_during_record_aborts_document_but_never_resurrects_fades() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(),trim=ed.lab.trim; + trim.select('sharper');const saved=api.save_document(),history=JSON.stringify(s.history); + const record=s.record;let recordEntered=false; + s.record=function(...args){recordEntered=true;record.apply(this,args);api.kg_clear_thesaurus();}; + if(trim.done()!==false) out.push('role invalidation accepted');s.record=record; + if(!recordEntered) return 'role fault setup not reached: '+trim.live.textContent; + if(api.save_document()!==saved || JSON.stringify(s.history)!==history) out.push('document rollback failed'); + if(trim.plan || ed.decorations.get('lab-trim').length || trim.kept.size) out.push('role fades resurrected'); + "#]).await, ""); +} + +#[wasm_bindgen_test] +async fn interleaved_native_write_refuses_abort_and_requires_explicit_canonical_reopen() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(),trim=ed.lab.trim;trim.select('sharper'); + const record=s.record;let recordEntered=false; + s.record=function(...args){recordEntered=true;record.apply(this,args);api.set_document_overflow('external notes');throw Error('interleaved');}; + if(trim.done()!==false || !ed.ghostHistoryConflict) out.push('missing conflict');s.record=record; + if(!recordEntered) return 'interleaved fault setup not reached: '+trim.live.textContent; + if(api.document_annotations().overflow!=='external notes') out.push('interleaved data overwritten'); + const canonical=api.save_document(); + ed.openDocument(canonical,'manual-recovery'); + if(ed.ghostHistoryConflict || s.canUndo() || s.canRedo()) out.push('canonical history not reset'); + if(api.document_annotations().overflow!=='external notes') out.push('recovery lost notes'); + "#]).await, ""); +} + +#[wasm_bindgen_test] +async fn all_six_real_operations_and_unicode_guard_use_actual_wasm_bridge() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(),trim=ed.lab.trim;trim.select('sharper'); + if(trim.done()!==true) return 'six-kind Done setup failed: '+trim.live.textContent; + if(!s.history[s.historyIndex]?.edits?.[0]?.ghost?.change) return 'six-kind setup missing actual ghost receipt'; + const body=s.getText(),receipt=s.history[s.historyIndex].edits[0].ghost.change; + const at=body.indexOf('Café'); + const changed=api.alt_create_span('word',at,at+4,'Coffee','human'); + const span=changed.after; + const empty={body,start:0,deletedText:'',insertedText:''}; + const operations=[{kind:'ghost',body,change:receipt,undo:true}, + {...empty,kind:'edit'}, {...empty,kind:'alt',span:span.id,target:span}, + {...empty,kind:'move',move:{start:0,end:body.length,to:body.length}}, + {...empty,kind:'swap',span:span.id,from:0,to:0}, + {...empty,kind:'overflow',before:'',after:''}]; + const begun=api.begin_document_history_replay(body,body,JSON.stringify(operations)); + const aborted=api.abort_document_history_transaction(begun.token); + if(aborted.ok!==true || aborted.rolledBack!==true || aborted.token!==begun.token) out.push('exact ack'); + ed.openDocument('a😀中b','unicode'); + const before=api.save_document(); + try {api.begin_document_history_replay('a😀中b','bad',JSON.stringify([{kind:'edit',body:'bad',start:2,deletedText:'',insertedText:'X'}]));out.push('half-surrogate accepted');}catch(_){} + if(api.save_document()!==before) out.push('unicode refusal mutated'); + "#]).await, ""); +} diff --git a/tests/web_headings.rs b/tests/web_headings.rs index 3b45b6a..96dc7d4 100644 --- a/tests/web_headings.rs +++ b/tests/web_headings.rs @@ -2,7 +2,7 @@ //! headline in Write_On mode (bold, same face and colour, larger) with its //! `#` marker hidden, while the document text, caret, selection and export //! keep the marker. Run with -//! `wasm-pack test --headless --chrome --test web_headings`. Real scripts, +//! `scripts/browser-tests.sh web_headings`. Real scripts, //! real DOM and real stylesheets; nothing is mocked. See `tests/web.rs` for //! why the browser tests are split across binaries. #![cfg(target_arch = "wasm32")] @@ -16,7 +16,7 @@ wasm_bindgen_test_configure!(run_in_browser); const DOC: &str = "# Plain heading\n\nA paragraph of body text.\n\n## Second level\n\n```\n# not a heading\n```\n\nLast line.\n"; -/// Fresh editor with `DOC` open in Write_On mode and `window.teHead` helpers. +/// Fresh editor with `DOC` open in the requested mode and `window.teHead` helpers. fn setup_doc(write_on: bool) { let _document = fresh_full_editor(); let script = format!( @@ -68,7 +68,12 @@ async fn frames() { #[wasm_bindgen_test] async fn test_plain_heading_is_styled_as_headline_with_marker_hidden() { - setup_doc(true); + styled_heading(true).await; + styled_heading(false).await; +} + +async fn styled_heading(write_on: bool) { + setup_doc(write_on); // Park the caret in the body paragraph, away from both headings. js_eval( "teHead.s.focus(); teHead.s.setSelectionOffsets(teHead.s.getText().indexOf('paragraph'));", @@ -123,7 +128,12 @@ async fn test_plain_heading_is_styled_as_headline_with_marker_hidden() { #[wasm_bindgen_test] async fn test_marker_is_shown_while_the_caret_is_on_the_heading_line() { - setup_doc(true); + caret_marker(true).await; + caret_marker(false).await; +} + +async fn caret_marker(write_on: bool) { + setup_doc(write_on); js_eval( "teHead.s.focus(); teHead.s.setSelectionOffsets(teHead.s.getText().indexOf('paragraph'));", ); @@ -201,7 +211,12 @@ async fn test_marker_is_shown_while_the_caret_is_on_the_heading_line() { #[wasm_bindgen_test] async fn test_typing_keeps_headings_styled_without_extra_renders() { - setup_doc(true); + typing_headings(true).await; + typing_headings(false).await; +} + +async fn typing_headings(write_on: bool) { + setup_doc(write_on); let typed = js_string( r##"(() => { const s = teHead.s; @@ -302,6 +317,8 @@ async fn test_plain_headings_are_styled_and_tools_show_raw_markdown() { styled('plain restored'); if (!s.getText().startsWith('# Plain heading\n') || !s.getText().includes('xbody text')) out.push('canonical text changed'); if (ed.exportDocument() !== s.getText()) out.push('export dropped markup'); + if (ed.indicators.active || s.root.querySelector('.te-ind')) out.push('plain indicators active after tools restoration'); + if (s.getText() !== s.root.textContent || s.getText() !== window.wasmBindings.export_document()) out.push('text changed'); ed.destroy(); if (!ed.headings.destroyed) out.push('layer not destroyed'); return out.join('; '); @@ -310,6 +327,45 @@ async fn test_plain_headings_are_styled_and_tools_show_raw_markdown() { assert_eq!(result, ""); } +#[wasm_bindgen_test] +async fn test_plain_heading_composition_defers_decorations_and_preserves_history() { + setup_doc(false); + assert_eq!( + js_string( + r##"(() => { + const {ed, s} = teHead; + s.focus(); s.setSelectionOffsets(2); + window.__headBefore = s.getText(); + window.__headCounts = JSON.stringify(ed.counts()); + s.root.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true})); + window.__headNode = s.root.firstChild; + ed.chrome.setTools(true); + ed.headings.flush(); + if (s.root.firstChild !== window.__headNode) return 'composition DOM rebuilt'; + ed.chrome.setTools(false); + s.root.dispatchEvent(new CompositionEvent('compositionend', {bubbles:true, data:''})); + s.replaceRange(2, 2, '新'); + ed.headings.flush(); + if (s.getText() !== window.__headBefore.slice(0, 2) + '新' + window.__headBefore.slice(2)) return 'insert text'; + s.undo(); ed.headings.flush(); + if (s.getText() !== window.__headBefore) return 'undo'; + if (JSON.stringify(ed.counts()) !== window.__headCounts) return 'counts'; + s.redo(); ed.headings.flush(); + if (s.root.textContent !== s.getText() || !teTest.canonical()) return 'canonical'; + if (s.root.querySelector('h1,h2,h3,[role="heading"]')) return 'invented heading semantics'; + s.setSelectionOffsets(s.getText().indexOf('paragraph')); + return ''; + })()"## + ), + "" + ); + frames().await; + assert_eq!( + js_string("teHead.width(teHead.marker(0)) === 0 ? '' : 'marker visible'"), + "" + ); +} + #[wasm_bindgen_test] fn test_headline_span_with_alternatives_keeps_one_size() { let _document = fresh_full_editor(); @@ -335,3 +391,35 @@ fn test_headline_span_with_alternatives_keeps_one_size() { ); assert_eq!(result, ""); } + +#[wasm_bindgen_test] +fn test_heading_modes_preserve_ghost_export_counts_and_markdown_structure() { + setup_doc(false); + assert_eq!( + js_string( + r##"(() => { + const {ed, s} = teHead; + const start = s.getText().indexOf('Plain heading'); + const ghost = window.wasmBindings.ghost_range(start, start + 'Plain heading'.length); + if (!ghost.ok) return 'ghost refused'; + const before = { + text: s.getText(), saved: ed.saveDocument(), exported: ed.exportDocument(), + counts: JSON.stringify(ed.counts()), ghosts: JSON.stringify(ed.annotations().ghosts) + }; + if (before.exported.includes('Plain heading')) return 'ghost not excluded'; + for (const [mode, tools] of [['plain',true],['write-on',true],['plain',false]]) { + ed.chrome.setMode(mode); ed.chrome.setTools(tools); ed.headings.flush(); + if (s.getText() !== before.text || s.root.textContent !== before.text) return 'canonical text changed'; + if (ed.saveDocument() !== before.saved || ed.exportDocument() !== before.exported) return 'persistence changed'; + if (JSON.stringify(ed.counts()) !== before.counts || JSON.stringify(ed.annotations().ghosts) !== before.ghosts) return 'model changed'; + } + ed.openDocument('# Title\nwrapped body\n\n| key | value |\n| --- | --- |\n| # cell | text |\n\nSetext\n======\n\n~~~\n# fenced\n~~~', 'heading-structure'); + ed.headings.flush(); + if (ed.headings.ranges().length !== 1) return 'non-ATX prose/table/fence styled'; + if (s.root.querySelector('h1,h2,h3,[role="heading"]')) return 'invented semantics'; + return teTest.canonical() ? '' : 'noncanonical structure'; + })()"## + ), + "" + ); +} diff --git a/tests/web_kg_role.rs b/tests/web_kg_role.rs new file mode 100644 index 0000000..4332706 --- /dev/null +++ b/tests/web_kg_role.rs @@ -0,0 +1,101 @@ +//! Browser qualification of the authoritative role preparation API (real WASM). +#![cfg(target_arch = "wasm32")] + +use terraphim_editor::{ + kg_clear_thesaurus, kg_commit_prepared_role, kg_discard_prepared_role, kg_load_thesaurus, + kg_prepare_role, kg_role_snapshot_json, kg_set_role_change_listener, +}; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_browser); + +const ROLE: &str = include_str!("fixtures/kg/thesaurus.json"); + +#[wasm_bindgen_test] +fn observer_sees_published_state_without_borrow_and_only_success_notifies() { + use std::{cell::RefCell, rc::Rc}; + use wasm_bindgen::{closure::Closure, JsCast}; + kg_set_role_change_listener(None); + kg_clear_thesaurus(); + let seen = Rc::new(RefCell::new(Vec::new())); + let captured = seen.clone(); + let callback = Closure::::new(move || { + captured.borrow_mut().push(kg_role_snapshot_json()); + }); + kg_set_role_change_listener(Some( + callback + .as_ref() + .unchecked_ref::() + .clone(), + )); + kg_load_thesaurus(ROLE).unwrap(); + assert_eq!(seen.borrow().last().unwrap(), &kg_role_snapshot_json()); + let stale = kg_prepare_role(ROLE).unwrap(); + assert!(kg_load_thesaurus("invalid").is_err()); + assert!(kg_prepare_role("invalid").is_err()); + assert_eq!(seen.borrow().len(), 1); + kg_clear_thesaurus(); + assert!(kg_commit_prepared_role(&stale).is_err()); + assert_eq!(seen.borrow().len(), 2); + let token = kg_prepare_role(ROLE).unwrap(); + kg_commit_prepared_role(&token).unwrap(); + assert!(kg_commit_prepared_role(&token).is_err()); + assert_eq!(seen.borrow().len(), 3); + assert_eq!(seen.borrow().last().unwrap(), &kg_role_snapshot_json()); + kg_set_role_change_listener(None); + kg_clear_thesaurus(); + assert_eq!(seen.borrow().len(), 3); +} + +#[wasm_bindgen_test] +fn throwing_observer_cannot_undo_publication_or_escape_successful_exports() { + kg_set_role_change_listener(Some(js_sys::Function::new_no_args( + "throw new Error('observer failed')", + ))); + assert!(kg_load_thesaurus(ROLE).is_ok()); + let token = kg_prepare_role(ROLE).unwrap(); + assert!(kg_commit_prepared_role(&token).is_ok()); + kg_clear_thesaurus(); + let snapshot: serde_json::Value = serde_json::from_str(&kg_role_snapshot_json()).unwrap(); + assert!(snapshot["thesaurusText"].is_null()); + kg_set_role_change_listener(None); +} + +#[wasm_bindgen_test] +fn preparation_does_not_publish_and_commit_is_single_use() { + kg_clear_thesaurus(); + let before = kg_role_snapshot_json(); + let token = kg_prepare_role(ROLE).unwrap(); + assert_eq!(kg_role_snapshot_json(), before); + let committed = kg_commit_prepared_role(&token).unwrap(); + let summary: serde_json::Value = serde_json::from_str(&committed).unwrap(); + assert_eq!(summary["concepts"], 7); + assert!(summary["generation"].is_string()); + let after = kg_role_snapshot_json(); + assert_ne!(after, before); + assert!(kg_commit_prepared_role(&token).is_err()); + assert_eq!(kg_role_snapshot_json(), after); +} + +#[wasm_bindgen_test] +fn clear_reload_discard_and_invalid_role_cannot_publish_stale_state() { + kg_load_thesaurus(ROLE).unwrap(); + let before = kg_role_snapshot_json(); + assert!(kg_prepare_role("not json").is_err()); + assert_eq!(kg_role_snapshot_json(), before); + let old = kg_prepare_role(ROLE).unwrap(); + let latest = kg_prepare_role(ROLE).unwrap(); + kg_discard_prepared_role(&old); + assert!(kg_commit_prepared_role(&old).is_err()); + assert!(kg_commit_prepared_role(&latest).is_ok()); + let stale = kg_prepare_role(ROLE).unwrap(); + kg_clear_thesaurus(); + let cleared = kg_role_snapshot_json(); + assert!(kg_commit_prepared_role(&stale).is_err()); + assert_eq!(kg_role_snapshot_json(), cleared); + let stale = kg_prepare_role(ROLE).unwrap(); + kg_load_thesaurus(ROLE).unwrap(); + let reloaded = kg_role_snapshot_json(); + assert!(kg_commit_prepared_role(&stale).is_err()); + assert_eq!(kg_role_snapshot_json(), reloaded); +} diff --git a/tests/web_overflow_layout.rs b/tests/web_overflow_layout.rs index 8c0669c..c3cdc81 100644 --- a/tests/web_overflow_layout.rs +++ b/tests/web_overflow_layout.rs @@ -67,10 +67,14 @@ async fn test_ctrl_shift_d_toggles_the_panel_shift_exact() { async fn test_panel_is_full_height_and_flush_right() { let markup = r#""#; let rect = rect_in_viewport(1140.0, 800.0, markup, ".te-overflow").await; - assert_eq!( - (rect.right, rect.top, rect.height), - (1140.0, 0.0, 800.0), - "the panel should run top to bottom against the right edge of a 1140x800 window" + // Layout engines can return fractional CSS pixels (Firefox reports + // 1139.999984741211 here). Preserve the geometry check within 0.01px. + assert!( + (rect.right - 1140.0).abs() < 0.01 + && rect.top.abs() < 0.01 + && (rect.height - 800.0).abs() < 0.01, + "the panel should fill the right edge of a 1140x800 window: {:?}", + (rect.right, rect.top, rect.height) ); let _document = fresh_full_editor(); diff --git a/tests/web_text_boundaries.rs b/tests/web_text_boundaries.rs new file mode 100644 index 0000000..002794f --- /dev/null +++ b/tests/web_text_boundaries.rs @@ -0,0 +1,187 @@ +//! Native adapter contracts and real WASM/panel targeting. Browser execution +//! is intentionally delegated to the parent's serial, load-guarded run. +use terraphim_editor::{text_range_json, TextTargetError}; + +#[cfg(target_arch = "wasm32")] +mod support; + +#[test] +fn utf16_caret_floors_only_for_targeting_and_errors_are_typed() { + assert_eq!( + text_range_json("𐐀x cafe\u{301}", 1, "word").unwrap(), + serde_json::json!({"start":0,"end":3}) + ); + assert_eq!(terraphim_alternatives::utf16_to_byte("𐐀x", 1), None); + assert_eq!( + text_range_json("x", 2, "word"), + Err(TextTargetError::OffsetOutOfRange) + ); + assert_eq!( + text_range_json("x", 0, "unknown"), + Err(TextTargetError::UnknownKind("unknown".into())) + ); + assert_eq!( + text_range_json("", 0, "word").unwrap(), + serde_json::Value::Null + ); +} + +#[test] +fn markdown_heading_body_without_gap_crlf_and_fences_have_exact_scopes() { + let text = "# Head line\r\nBody. Next.\r\n\r\n```\r\nnot prose. Next.\r\n```"; + assert_eq!( + text_range_json(text, 3, "sentence").unwrap(), + serde_json::json!({"start":2,"end":11}) + ); + assert_eq!( + text_range_json(text, 15, "paragraph").unwrap(), + serde_json::json!({"start":13,"end":24}) + ); + assert_eq!( + text_range_json(text, 35, "sentence").unwrap(), + serde_json::Value::Null + ); + assert_eq!( + text_range_json(text, 35, "word").unwrap(), + serde_json::Value::Null + ); + assert_eq!( + text_range_json("Title\n=====\nBody.", 2, "sentence").unwrap(), + serde_json::json!({"start":0,"end":5}) + ); +} + +#[test] +fn adapter_retains_combining_words_repeated_offsets_and_inline_protection() { + let text = "cafe\u{301}x. Same. Same."; + assert_eq!( + text_range_json(text, 5, "word").unwrap(), + serde_json::json!({"start":0,"end":6}) + ); + assert_eq!( + text_range_json(text, 16, "sentence").unwrap(), + serde_json::json!({"start":14,"end":19}) + ); + let text = "Use `code. Here` now. `lower` next."; + assert_eq!( + text_range_json(text, 12, "sentence").unwrap(), + serde_json::json!({"start":0,"end":21}) + ); + assert_eq!( + text_range_json(text, 25, "sentence").unwrap(), + serde_json::json!({"start":22,"end":35}) + ); +} + +#[cfg(target_arch = "wasm32")] +mod browser { + use super::support::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen_test] + fn actual_wasm_binding_returns_utf16_null_and_refusals() { + let value = terraphim_editor::text_range_at("𐐀x", 1, "word").unwrap(); + assert_eq!( + js_sys::JSON::stringify(&value) + .unwrap() + .as_string() + .unwrap(), + "{\"end\":3,\"start\":0}" + ); + assert!(terraphim_editor::text_range_at("", 0, "word") + .unwrap() + .is_null()); + assert!(terraphim_editor::text_range_at("x", 2, "word").is_err()); + assert!(terraphim_editor::text_range_at("x", 0, "unknown").is_err()); + assert_eq!(terraphim_editor::text_infer_kind("cafe\u{301}x"), "word"); + assert_eq!( + terraphim_editor::text_infer_kind("First. `lower` next."), + "paragraph" + ); + } + + // These are actual exported Rust functions, not a boundary mock. Existing + // support installs its older export list, so install new exports explicitly. + fn install_text_bindings() { + let range = Closure::wrap(Box::new(|text: String, pos: u32, kind: String| { + terraphim_editor::text_range_at(&text, pos, &kind) + }) + as Box Result>); + install_bindings("text_range_at", range.as_ref()); + range.forget(); + let infer = Closure::wrap( + Box::new(|text: String| terraphim_editor::text_infer_kind(&text)) + as Box String>, + ); + install_bindings("text_infer_kind", infer.as_ref()); + infer.forget(); + } + + #[wasm_bindgen_test] + async fn real_panel_targets_unicode_and_markdown_through_wasm() { + install_text_bindings(); + let _document = fresh_full_editor(); + sleep(0).await; + install_alt_panel_helpers(); + let result = run_steps(&[ + r##" + const body = '𐐀x cafe\u0301x. Same. Same.'; + s.setText(body); + for (const pos of [0,1,2,3]) { + s.setSelectionOffsets(pos,pos); + ed.altPanel.open(undefined,{focus:false}); + ed.altPanel.setTab('word'); + if (ed.altPanel.target.pending.text !== '𐐀x') return 'astral '+pos; + } + for (let pos=4;pos<=10;pos++) { + s.setSelectionOffsets(pos,pos); + ed.altPanel.close({restoreFocus:false}); + ed.altPanel.open(undefined,{focus:false}); + if (ed.altPanel.target.pending.text !== 'cafe\u0301x') return 'combining '+pos; + } + s.setSelectionOffsets(20,20); + ed.altPanel.close({restoreFocus:false}); + ed.altPanel.open(undefined,{focus:false}); + ed.altPanel.setTab('sentence'); + if (ed.altPanel.target.pending.start !== 18 || ed.altPanel.target.pending.text !== 'Same.') return 'repeat offset'; + "##, + r##" + s.setText('# Head line\r\nBody. Next.\r\n\r\n```\r\nnot prose. Next.\r\n```'); + s.setSelectionOffsets(3,3); + ed.altPanel.close({restoreFocus:false}); + ed.altPanel.open(undefined,{focus:false}); + ed.altPanel.setTab('sentence'); + if (ed.altPanel.target.pending.text !== 'Head line') return 'heading'; + s.setSelectionOffsets(35,35); + ed.altPanel.close({restoreFocus:false}); + ed.altPanel.open(undefined,{focus:false}); + if (!ed.altPanel.target.message || ed.altPanel.target.pending) return 'code targeted'; + ed.altPanel.open({start:35,end:38,kind:'word'},{focus:false}); + if (!ed.altPanel.target.pending) return 'explicit selection refused'; + "##, + ]).await; + assert_eq!(result, ""); + } + + #[wasm_bindgen_test] + async fn missing_text_bindings_are_actionable_without_a_parser_fallback() { + install_text_bindings(); + let _document = fresh_full_editor(); + sleep(0).await; + install_alt_panel_helpers(); + let result = run_steps(&[r##" + s.setText('Word. Next.'); s.setSelectionOffsets(1,1); + const api = ed.documentApi(); + const old = api.text_range_at; + delete api.text_range_at; + try { + ed.altPanel.open(undefined,{focus:false}); + if (ed.altPanel.target.pending || !/WASM.*reload/i.test(ed.altPanel.target.message || '')) return 'silent fallback'; + } finally { api.text_range_at = old; } + ed.altPanel.open({start:0,end:4,kind:'word'},{focus:false}); + if (!ed.altPanel.target.pending) return 'explicit range unavailable'; + "##]).await; + assert_eq!(result, ""); + } +} diff --git a/tests/web_trim_done.rs b/tests/web_trim_done.rs index ee82ebc..053c620 100644 --- a/tests/web_trim_done.rs +++ b/tests/web_trim_done.rs @@ -1,5 +1,5 @@ -//! Browser tests for leaving a trim (issue #15, spec R-8.3 to R-8.5): Done -//! persists exact faded coverage as ghosts, and edits invalidate the trim. The levels are in +//! Browser tests for completing a trim: Done persists independent ghosts in +//! one undo step; edits still invalidate a subsequent preview. The levels are in //! `tests/web_trim.rs`, making the cuts in `tests/web_trim_cuts.rs` and the //! shared helpers in `tests/support/trim.rs`. Run with `wasm-pack test //! --headless --chrome --test web_trim_done`. Real scripts, real stylesheets, the real @@ -7,8 +7,8 @@ //! committed fixture `tests/fixtures/lab/lab.md`; nothing is mocked. The //! expected card numbers, pieces and cut text are computed in the same test //! by the `terraphim_lab` engine, so the browser must show exactly what the -//! engine returns. Scenarios run as short steps with a yield between them -//! (`support::run_steps`), one or two model calls per step. +//! engine returns. Scenarios yield between lifecycle phases through +//! `support::run_steps`; the original browser binary/time budget is unchanged. #![cfg(target_arch = "wasm32")] mod support; @@ -18,60 +18,160 @@ use support::*; wasm_bindgen_test_configure!(run_in_browser); -#[wasm_bindgen_test] -async fn test_done_persists_exact_ghosts_with_save_reopen_and_one_history_step() { +/// Public fixture/role with engine-derived kept and remaining ranges. The +/// heading marker is a disjoint, pre-existing manual ghost, not a trim cut. +fn migration_setup() { setup(); - let cuts = plan().active(TrimLevel::Slight, &[]); - let mut expected: Vec<_> = cuts - .iter() - .filter(|cut| { - !cuts.iter().any(|outer| { - outer.start <= cut.start - && cut.end <= outer.end - && (outer.start < cut.start || cut.end < outer.end) - }) - }) - .map(|cut| (cut.start, cut.end)) - .collect(); - expected.sort_unstable(); - expected.dedup(); - assert!(!expected.is_empty(), "fixture must propose actual cuts"); + let plan = plan(); + let initial = plan.active(TrimLevel::Sharper, &[]); + assert!( + initial.len() > 1, + "fixture needs multiple independent fades" + ); + let kept = initial[0].id; + let remaining = plan.active(TrimLevel::Sharper, &[kept]); + assert!(!remaining.is_empty() && remaining.len() < initial.len()); + assert!(remaining.iter().all(|c| c.start >= 1)); + // Approved promotion oracle only: retain maximal post-kept coverage, + // leaving original preview/Make-cuts/export oracle unchanged. + let mut ordered: Vec<_> = remaining.iter().map(|c| (c.start, c.end)).collect(); + for (i, &(s, e)) in ordered.iter().enumerate() { + for &(a, b) in &ordered[..i] { + assert!( + s >= b || a >= e || (s <= a && b <= e) || (a <= s && e <= b), + "crossing is not authorized for promotion" + ); + } + } + ordered.sort_by_key(|&(s, e)| (s, std::cmp::Reverse(e))); + let mut promotion: Vec<(usize, usize)> = Vec::new(); + for (s, e) in ordered { + if !promotion.last().is_some_and(|&(a, b)| a <= s && e <= b) { + promotion.push((s, e)); + } + } + assert!(promotion.windows(2).all(|w| w[0].1 <= w[1].0)); + // Unit-for-unit coverage proof, not a merged stored-ghost oracle. + for offset in 0..FIXTURE.encode_utf16().count() { + assert_eq!( + remaining + .iter() + .any(|c| c.start <= offset && offset < c.end), + promotion.iter().any(|&(s, e)| s <= offset && offset < e) + ); + } + let mut exported = terraphim_alternatives::Document::new(FIXTURE); + exported.ghost(0, 1).unwrap(); + for cut in &remaining { + exported.ghost(cut.start, cut.end).unwrap(); + } + let oracle = serde_json::json!({ + "initial": pieces(&initial), + "kept": kept.0, + "remaining": pieces(&remaining), + "promotion": promotion, + "exported": exported.export(), + }); + assert_eq!( + js_string(&format!( + r##"(() => {{ + window.doneOracle = {oracle}; + const ed = window.__teEditor; + const block = {{version:1, spans:[], ghosts:[{{id:'g1', anchor:{{start:0,end:1,text:'#'}}}}], overflow:''}}; + ed.openDocument(teTrim.fixture + '\n\n```terraphim-alternatives\n' + JSON.stringify(block) + '\n```\n', 'trim-done-manual'); + // Opening a new key restores that key's mode preference (plain here). + ed.chrome.setMode('write-on'); + if (!ed.chrome.isWriteOn()) return 'manual document not in Write_On'; + return ed.annotations().ghosts.length === 1 ? 'ok' : 'manual ghost missing'; + }})()"## + )), + "ok" + ); +} + +#[wasm_bindgen_test] +async fn test_done_persists_ghosts_and_edits_invalidate_the_next_trim() { + migration_setup(); let result = run_steps(&[ - &format!(r##"T.expected = {expected}; + r##"const trim = teTrim.pick('sharper'); + if (JSON.stringify(teTrim.pieces()) !== JSON.stringify(doneOracle.initial)) out.push('initial engine ranges'); + trim.keep(doneOracle.kept); + if (JSON.stringify(teTrim.pieces()) !== JSON.stringify(doneOracle.remaining)) out.push('kept engine ranges'); T.before = ed.saveDocument(); - T.history = s.historyIndex; - teTrim.pick('slight'); + T.manual = JSON.stringify(ed.annotations().ghosts); + T.counts = JSON.stringify(ed.counts()); + T.history = s.history.length; + T.index = s.historyIndex; teTrim.card().querySelector('.te-trim-done').click(); - if (!teTrim.card().hidden || teTrim.layer().length || s.root.querySelector('.te-trim-faded')) out.push('Done left transient preview'); - if (teTrim.trim().level !== 'original' || teTrim.button('original').getAttribute('aria-checked') !== 'true') out.push('Done did not reset preview controls'); + if (trim.live.textContent !== 'Done: cuts saved as ghosts. Undo restores them.') return 'Done setup failed: ' + trim.live.textContent; + if (!teTrim.card().hidden) out.push('Done left the card'); + if (teTrim.layer().length || s.root.querySelector('.te-trim-faded')) out.push('Done retained transient fades'); + if (trim.plan || trim.status || trim.active.length || trim.kept.size || trim.walkIndex !== -1) out.push('Done retained plan state'); + if ('teTrimming' in s.root.dataset) out.push('Done retained trimming flag'); + if (teTrim.button('original').getAttribute('aria-checked') !== 'true') out.push('Original not selected after Done'); + if (s.history.length !== T.history + 1 || s.historyIndex !== T.index + 1) out.push('Done not one history step'); + if (s.getText() !== teTrim.fixture || ed.documentApi().document_body() !== teTrim.fixture) out.push('Done mutated raw body'); + if (JSON.stringify(ed.counts()) !== T.counts) out.push('Done excluded ghosts from body counts'); const ghosts = ed.annotations().ghosts; - if (JSON.stringify(ghosts.map((g) => [g.anchor.start,g.anchor.end])) !== JSON.stringify(T.expected)) out.push('ghosts differ from exact outermost engine cuts'); - for (const g of ghosts) {{ - if (s.getText().slice(g.anchor.start,g.anchor.end) !== g.anchor.text) out.push('ghost anchor text'); - }} - if (!s.root.querySelector('.te-ghost')) out.push('persistent fades not rendered'); - if (s.getText() !== teTrim.fixture) out.push('Done rewrote the body'); - if (s.historyIndex !== T.history + 1) out.push('Done is not one history step'); + if (JSON.stringify(ghosts.filter(g => g.id === 'g1')) !== T.manual) out.push('Done changed manual ghost'); + const promoted = ghosts.filter(g => g.id !== 'g1'); + const ranges = promoted.map(g => [g.anchor.start, g.anchor.end]); + if (JSON.stringify(ranges) !== JSON.stringify(doneOracle.promotion)) out.push('independent ghost ranges differ from approved promotion projection'); + if (new Set(ghosts.map(g => g.id)).size !== ghosts.length || ghosts.some(g => typeof g.id !== 'string')) out.push('ghost IDs not independent strings'); + if (promoted.some(g => g.anchor.text !== teTrim.fixture.slice(g.anchor.start, g.anchor.end))) out.push('ghost anchor text'); + T.checkGhostRendering = (annotations, phase, errors) => { + const expected = annotations.map(g => [g.id, g.anchor.start, g.anchor.end, 'te-ghost']); + const registered = ed.decorations.get('ghosts').map(d => [d.id, d.start, d.end, d.className]); + if (JSON.stringify(registered) !== JSON.stringify(expected) || ed.ghosts.count !== annotations.length) errors.push(phase + ': ghost registry/count not refreshed'); + const els = Array.from(s.root.querySelectorAll('.te-ghost')); + if (!els.length) errors.push(phase + ': no persistent ghost DOM'); + // Match native string IDs as attribute tokens, without numeric coercion + // or selector interpolation. One ghost may render several segments. + for (const ghost of annotations) { + const segments = els.filter(el => (el.getAttribute('data-te-decoration') || '').split(/\s+/).includes('ghosts:' + ghost.id)); + if (!segments.length) errors.push(phase + ': missing rendered ghost ' + ghost.id); + for (const el of segments) { + const style = getComputedStyle(el), color = style.color; + const slash = color.match(/\/\s*([\d.]+)\s*\)$/), rgba = color.match(/^rgba\(([^)]+)\)$/); + const alpha = slash ? Number(slash[1]) : rgba ? Number(rgba[1].split(',')[3]) : 1; + if (!el.isConnected || !el.getClientRects().length || style.visibility !== 'visible' || Math.abs(alpha - 0.1) > 0.02) errors.push(phase + ': ghost not visibly faded ' + ghost.id + ' ' + color); + } + } + // Preserve the original check on every ghost-classed segment as well. + for (const el of els) { + const color = getComputedStyle(el).color; + const slash = color.match(/\/\s*([\d.]+)\s*\)$/), rgba = color.match(/^rgba\(([^)]+)\)$/); + const alpha = slash ? Number(slash[1]) : rgba ? Number(rgba[1].split(',')[3]) : 1; + if (Math.abs(alpha - 0.1) > 0.02) errors.push(phase + ': ghost alpha ' + color); + } + return els.find(el => annotations.some(g => g.id !== 'g1' && (el.getAttribute('data-te-decoration') || '').split(/\s+/).includes('ghosts:' + g.id))); + }; + const promotedSegment = T.checkGhostRendering(ghosts, 'Done', out); + if (trim.live.textContent !== 'Done: cuts saved as ghosts. Undo restores them.' || trim.live.getAttribute('role') !== 'status' || trim.live.getAttribute('aria-live') !== 'polite') out.push('Done status'); T.after = ed.saveDocument(); - if (T.after === T.before) out.push('Done did not persist annotations'); - // Clicking a persistent ghost cannot retain a now-retired preview cut. - teTrim.click(s.root.querySelector('.te-ghost')); - if (teTrim.trim().kept.size || ed.saveDocument() !== T.after) out.push('click after Done changed annotations');"##, - expected = serde_json::to_string(&expected).unwrap()), - r##"if (!s.undo() || ed.saveDocument() !== T.before || s.historyIndex !== T.history) out.push('actual Undo did not restore exact saved source'); - if (!s.redo() || ed.saveDocument() !== T.after || s.historyIndex !== T.history + 1) out.push('actual Redo did not restore exact saved ghosts'); - ed.openDocument(T.after, 'trim-done-reopened'); - if (ed.saveDocument() !== T.after || s.getText() !== teTrim.fixture) out.push('save/reopen changed source'); - if (JSON.stringify(ed.annotations().ghosts.map((g) => [g.anchor.start,g.anchor.end])) !== JSON.stringify(T.expected)) out.push('reopen lost exact ghost ranges'); - if (!s.root.querySelector('.te-ghost')) out.push('reopened ghosts not rendered');"##, - ]).await; - assert_eq!(result, ""); -} - -#[wasm_bindgen_test] -async fn test_edits_invalidate_trim_and_leaving_write_on_clears_preview() { - setup(); - let result = run_steps(&[ + T.ghosts = JSON.stringify(ghosts); + if (!T.after.includes('```terraphim-alternatives\n')) out.push('missing saved annotation block'); + if (ed.exportDocument() !== doneOracle.exported || ed.exportDocument().includes('terraphim-alternatives')) out.push('clean export not engine-range omission'); + // Persistent ghosts cannot keep a retired preview. + if (promotedSegment) teTrim.click(promotedSegment); + else out.push('no promoted segment to click'); + if (trim.kept.size) out.push('click kept after Done'); + if (!s.undo()) out.push('Done undo refused'); + if (ed.saveDocument() !== T.before || JSON.stringify(ed.annotations().ghosts) !== T.manual || s.getText() !== teTrim.fixture) out.push('one Undo failed to restore manual-only state'); + if (s.historyIndex !== T.index) out.push('Undo index'); + if (!s.redo() || ed.saveDocument() !== T.after || JSON.stringify(ed.annotations().ghosts) !== T.ghosts) out.push('Redo did not restore exact ghost identities'); + T.checkGhostRendering(ed.annotations().ghosts, 'Redo', out);"##, + r##"ed.openDocument(T.after, 'trim-done-reopen'); + ed.chrome.setMode('write-on'); + if (!ed.chrome.isWriteOn()) return 'reopened document not in Write_On'; + if (s.getText() !== teTrim.fixture || ed.documentApi().document_body() !== teTrim.fixture || JSON.stringify(ed.counts()) !== T.counts) out.push('reopen body/counts'); + if (JSON.stringify(ed.annotations().ghosts) !== T.ghosts || ed.saveDocument() !== T.after) out.push('reopen annotations/IDs'); + if (ed.exportDocument() !== doneOracle.exported) out.push('reopen clean export'); + T.checkGhostRendering(ed.annotations().ghosts, 'reopen', out); + // A fresh level reopens the card without removing persisted ghosts. + teTrim.pick('slight'); + if (teTrim.card().hidden) out.push('card not reopened'); + if (JSON.stringify(ed.annotations().ghosts) !== T.ghosts) out.push('fresh preview changed ghosts');"##, r##"// An edit ends the trim: stale at once, cleared on the next tick. const trim = teTrim.pick('slight'); s.replaceRange(0, 0, 'X', { source: 'typing' }); @@ -85,6 +185,11 @@ async fn test_edits_invalidate_trim_and_leaving_write_on_clears_preview() { try { api.trim_make_cuts('slight', '[]'); } catch (e) { refused = /stale/.test(String(e)); } if (!refused) out.push('bridge accepted a stale plan');"##, r##"const trim = teTrim.trim(); + ed.ghosts.flush(); + const expectedGhosts = JSON.parse(T.ghosts).map(g => [g.id, g.anchor.start + 1, g.anchor.end + 1, g.anchor.text]); + const editedGhosts = ed.annotations().ghosts.map(g => [g.id, g.anchor.start, g.anchor.end, g.anchor.text]); + if (JSON.stringify(editedGhosts) !== JSON.stringify(expectedGhosts) || ed.ghosts.count !== expectedGhosts.length) out.push('typing lost persistent ghosts/count'); + T.editedGhosts = JSON.stringify(ed.annotations().ghosts); if (teTrim.layer().length || s.root.querySelector('.te-trim-faded')) out.push('preview left after edit'); if (trim.level !== 'original' || trim.kept.size) out.push('state left after edit'); if (teTrim.button('original').getAttribute('aria-checked') !== 'true') out.push('Original not selected'); @@ -93,8 +198,32 @@ async fn test_edits_invalidate_trim_and_leaving_write_on_clears_preview() { teTrim.pick('slight'); if (!teTrim.layer().length || teTrim.card().hidden) out.push('recompute failed');"##, r##"// Leaving Write_On clears the trim; destroy removes everything. - ed.chrome.setMode('plain'); + if (!ed.chrome.isWriteOn()) return 'plain transition requires prior Write_On'; + if (!teTrim.layer().length || teTrim.card().hidden) return 'plain transition requires active preview'; + const api = ed.documentApi(); + const persistentBefore = JSON.stringify(api.document_annotations()); + const ghostIdsBefore = JSON.stringify(api.document_annotations().ghosts.map(g => g.id)); + const manualBefore = JSON.stringify(api.document_annotations().ghosts.filter(g => g.id === 'g1')); + if (api.document_annotations().ghosts.filter(g => g.id === 'g1').length !== 1) return 'plain transition requires manual ghost'; + const historyBefore = JSON.stringify({entries:s.history,index:s.historyIndex,limit:s.historyLimit}); + const bodyBefore = s.getText(); + const roleBefore = api.kg_role_snapshot_json(); + const modes = []; + const observeMode = e => { if (e.detail && e.detail.editor === ed) modes.push(e.detail.mode); }; + document.addEventListener('te:mode-change', observeMode); + try { ed.chrome.setMode('plain'); } + finally { document.removeEventListener('te:mode-change', observeMode); } + if (ed.chrome.isWriteOn() || ed.chrome.mode !== 'plain') out.push('plain mode not established'); + if (JSON.stringify(modes) !== JSON.stringify(['plain'])) out.push('plain transition event not exactly once'); if (teTrim.layer().length || !teTrim.card().hidden) out.push('plain mode left the trim'); + if (JSON.stringify(ed.annotations().ghosts) !== T.editedGhosts) out.push('plain mode removed persistent ghosts'); + if (s.root.querySelector('.te-trim-faded') || 'teTrimming' in s.root.dataset) out.push('plain mode left ephemeral DOM'); + if (JSON.stringify(api.document_annotations()) !== persistentBefore) out.push('plain mode changed persistent annotations'); + if (JSON.stringify(api.document_annotations().ghosts.map(g => g.id)) !== ghostIdsBefore) out.push('plain mode changed ghost IDs'); + if (JSON.stringify(api.document_annotations().ghosts.filter(g => g.id === 'g1')) !== manualBefore) out.push('plain mode changed manual ghost'); + if (JSON.stringify({entries:s.history,index:s.historyIndex,limit:s.historyLimit}) !== historyBefore) out.push('plain mode changed history'); + if (s.getText() !== bodyBefore || api.document_body() !== bodyBefore) out.push('plain mode changed body'); + if (api.kg_role_snapshot_json() !== roleBefore) out.push('plain mode changed role'); ed.chrome.setMode('write-on'); teTrim.pick('slight'); const trim = teTrim.trim(); @@ -108,3 +237,39 @@ async fn test_edits_invalidate_trim_and_leaving_write_on_clears_preview() { .await; assert_eq!(result, ""); } + +#[wasm_bindgen_test] +async fn test_done_capacity_refusal_preserves_preview_selection_and_redo() { + migration_setup(); + let result = run_steps(&[ + r##"// Build a real redo branch, then preview/keep/walk without history edits. + const end = s.getText().length; + s.replaceRange(end, end, 'X', {source:'typing'}); + if (!s.undo() || !s.canRedo()) out.push('no redo branch'); + const trim = teTrim.pick('sharper'); + trim.keep(doneOracle.kept); + trim.startWalk(); + s.setSelectionOffsets(2, 5); + T.limit = s.historyLimit; + s.historyLimit = 1; + T.refusal = { + saved:ed.saveDocument(), annotations:JSON.stringify(ed.annotations()), body:s.getText(), + history:JSON.stringify(s.history), index:s.historyIndex, selection:JSON.stringify(s.getSelectionOffsets()), + plan:JSON.stringify(trim.plan), status:JSON.stringify(trim.status), pieces:JSON.stringify(teTrim.pieces()), + kept:JSON.stringify(Array.from(trim.kept)), walk:trim.walkIndex, ghosts:JSON.stringify(ed.decorations.get('ghosts')) + }; + teTrim.card().querySelector('.te-trim-done').click(); + const old = T.refusal; + if (ed.saveDocument() !== old.saved || JSON.stringify(ed.annotations()) !== old.annotations || s.getText() !== old.body || ed.documentApi().document_body() !== old.body) out.push('refusal mutated model/JSON'); + if (JSON.stringify(s.history) !== old.history || s.historyIndex !== old.index || !s.canRedo() || s.historyLimit !== 1) out.push('refusal changed history/redo'); + if (JSON.stringify(s.getSelectionOffsets()) !== old.selection) out.push('refusal changed selection'); + if (JSON.stringify(trim.plan) !== old.plan || JSON.stringify(trim.status) !== old.status || JSON.stringify(teTrim.pieces()) !== old.pieces || JSON.stringify(Array.from(trim.kept)) !== old.kept || trim.walkIndex !== old.walk) out.push('refusal changed preview/keeps/walk'); + if (trim.level !== 'sharper' || !trim.cardOpen || trim.card.hidden || !s.root.querySelector('.te-trim-faded')) out.push('refusal retired valid preview'); + if (JSON.stringify(ed.decorations.get('ghosts')) !== old.ghosts || ed.annotations().ghosts.length !== 1 || ed.annotations().ghosts[0].id !== 'g1') out.push('refusal changed manual ghosts'); + if (trim.live.textContent !== 'Done refused: History capacity must retain a base state and one ghost operation' || trim.live.getAttribute('role') !== 'status' || trim.live.getAttribute('aria-live') !== 'polite') out.push('capacity refusal status'); + // Restore normal capacity; the preserved redo operation must remain usable. + s.historyLimit = T.limit; + if (!s.redo() || s.getText() !== teTrim.fixture + 'X') out.push('refusal lost redo operation');"##, + ]).await; + assert_eq!(result, ""); +} From 8b7ce30b14ef6aab3e82383b9daf90da6913b107 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sat, 17 Oct 2026 21:45:40 +0100 Subject: [PATCH 02/16] test: split browser batches and qualify the complete Chrome suite --- .github/workflows/public-root.yml | 79 +++++++ tests/browser/verify-wasm-binaries.py | 9 + tests/support/cycling.rs | 107 +++++++++ tests/support/lab_partition.rs | 40 ++++ tests/web_cycling.rs | 280 +----------------------- tests/web_cycling_history.rs | 176 +++++++++++++++ tests/web_ghost_transaction.rs | 128 ----------- tests/web_ghost_transaction_identity.rs | 56 +++++ tests/web_ghost_transaction_replay.rs | 50 +++++ tests/web_ghost_transaction_rollback.rs | 40 ++++ tests/web_lab.rs | 178 +-------------- tests/web_lab_lifecycle.rs | 139 ++++++++++++ tests/web_overflow.rs | 253 +-------------------- tests/web_overflow_insertion.rs | 198 +++++++++++++++++ tests/web_overflow_panel.rs | 76 +++++++ 15 files changed, 981 insertions(+), 828 deletions(-) create mode 100644 tests/support/cycling.rs create mode 100644 tests/support/lab_partition.rs create mode 100644 tests/web_cycling_history.rs create mode 100644 tests/web_ghost_transaction_identity.rs create mode 100644 tests/web_ghost_transaction_replay.rs create mode 100644 tests/web_ghost_transaction_rollback.rs create mode 100644 tests/web_lab_lifecycle.rs create mode 100644 tests/web_overflow_insertion.rs create mode 100644 tests/web_overflow_panel.rs diff --git a/.github/workflows/public-root.yml b/.github/workflows/public-root.yml index 241e68d..60f75ed 100644 --- a/.github/workflows/public-root.yml +++ b/.github/workflows/public-root.yml @@ -49,3 +49,82 @@ jobs: bash scripts/ci/run.sh cargo test --locked --workspace --all-targets bash scripts/ci/run.sh cargo test --locked --workspace --doc bash scripts/ci/run.sh cargo check --locked --workspace --target wasm32-unknown-unknown --lib + + browser-workspace: + # Source verification only: no self-hosted runners, secrets or publication. + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + RUSTFLAGS: '-D warnings' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.12' + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - name: Install qualified Rust and fetch locked public dependencies + run: | + rustup toolchain install 1.96 --profile minimal + rustup target add wasm32-unknown-unknown --toolchain 1.96 + bash scripts/ci/registry.sh + bash scripts/ci/run.sh --network cargo fetch --locked + - name: Install matching pinned browser and existing qualified tools + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/editor-browser-evidence" "$RUNNER_TEMP/editor-browser" + curl --fail --location --retry 3 --output "$RUNNER_TEMP/editor-chrome.zip" \ + https://storage.googleapis.com/chrome-for-testing-public/145.0.7632.75/linux64/chrome-linux64.zip + printf '%s %s\n' 9a8e09c96152ff1f98d1fcf855eff750424adbd355cedd2493adb86a642042cd "$RUNNER_TEMP/editor-chrome.zip" | sha256sum --check --strict + unzip -q "$RUNNER_TEMP/editor-chrome.zip" -d "$RUNNER_TEMP/editor-browser" + export CHROME="$RUNNER_TEMP/editor-browser/chrome-linux64/chrome" + bash scripts/ci/tools.sh + export CI_TOOLS_PATH=1 + source scripts/ci/env.sh + export CHROMEDRIVER="$CI_TOOLS/bin/chromedriver" + export WASM_BINDGEN_TEST_WEBDRIVER_JSON="$RUNNER_TEMP/editor-browser-evidence/webdriver.json" + python3 - <<'PY' + import json, os + from pathlib import Path + caps = json.loads(Path('webdriver.json').read_text()) + caps['goog:chromeOptions']['binary'] = os.environ['CHROME'] + Path(os.environ['WASM_BINDGEN_TEST_WEBDRIVER_JSON']).write_text(json.dumps(caps, indent=2) + '\n') + PY + { + git rev-parse HEAD + "$CHROME" --version + "$CHROMEDRIVER" --version + wasm-pack --version + wasm-bindgen --version + rustc --version + sha256sum "$RUNNER_TEMP/editor-chrome.zip" "$WASM_BINDGEN_TEST_WEBDRIVER_JSON" + } > "$RUNNER_TEMP/editor-browser-evidence/tool-identities.txt" + # Do not use the legacy wrapper that permits benchmark skips. + python3 tests/browser/verify-wasm-binaries.py --browser chrome \ + --output "$RUNNER_TEMP/editor-browser-evidence/audit" + python3 - <<'PY' + import hashlib, json, os, re + from pathlib import Path + folder = Path(os.environ['RUNNER_TEMP']) / 'editor-browser-evidence/audit' + receipt = json.loads((folder / 'receipt.json').read_text()) + expected = sorted(p.stem for p in Path('tests').glob('*.rs') if 'run_in_browser' in p.read_text()) + assert expected and receipt['binaries'] == expected + assert receipt['status'] == 'PASS' and len(receipt['runs']) == len(expected) + assert [row['binary'] for row in receipt['runs']] == expected + for row in receipt['runs']: + assert row['status'] == 'PASS' and row['exitCode'] == 0 + source = Path('tests', row['binary'] + '.rs').read_text() + count = len(re.findall(r'^#\[wasm_bindgen_test(?:\([^\]]*\))?\]', source, re.M)) + assert count > 0 and row['reportedCounts'] == [[str(count), '0', '0']], row + assert hashlib.sha256((folder / (row['binary'] + '.log')).read_bytes()).hexdigest() == row['logSha256'] + PY + - name: Retain every browser result, including failures + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: editor-browser-source-${{ github.sha }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/editor-browser-evidence + if-no-files-found: error diff --git a/tests/browser/verify-wasm-binaries.py b/tests/browser/verify-wasm-binaries.py index fb9d237..a600f6f 100644 --- a/tests/browser/verify-wasm-binaries.py +++ b/tests/browser/verify-wasm-binaries.py @@ -47,6 +47,7 @@ def main(): '--', '--locked', '--test', binary] log = output / (binary + '.log') started = time.monotonic() + cleanup_warning = None with log.open('w') as stream: child = subprocess.Popen(command, cwd=root, env=env, stdout=stream, stderr=subprocess.STDOUT, start_new_session=True) @@ -69,6 +70,12 @@ def main(): os.killpg(child.pid, signal.SIGTERM) except ProcessLookupError: pass + except PermissionError as error: + # macOS may deny a group signal after the runner exits. + # Preserve the domain result and disclose cleanup instead + # of aborting before this completed test is recorded. + cleanup_warning = {'status':'DENIED','processGroup':child.pid, + 'error':str(error)} text = log.read_text() counts = re.findall(r'test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored', text) if not counts and status == 'PASS': @@ -77,6 +84,8 @@ def main(): 'status': status, 'exitCode': code, 'seconds': round(time.monotonic() - started, 3), 'reportedCounts': counts, 'logSha256': hashlib.sha256(log.read_bytes()).hexdigest()} + if cleanup_warning: + run['cleanupWarning'] = cleanup_warning receipt['runs'].append(run) (output / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') print(json.dumps(run), flush=True) diff --git a/tests/support/cycling.rs b/tests/support/cycling.rs new file mode 100644 index 0000000..091ddb0 --- /dev/null +++ b/tests/support/cycling.rs @@ -0,0 +1,107 @@ +//! Browser tests for in-place cycling of alternatives (issue #9, spec R-2.4, +//! R-2.6, R-3.3, R-3.6): hovering a span and pressing ArrowUp/ArrowDown, +//! Alt+ArrowUp/Alt+ArrowDown with the caret in a span, and clicking a dot +//! swap the active alternative through the real exported +//! `set_active_alternative`, fixing a preceding "a"/"an", as one undo step +//! whose undo and redo replay the swap in the model. Run with `wasm-pack test +//! --headless --chrome`. Real scripts, real stylesheets and the real +//! document API; nothing is mocked. See `tests/web.rs` for why the browser +//! tests are split across binaries. + +use super::*; +use terraphim_alternatives::{write, Document, Source, SpanKind}; + +/// "Pass me a paperclip. Drop this.\n\nSecond line.\n" with span `s1` over +/// "paperclip" (alternatives "eraser" and "thumbtack") and ghost `g1` over +/// " Drop this.", built through the crate so the fixture is always valid. +fn fixture() -> String { + let mut doc = Document::new("Pass me a paperclip. Drop this.\n\nSecond line.\n"); + let id = doc.add_span(SpanKind::Word, 10, 19).unwrap(); + doc.add_alternative(&id, "eraser", Source::Human, None) + .unwrap(); + doc.add_alternative(&id, "thumbtack", Source::Human, None) + .unwrap(); + doc.ghost(20, 31).unwrap(); + write(&doc) +} + +/// Fresh editor in Write_On mode with the fixture open, plus helpers on +/// `window.teCyc`. +fn setup() { + let _document = fresh_full_editor(); + let script = format!( + r##"(() => {{ + const ed = window.__teEditor; + const api = window.wasmBindings; + ed.openDocument({src}, 'cycling-test-' + Math.random()); + ed.chrome.setMode('write-on'); + ed.indicators.flush(); + window.teCyc = {{ + ed, api, + // The model read directly: ed.annotations() re-syncs by text + // first, which would hide a model that drifted from the surface. + model() {{ + const a = api.document_annotations(); + const s = a.spans.map((x) => x.id + '@' + x.anchor.start + '#' + x.active); + const g = a.ghosts.map((x) => x.id + '@' + x.anchor.start); + return s.concat(g).join(' ') + ' aside=' + (a.setAside.spans.length + a.setAside.ghosts.length); + }}, + // Text, model and lit dot after the indicator refresh. + state() {{ + ed.indicators.flush(); + const dots = Array.from(document.querySelectorAll('.te-indicators [data-span-id="s1"] .te-ind-dot')); + const lit = dots.findIndex((d) => d.classList.contains('te-ind-dot--active')); + const sync = api.document_body() === ed.surface.getText() ? '' : ' DRIFT'; + return ed.surface.getText().split('.')[0] + ' | ' + teCyc.model() + ' | lit=' + lit + sync; + }}, + // The pointer is placed over the centre of the span's first + // rendered box, with real client coordinates: a plain arrow + // re-checks them against the live layout before swapping. + hover(id) {{ + const el = ed.surface.root.querySelector('[data-te-decoration~="indicators:' + id + '"]'); + const r = el.getClientRects()[0]; + teCyc.pointAt(el, r.left + r.width / 2, r.top + r.height / 2); + }}, + // Move the pointer to (x, y) within the surface, over `el`. + pointAt(el, x, y) {{ + const init = {{ bubbles: true, clientX: x, clientY: y }}; + el.dispatchEvent(new MouseEvent('mouseover', init)); + el.dispatchEvent(new MouseEvent('mousemove', init)); + }}, + unhover() {{ + ed.surface.root.dispatchEvent(new MouseEvent('mouseleave')); + }}, + key(key, opts) {{ return teTest.key(ed.surface.root, key, opts); }}, + dot(i) {{ + const dot = document.querySelectorAll('.te-indicators [data-span-id="s1"] .te-ind-dot')[i]; + dot.dispatchEvent(new MouseEvent('click', {{ bubbles: true, cancelable: true }})); + }}, + }}; + return 'ok'; + }})()"##, + src = js_string_literal(&fixture()), + ); + assert_eq!(js_string(&script), "ok"); +} + +pub(super) const PAPERCLIP: &str = "Pass me a paperclip | s1@10#0 g1@20 aside=0 | lit=0"; +pub(super) const ERASER: &str = "Pass me an eraser | s1@11#1 g1@18 aside=0 | lit=1"; +pub(super) const THUMBTACK: &str = "Pass me a thumbtack | s1@10#2 g1@20 aside=0 | lit=2"; + +/// Run `body` (JavaScript statements using `out`, `teCyc` and `ed`) after +/// setup; it returns the problems found, joined. +pub(super) fn run(body: &str) -> String { + setup(); + js_string(&format!( + r##"(() => {{ + const out = []; + const ed = teCyc.ed; + const expect = (label, want) => {{ + const got = teCyc.state(); + if (got !== want) out.push(label + ': ' + got); + }}; + {body} + return out.join('; '); + }})()"## + )) +} diff --git a/tests/support/lab_partition.rs b/tests/support/lab_partition.rs new file mode 100644 index 0000000..958bc2b --- /dev/null +++ b/tests/support/lab_partition.rs @@ -0,0 +1,40 @@ +//! Browser tests for the Lab popover and its marks (issue #14, spec R-8.1, +//! R-8.2). Run with `wasm-pack test --headless --chrome`. Real scripts, real +//! stylesheets, the real exported document and Lab API on +//! `window.wasmBindings` and the committed fixture +//! `tests/fixtures/lab/lab.md`; nothing is mocked. The expected marks are +//! computed in the same test by the `terraphim_lab` engine itself, so the +//! browser must show exactly what the engine returns. See `tests/web.rs` for +//! why the browser tests are split across binaries. + +use super::*; + +pub(super) const FIXTURE: &str = include_str!("../fixtures/lab/lab.md"); + +/// Fresh editor, fixture open, Write_On on, helpers on `window.teLab`. +pub(super) fn setup() { + let _document = fresh_full_editor(); + let src = format!( + r##"(() => {{ + window.teLab = {{ + fixture: {fixture}, + editor() {{ return window.__teEditor; }}, + lab() {{ return window.__teEditor.lab; }}, + popover() {{ return window.__teEditor.lab.root; }}, + pill() {{ return teTest.control('lab'); }}, + item(id) {{ return teLab.popover().querySelector('[data-action="' + id + '"]'); }}, + marks() {{ return teLab.editor().decorations.get('lab-marks'); }}, + triples() {{ return teLab.marks().map((m) => [m.start, m.end, m.data.kind]); }}, + markEl(id) {{ + return teLab.editor().surface.root.querySelector('[data-te-decoration~="lab-marks:' + id + '"]'); + }}, + }}; + const ed = window.__teEditor; + ed.openDocument(teLab.fixture, 'lab-test-' + Math.random()); + ed.chrome.setMode('write-on'); + return ed.lab ? 'ok' : 'no lab'; + }})()"##, + fixture = js_string_literal(FIXTURE), + ); + assert_eq!(js_string(&src), "ok"); +} diff --git a/tests/web_cycling.rs b/tests/web_cycling.rs index 4488e6a..9685638 100644 --- a/tests/web_cycling.rs +++ b/tests/web_cycling.rs @@ -1,116 +1,12 @@ -//! Browser tests for in-place cycling of alternatives (issue #9, spec R-2.4, -//! R-2.6, R-3.3, R-3.6): hovering a span and pressing ArrowUp/ArrowDown, -//! Alt+ArrowUp/Alt+ArrowDown with the caret in a span, and clicking a dot -//! swap the active alternative through the real exported -//! `set_active_alternative`, fixing a preceding "a"/"an", as one undo step -//! whose undo and redo replay the swap in the model. Run with `wasm-pack test -//! --headless --chrome`. Real scripts, real stylesheets and the real -//! document API; nothing is mocked. See `tests/web.rs` for why the browser -//! tests are split across binaries. +//! Mechanical partition of the original cycling tests; shared real fixtures unchanged. #![cfg(target_arch = "wasm32")] - mod support; - use support::*; -use terraphim_alternatives::{write, Document, Source, SpanKind}; - +#[path = "support/cycling.rs"] +mod cycling; +use cycling::*; wasm_bindgen_test_configure!(run_in_browser); -/// "Pass me a paperclip. Drop this.\n\nSecond line.\n" with span `s1` over -/// "paperclip" (alternatives "eraser" and "thumbtack") and ghost `g1` over -/// " Drop this.", built through the crate so the fixture is always valid. -fn fixture() -> String { - let mut doc = Document::new("Pass me a paperclip. Drop this.\n\nSecond line.\n"); - let id = doc.add_span(SpanKind::Word, 10, 19).unwrap(); - doc.add_alternative(&id, "eraser", Source::Human, None) - .unwrap(); - doc.add_alternative(&id, "thumbtack", Source::Human, None) - .unwrap(); - doc.ghost(20, 31).unwrap(); - write(&doc) -} - -/// Fresh editor in Write_On mode with the fixture open, plus helpers on -/// `window.teCyc`. -fn setup() { - let _document = fresh_full_editor(); - let script = format!( - r##"(() => {{ - const ed = window.__teEditor; - const api = window.wasmBindings; - ed.openDocument({src}, 'cycling-test-' + Math.random()); - ed.chrome.setMode('write-on'); - ed.indicators.flush(); - window.teCyc = {{ - ed, api, - // The model read directly: ed.annotations() re-syncs by text - // first, which would hide a model that drifted from the surface. - model() {{ - const a = api.document_annotations(); - const s = a.spans.map((x) => x.id + '@' + x.anchor.start + '#' + x.active); - const g = a.ghosts.map((x) => x.id + '@' + x.anchor.start); - return s.concat(g).join(' ') + ' aside=' + (a.setAside.spans.length + a.setAside.ghosts.length); - }}, - // Text, model and lit dot after the indicator refresh. - state() {{ - ed.indicators.flush(); - const dots = Array.from(document.querySelectorAll('.te-indicators [data-span-id="s1"] .te-ind-dot')); - const lit = dots.findIndex((d) => d.classList.contains('te-ind-dot--active')); - const sync = api.document_body() === ed.surface.getText() ? '' : ' DRIFT'; - return ed.surface.getText().split('.')[0] + ' | ' + teCyc.model() + ' | lit=' + lit + sync; - }}, - // The pointer is placed over the centre of the span's first - // rendered box, with real client coordinates: a plain arrow - // re-checks them against the live layout before swapping. - hover(id) {{ - const el = ed.surface.root.querySelector('[data-te-decoration~="indicators:' + id + '"]'); - const r = el.getClientRects()[0]; - teCyc.pointAt(el, r.left + r.width / 2, r.top + r.height / 2); - }}, - // Move the pointer to (x, y) within the surface, over `el`. - pointAt(el, x, y) {{ - const init = {{ bubbles: true, clientX: x, clientY: y }}; - el.dispatchEvent(new MouseEvent('mouseover', init)); - el.dispatchEvent(new MouseEvent('mousemove', init)); - }}, - unhover() {{ - ed.surface.root.dispatchEvent(new MouseEvent('mouseleave')); - }}, - key(key, opts) {{ return teTest.key(ed.surface.root, key, opts); }}, - dot(i) {{ - const dot = document.querySelectorAll('.te-indicators [data-span-id="s1"] .te-ind-dot')[i]; - dot.dispatchEvent(new MouseEvent('click', {{ bubbles: true, cancelable: true }})); - }}, - }}; - return 'ok'; - }})()"##, - src = js_string_literal(&fixture()), - ); - assert_eq!(js_string(&script), "ok"); -} - -const PAPERCLIP: &str = "Pass me a paperclip | s1@10#0 g1@20 aside=0 | lit=0"; -const ERASER: &str = "Pass me an eraser | s1@11#1 g1@18 aside=0 | lit=1"; -const THUMBTACK: &str = "Pass me a thumbtack | s1@10#2 g1@20 aside=0 | lit=2"; - -/// Run `body` (JavaScript statements using `out`, `teCyc` and `ed`) after -/// setup; it returns the problems found, joined. -fn run(body: &str) -> String { - setup(); - js_string(&format!( - r##"(() => {{ - const out = []; - const ed = teCyc.ed; - const expect = (label, want) => {{ - const got = teCyc.state(); - if (got !== want) out.push(label + ': ' + got); - }}; - {body} - return out.join('; '); - }})()"## - )) -} - #[wasm_bindgen_test] fn test_hover_and_arrows_cycle_with_wrap_and_one_undo_step_each() { let body = format!( @@ -245,171 +141,3 @@ fn test_dot_click_jumps_and_alt_arrows_cycle_from_the_caret() { ); assert_eq!(run(&body), ""); } - -#[wasm_bindgen_test] -fn test_save_reopen_keeps_the_active_alternative_and_refused_swaps_change_nothing() { - let body = format!( - r##" - teCyc.dot(1); - const saved = ed.saveDocument(); - const opened = ed.openDocument(saved, 'cycling-reopen-' + Math.random()); - if (opened.unresolved !== 0 || opened.warning) out.push('reopen ' + JSON.stringify(opened)); - ed.chrome.setMode('write-on'); - expect('reopened', {ERASER:?}); - if (ed.exportDocument() !== 'Pass me an eraser.\n\nSecond line.\n') out.push('export ' + JSON.stringify(ed.exportDocument())); - // Cycling continues from the saved active index. - teCyc.hover('s1'); - teCyc.key('ArrowDown'); - expect('cycled after reopen', {THUMBTACK:?}); - - // A swap the model refuses throws and changes nothing. - const depth = ed.surface.historyIndex; - for (const [id, i] of [['s1', 9], ['s9', 1]]) {{ - let threw = false; - try {{ ed.swapAlternative(id, i); }} catch (err) {{ threw = true; }} - if (!threw) out.push('swap ' + id + '/' + i + ' did not throw'); - }} - // Making the active alternative active again records nothing. - const same = ed.swapAlternative('s1', 2); - if (same.edit !== null) out.push('same index edit ' + JSON.stringify(same.edit)); - if (ed.surface.historyIndex !== depth) out.push('refused swaps recorded'); - expect('refused', {THUMBTACK:?}); - "## - ); - assert_eq!(run(&body), ""); -} - -/// "Give me a eraser." (an article typed against the a/an rule) with span -/// `s1` over "eraser" and alternative "thumbtack". -fn ungrammatical_fixture() -> String { - let mut doc = Document::new("Give me a eraser.\n"); - let id = doc.add_span(SpanKind::Word, 10, 16).unwrap(); - doc.add_alternative(&id, "thumbtack", Source::Human, None) - .unwrap(); - write(&doc) -} - -#[wasm_bindgen_test] -fn test_undo_to_an_ungrammatical_article_keeps_model_and_surface_equal() { - let body = format!( - r##" - const api = teCyc.api; - ed.openDocument({src}, 'cycling-article-' + Math.random()); - ed.chrome.setMode('write-on'); - ed.indicators.flush(); - ed.swapAlternative('s1', 1); - if (ed.surface.getText() !== 'Give me a thumbtack.\n') out.push('swap ' + JSON.stringify(ed.surface.getText())); - // Undo restores the author's text exactly; the model, which would - // derive "an eraser", is re-synced from the surface. - ed.surface.undo(); - if (ed.surface.getText() !== 'Give me a eraser.\n') out.push('undo ' + JSON.stringify(ed.surface.getText())); - if (api.document_body() !== ed.surface.getText()) out.push('model drifted: ' + JSON.stringify(api.document_body())); - const a = api.document_annotations(); - const s = a.spans.map((x) => x.id + '@' + x.anchor.start + '#' + x.active).join(' '); - if (s !== 's1@10#0') out.push('spans ' + s + ' aside=' + a.setAside.spans.length); - ed.surface.redo(); - if (ed.surface.getText() !== 'Give me a thumbtack.\n' || api.document_body() !== ed.surface.getText()) out.push('redo'); - "##, - src = js_string_literal(&ungrammatical_fixture()), - ); - assert_eq!(run(&body), ""); -} - -#[wasm_bindgen_test] -fn test_stale_hover_never_swallows_plain_arrows() { - let body = format!( - r##" - const root = ed.surface.root; - ed.surface.focus(); - ed.surface.setSelectionOffsets(3); - const depth = ed.surface.historyIndex; - // The point over the start of "Second line." (plain text, no span). - const second = (() => {{ - const r = document.createRange(); - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - let n; - while ((n = walker.nextNode())) {{ - const i = n.data.indexOf('Second'); - if (i >= 0) {{ r.setStart(n, i); r.setEnd(n, i + 1); break; }} - }} - const b = r.getClientRects()[0]; - return {{ el: n.parentElement, x: b.left + b.width / 2, y: b.top + b.height / 2 }}; - }})(); - - // 1. Hover, then move the pointer onto plain text: no swap. - teCyc.hover('s1'); - teCyc.pointAt(second.el, second.x, second.y); - if (teCyc.key('ArrowDown')) out.push('moved away: arrow prevented'); - expect('moved away', {PAPERCLIP:?}); - - // 2. Hover, then leave the surface: no swap. - teCyc.hover('s1'); - teCyc.unhover(); - if (teCyc.key('ArrowDown')) out.push('left: arrow prevented'); - expect('left', {PAPERCLIP:?}); - - // 3. Hover, then window blur: no swap. - teCyc.hover('s1'); - window.dispatchEvent(new Event('blur')); - if (teCyc.key('ArrowDown')) out.push('blur: arrow prevented'); - expect('blur', {PAPERCLIP:?}); - - // 4. Hover, then type before the span so it shifts out from under - // the still pointer: the stale hover does not take the arrow. - teCyc.hover('s1'); - ed.surface.replaceRange(0, 0, 'Please, if you would kindly, '); - ed.indicators.flush(); - const caret = ed.surface.getSelectionOffsets().start; - if (teCyc.key('ArrowDown')) out.push('shifted: arrow prevented'); - if (ed.surface.getSelectionOffsets().start !== caret) out.push('shifted: caret changed'); - const shifted = teCyc.state(); - if (!shifted.startsWith('Please, if you would kindly, Pass me a paperclip') || !shifted.includes('#0')) - out.push('shifted: ' + shifted); - ed.surface.undo(); - expect('undo typing', {PAPERCLIP:?}); - if (ed.surface.historyIndex !== depth) out.push('history ' + (ed.surface.historyIndex - depth)); - - // 5. Hovering again, repeated swaps still work, including when the - // new alternative's text no longer reaches the pointer ("an - // eraser" is shorter than "a paperclip"): the hover is held across - // a swap of the same span. - const el = root.querySelector('[data-te-decoration~="indicators:s1"]'); - const r = el.getClientRects()[0]; - teCyc.pointAt(el, r.right - 2, r.top + r.height / 2); - if (!teCyc.key('ArrowDown')) out.push('hover again: not prevented'); - expect('down 1', {ERASER:?}); - if (!teCyc.key('ArrowDown')) out.push('held: not prevented'); - expect('down 2', {THUMBTACK:?}); - teCyc.key('ArrowDown'); - expect('down 3', {PAPERCLIP:?}); - - // 6. A re-layout ends the post-swap hold: swap to the shorter - // "an eraser" under a still pointer past its end, resize, and the - // next plain arrow is the browser's again. - const el2 = root.querySelector('[data-te-decoration~="indicators:s1"]'); - const r2 = el2.getClientRects()[0]; - teCyc.pointAt(el2, r2.right - 2, r2.top + r2.height / 2); - if (!teCyc.key('ArrowDown')) out.push('relayout: first swap not prevented'); - expect('relayout swap', {ERASER:?}); - window.dispatchEvent(new Event('resize')); - if (teCyc.key('ArrowDown')) out.push('relayout: held hover took the arrow'); - expect('after relayout', {ERASER:?}); - - // 7. Another decoration layer re-rendering also ends the hold - // (e.g. Lab marks), while this layer's own refresh does not. - ed.swapAlternative('s1', 0); - ed.indicators.flush(); - expect('back to paperclip', {PAPERCLIP:?}); - const el3 = root.querySelector('[data-te-decoration~="indicators:s1"]'); - const r3 = el3.getClientRects()[0]; - teCyc.pointAt(el3, r3.right - 2, r3.top + r3.height / 2); - if (!teCyc.key('ArrowDown')) out.push('layer: first swap not prevented'); - expect('layer swap', {ERASER:?}); - ed.decorations.set('te-test', [{{ id: 'probe', start: 0, end: 4, className: 'te-test-probe' }}]); - if (teCyc.key('ArrowDown')) out.push('layer: held hover took the arrow'); - expect('after other layer', {ERASER:?}); - ed.decorations.clear('te-test'); - "## - ); - assert_eq!(run(&body), ""); -} diff --git a/tests/web_cycling_history.rs b/tests/web_cycling_history.rs new file mode 100644 index 0000000..7d53ae3 --- /dev/null +++ b/tests/web_cycling_history.rs @@ -0,0 +1,176 @@ +//! Mechanical partition of the original cycling tests; shared real fixtures unchanged. +#![cfg(target_arch = "wasm32")] +mod support; +use support::*; +#[path = "support/cycling.rs"] +mod cycling; +use cycling::*; +wasm_bindgen_test_configure!(run_in_browser); + +/// "Give me a eraser." (an article typed against the a/an rule) with span +/// `s1` over "eraser" and alternative "thumbtack". +fn ungrammatical_fixture() -> String { + let mut doc = Document::new("Give me a eraser.\n"); + let id = doc.add_span(SpanKind::Word, 10, 16).unwrap(); + doc.add_alternative(&id, "thumbtack", Source::Human, None) + .unwrap(); + write(&doc) +} + +#[wasm_bindgen_test] +fn test_save_reopen_keeps_the_active_alternative_and_refused_swaps_change_nothing() { + let body = format!( + r##" + teCyc.dot(1); + const saved = ed.saveDocument(); + const opened = ed.openDocument(saved, 'cycling-reopen-' + Math.random()); + if (opened.unresolved !== 0 || opened.warning) out.push('reopen ' + JSON.stringify(opened)); + ed.chrome.setMode('write-on'); + expect('reopened', {ERASER:?}); + if (ed.exportDocument() !== 'Pass me an eraser.\n\nSecond line.\n') out.push('export ' + JSON.stringify(ed.exportDocument())); + // Cycling continues from the saved active index. + teCyc.hover('s1'); + teCyc.key('ArrowDown'); + expect('cycled after reopen', {THUMBTACK:?}); + + // A swap the model refuses throws and changes nothing. + const depth = ed.surface.historyIndex; + for (const [id, i] of [['s1', 9], ['s9', 1]]) {{ + let threw = false; + try {{ ed.swapAlternative(id, i); }} catch (err) {{ threw = true; }} + if (!threw) out.push('swap ' + id + '/' + i + ' did not throw'); + }} + // Making the active alternative active again records nothing. + const same = ed.swapAlternative('s1', 2); + if (same.edit !== null) out.push('same index edit ' + JSON.stringify(same.edit)); + if (ed.surface.historyIndex !== depth) out.push('refused swaps recorded'); + expect('refused', {THUMBTACK:?}); + "## + ); + assert_eq!(run(&body), ""); +} + +#[wasm_bindgen_test] +fn test_undo_to_an_ungrammatical_article_keeps_model_and_surface_equal() { + let body = format!( + r##" + const api = teCyc.api; + ed.openDocument({src}, 'cycling-article-' + Math.random()); + ed.chrome.setMode('write-on'); + ed.indicators.flush(); + ed.swapAlternative('s1', 1); + if (ed.surface.getText() !== 'Give me a thumbtack.\n') out.push('swap ' + JSON.stringify(ed.surface.getText())); + // Undo restores the author's text exactly; the model, which would + // derive "an eraser", is re-synced from the surface. + ed.surface.undo(); + if (ed.surface.getText() !== 'Give me a eraser.\n') out.push('undo ' + JSON.stringify(ed.surface.getText())); + if (api.document_body() !== ed.surface.getText()) out.push('model drifted: ' + JSON.stringify(api.document_body())); + const a = api.document_annotations(); + const s = a.spans.map((x) => x.id + '@' + x.anchor.start + '#' + x.active).join(' '); + if (s !== 's1@10#0') out.push('spans ' + s + ' aside=' + a.setAside.spans.length); + ed.surface.redo(); + if (ed.surface.getText() !== 'Give me a thumbtack.\n' || api.document_body() !== ed.surface.getText()) out.push('redo'); + "##, + src = js_string_literal(&ungrammatical_fixture()), + ); + assert_eq!(run(&body), ""); +} + +#[wasm_bindgen_test] +fn test_stale_hover_never_swallows_plain_arrows() { + let body = format!( + r##" + const root = ed.surface.root; + ed.surface.focus(); + ed.surface.setSelectionOffsets(3); + const depth = ed.surface.historyIndex; + // The point over the start of "Second line." (plain text, no span). + const second = (() => {{ + const r = document.createRange(); + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let n; + while ((n = walker.nextNode())) {{ + const i = n.data.indexOf('Second'); + if (i >= 0) {{ r.setStart(n, i); r.setEnd(n, i + 1); break; }} + }} + const b = r.getClientRects()[0]; + return {{ el: n.parentElement, x: b.left + b.width / 2, y: b.top + b.height / 2 }}; + }})(); + + // 1. Hover, then move the pointer onto plain text: no swap. + teCyc.hover('s1'); + teCyc.pointAt(second.el, second.x, second.y); + if (teCyc.key('ArrowDown')) out.push('moved away: arrow prevented'); + expect('moved away', {PAPERCLIP:?}); + + // 2. Hover, then leave the surface: no swap. + teCyc.hover('s1'); + teCyc.unhover(); + if (teCyc.key('ArrowDown')) out.push('left: arrow prevented'); + expect('left', {PAPERCLIP:?}); + + // 3. Hover, then window blur: no swap. + teCyc.hover('s1'); + window.dispatchEvent(new Event('blur')); + if (teCyc.key('ArrowDown')) out.push('blur: arrow prevented'); + expect('blur', {PAPERCLIP:?}); + + // 4. Hover, then type before the span so it shifts out from under + // the still pointer: the stale hover does not take the arrow. + teCyc.hover('s1'); + ed.surface.replaceRange(0, 0, 'Please, if you would kindly, '); + ed.indicators.flush(); + const caret = ed.surface.getSelectionOffsets().start; + if (teCyc.key('ArrowDown')) out.push('shifted: arrow prevented'); + if (ed.surface.getSelectionOffsets().start !== caret) out.push('shifted: caret changed'); + const shifted = teCyc.state(); + if (!shifted.startsWith('Please, if you would kindly, Pass me a paperclip') || !shifted.includes('#0')) + out.push('shifted: ' + shifted); + ed.surface.undo(); + expect('undo typing', {PAPERCLIP:?}); + if (ed.surface.historyIndex !== depth) out.push('history ' + (ed.surface.historyIndex - depth)); + + // 5. Hovering again, repeated swaps still work, including when the + // new alternative's text no longer reaches the pointer ("an + // eraser" is shorter than "a paperclip"): the hover is held across + // a swap of the same span. + const el = root.querySelector('[data-te-decoration~="indicators:s1"]'); + const r = el.getClientRects()[0]; + teCyc.pointAt(el, r.right - 2, r.top + r.height / 2); + if (!teCyc.key('ArrowDown')) out.push('hover again: not prevented'); + expect('down 1', {ERASER:?}); + if (!teCyc.key('ArrowDown')) out.push('held: not prevented'); + expect('down 2', {THUMBTACK:?}); + teCyc.key('ArrowDown'); + expect('down 3', {PAPERCLIP:?}); + + // 6. A re-layout ends the post-swap hold: swap to the shorter + // "an eraser" under a still pointer past its end, resize, and the + // next plain arrow is the browser's again. + const el2 = root.querySelector('[data-te-decoration~="indicators:s1"]'); + const r2 = el2.getClientRects()[0]; + teCyc.pointAt(el2, r2.right - 2, r2.top + r2.height / 2); + if (!teCyc.key('ArrowDown')) out.push('relayout: first swap not prevented'); + expect('relayout swap', {ERASER:?}); + window.dispatchEvent(new Event('resize')); + if (teCyc.key('ArrowDown')) out.push('relayout: held hover took the arrow'); + expect('after relayout', {ERASER:?}); + + // 7. Another decoration layer re-rendering also ends the hold + // (e.g. Lab marks), while this layer's own refresh does not. + ed.swapAlternative('s1', 0); + ed.indicators.flush(); + expect('back to paperclip', {PAPERCLIP:?}); + const el3 = root.querySelector('[data-te-decoration~="indicators:s1"]'); + const r3 = el3.getClientRects()[0]; + teCyc.pointAt(el3, r3.right - 2, r3.top + r3.height / 2); + if (!teCyc.key('ArrowDown')) out.push('layer: first swap not prevented'); + expect('layer swap', {ERASER:?}); + ed.decorations.set('te-test', [{{ id: 'probe', start: 0, end: 4, className: 'te-test-probe' }}]); + if (teCyc.key('ArrowDown')) out.push('layer: held hover took the arrow'); + expect('after other layer', {ERASER:?}); + ed.decorations.clear('te-test'); + "## + ); + assert_eq!(run(&body), ""); +} diff --git a/tests/web_ghost_transaction.rs b/tests/web_ghost_transaction.rs index ec7d119..ac6e041 100644 --- a/tests/web_ghost_transaction.rs +++ b/tests/web_ghost_transaction.rs @@ -51,131 +51,3 @@ async fn persistent_ghost_ids_above_js_safe_integer_are_not_coerced() { if(JSON.stringify(api.document_annotations().ghosts.map(g=>g.id))!==JSON.stringify(ids)) out.push('large-ID persistence'); "#]).await, ""); } - -#[wasm_bindgen_test] -async fn protected_done_is_one_persistent_undo_step_and_preserves_manual_ghosts() { - support::trim::setup(); - assert_eq!(run_steps(&[r#" - const api=ed.documentApi(), trim=ed.lab.trim; - trim.select('sharper'); - const ranges=trim.active; - if (!ranges.length) out.push('missing cuts'); - const body=s.getText(); - // A disjoint manual ghost is not promoted or removed by Done/undo. - const at=0; // fixture heading marker, outside all protected cut ranges - api.ghost_range(at,at+1); - const manual=api.document_annotations().ghosts[0].id; - const history=s.history.length; - if(trim.done()!==true) return 'Done setup failed: '+trim.live.textContent; - if(s.getText()!==body || s.history.length!==history+1) out.push('body/history'); - if(ed.decorations.get('lab-trim').length || trim.plan) out.push('transients retained'); - if(!s.undo()) out.push('undo refused'); - if(JSON.stringify(api.document_annotations().ghosts.map(g=>g.id))!==JSON.stringify([manual])) out.push('manual lost'); - if(!s.redo()) out.push('redo refused'); - const saved=api.save_document(); - const ids=api.document_annotations().ghosts.map(g=>g.id); - ed.openDocument(saved,'ghost-reopen'); - if(JSON.stringify(api.document_annotations().ghosts.map(g=>g.id))!==JSON.stringify(ids)) out.push('persistence identities'); - "#]).await, ""); -} - -#[wasm_bindgen_test] -async fn capacities_and_partial_record_failure_restore_exact_native_receipt_state() { - support::trim::setup(); - assert_eq!(run_steps(&[r#" - const api=ed.documentApi(),trim=ed.lab.trim; - trim.select('sharper');s.historyLimit=1; - const saved=api.save_document(), history=JSON.stringify(s.history), index=s.historyIndex; - if(trim.done()!==false || api.save_document()!==saved || JSON.stringify(s.history)!==history || s.historyIndex!==index) out.push('capacity mutated'); - if(!trim.plan || !ed.decorations.get('lab-trim').length) out.push('capacity retired plan'); - s.historyLimit=2; - const record=s.record;let recordEntered=false; - s.record=function(){recordEntered=true;this.history.length=0;this.historyIndex=-1;throw Error('injected record fault');}; - if(trim.done()!==false) out.push('record fault accepted'); - s.record=record; - if(!recordEntered) return 'record fault setup not reached: '+trim.live.textContent; - if(api.save_document()!==saved || JSON.stringify(s.history)!==history || s.historyIndex!==index) out.push('failed record changed checkpoints'); - if(trim.done()!==true || !s.canUndo()) return 'capacity2 setup failed: '+trim.live.textContent; - const ids=api.document_annotations().ghosts.map(g=>g.id); - if(ids[0]!=='g1') out.push('failed record retained allocation'); - if(!s.undo() || !s.redo()) out.push('capacity2 history'); - "#]).await, ""); -} - -#[wasm_bindgen_test] -async fn mixed_typing_and_ghost_group_uses_intermediate_body_and_atomic_last_refusal() { - support::trim::setup(); - assert_eq!(run_steps(&[r#" - const api=ed.documentApi(),trim=ed.lab.trim; - trim.select('sharper');if(trim.done()!==true) return 'mixed Done setup failed: '+trim.live.textContent; - const body=s.getText();s.replaceRange(body.length,body.length,'X',{source:'typing'}); - if(!s.squashHistory(2)) out.push('squash'); - if(!s.undo() || s.getText()!==body || api.document_annotations().ghosts.length) out.push('mixed undo'); - if(!s.redo() || s.getText()!==body+'X') out.push('mixed redo'); - // Interleave real native manual metadata; last ghost replay must refuse - // even though its earlier text deletion would have been valid. - api.ghost_range(0,1); - const history=JSON.stringify(s.history),index=s.historyIndex,text=s.getText(),saved=api.save_document(); - if(s.undo()!==false) out.push('receipt conflict accepted'); - if(s.getText()!==text || api.save_document()!==saved || JSON.stringify(s.history)!==history || s.historyIndex!==index) out.push('partial mixed mutation'); - "#]).await, ""); -} - -#[wasm_bindgen_test] -async fn role_invalidation_during_record_aborts_document_but_never_resurrects_fades() { - support::trim::setup(); - assert_eq!(run_steps(&[r#" - const api=ed.documentApi(),trim=ed.lab.trim; - trim.select('sharper');const saved=api.save_document(),history=JSON.stringify(s.history); - const record=s.record;let recordEntered=false; - s.record=function(...args){recordEntered=true;record.apply(this,args);api.kg_clear_thesaurus();}; - if(trim.done()!==false) out.push('role invalidation accepted');s.record=record; - if(!recordEntered) return 'role fault setup not reached: '+trim.live.textContent; - if(api.save_document()!==saved || JSON.stringify(s.history)!==history) out.push('document rollback failed'); - if(trim.plan || ed.decorations.get('lab-trim').length || trim.kept.size) out.push('role fades resurrected'); - "#]).await, ""); -} - -#[wasm_bindgen_test] -async fn interleaved_native_write_refuses_abort_and_requires_explicit_canonical_reopen() { - support::trim::setup(); - assert_eq!(run_steps(&[r#" - const api=ed.documentApi(),trim=ed.lab.trim;trim.select('sharper'); - const record=s.record;let recordEntered=false; - s.record=function(...args){recordEntered=true;record.apply(this,args);api.set_document_overflow('external notes');throw Error('interleaved');}; - if(trim.done()!==false || !ed.ghostHistoryConflict) out.push('missing conflict');s.record=record; - if(!recordEntered) return 'interleaved fault setup not reached: '+trim.live.textContent; - if(api.document_annotations().overflow!=='external notes') out.push('interleaved data overwritten'); - const canonical=api.save_document(); - ed.openDocument(canonical,'manual-recovery'); - if(ed.ghostHistoryConflict || s.canUndo() || s.canRedo()) out.push('canonical history not reset'); - if(api.document_annotations().overflow!=='external notes') out.push('recovery lost notes'); - "#]).await, ""); -} - -#[wasm_bindgen_test] -async fn all_six_real_operations_and_unicode_guard_use_actual_wasm_bridge() { - support::trim::setup(); - assert_eq!(run_steps(&[r#" - const api=ed.documentApi(),trim=ed.lab.trim;trim.select('sharper'); - if(trim.done()!==true) return 'six-kind Done setup failed: '+trim.live.textContent; - if(!s.history[s.historyIndex]?.edits?.[0]?.ghost?.change) return 'six-kind setup missing actual ghost receipt'; - const body=s.getText(),receipt=s.history[s.historyIndex].edits[0].ghost.change; - const at=body.indexOf('Café'); - const changed=api.alt_create_span('word',at,at+4,'Coffee','human'); - const span=changed.after; - const empty={body,start:0,deletedText:'',insertedText:''}; - const operations=[{kind:'ghost',body,change:receipt,undo:true}, - {...empty,kind:'edit'}, {...empty,kind:'alt',span:span.id,target:span}, - {...empty,kind:'move',move:{start:0,end:body.length,to:body.length}}, - {...empty,kind:'swap',span:span.id,from:0,to:0}, - {...empty,kind:'overflow',before:'',after:''}]; - const begun=api.begin_document_history_replay(body,body,JSON.stringify(operations)); - const aborted=api.abort_document_history_transaction(begun.token); - if(aborted.ok!==true || aborted.rolledBack!==true || aborted.token!==begun.token) out.push('exact ack'); - ed.openDocument('a😀中b','unicode'); - const before=api.save_document(); - try {api.begin_document_history_replay('a😀中b','bad',JSON.stringify([{kind:'edit',body:'bad',start:2,deletedText:'',insertedText:'X'}]));out.push('half-surrogate accepted');}catch(_){} - if(api.save_document()!==before) out.push('unicode refusal mutated'); - "#]).await, ""); -} diff --git a/tests/web_ghost_transaction_identity.rs b/tests/web_ghost_transaction_identity.rs new file mode 100644 index 0000000..d3fdb43 --- /dev/null +++ b/tests/web_ghost_transaction_identity.rs @@ -0,0 +1,56 @@ +//! Actual production JS and real WASM checkpoint closures. Compile-only here; +//! execution/Firefox/Chromium and human acceptance require the parent gate. +#![cfg(target_arch = "wasm32")] +mod support; +use support::*; +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn protected_done_is_one_persistent_undo_step_and_preserves_manual_ghosts() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(), trim=ed.lab.trim; + trim.select('sharper'); + const ranges=trim.active; + if (!ranges.length) out.push('missing cuts'); + const body=s.getText(); + // A disjoint manual ghost is not promoted or removed by Done/undo. + const at=0; // fixture heading marker, outside all protected cut ranges + api.ghost_range(at,at+1); + const manual=api.document_annotations().ghosts[0].id; + const history=s.history.length; + if(trim.done()!==true) return 'Done setup failed: '+trim.live.textContent; + if(s.getText()!==body || s.history.length!==history+1) out.push('body/history'); + if(ed.decorations.get('lab-trim').length || trim.plan) out.push('transients retained'); + if(!s.undo()) out.push('undo refused'); + if(JSON.stringify(api.document_annotations().ghosts.map(g=>g.id))!==JSON.stringify([manual])) out.push('manual lost'); + if(!s.redo()) out.push('redo refused'); + const saved=api.save_document(); + const ids=api.document_annotations().ghosts.map(g=>g.id); + ed.openDocument(saved,'ghost-reopen'); + if(JSON.stringify(api.document_annotations().ghosts.map(g=>g.id))!==JSON.stringify(ids)) out.push('persistence identities'); + "#]).await, ""); +} + +#[wasm_bindgen_test] +async fn capacities_and_partial_record_failure_restore_exact_native_receipt_state() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(),trim=ed.lab.trim; + trim.select('sharper');s.historyLimit=1; + const saved=api.save_document(), history=JSON.stringify(s.history), index=s.historyIndex; + if(trim.done()!==false || api.save_document()!==saved || JSON.stringify(s.history)!==history || s.historyIndex!==index) out.push('capacity mutated'); + if(!trim.plan || !ed.decorations.get('lab-trim').length) out.push('capacity retired plan'); + s.historyLimit=2; + const record=s.record;let recordEntered=false; + s.record=function(){recordEntered=true;this.history.length=0;this.historyIndex=-1;throw Error('injected record fault');}; + if(trim.done()!==false) out.push('record fault accepted'); + s.record=record; + if(!recordEntered) return 'record fault setup not reached: '+trim.live.textContent; + if(api.save_document()!==saved || JSON.stringify(s.history)!==history || s.historyIndex!==index) out.push('failed record changed checkpoints'); + if(trim.done()!==true || !s.canUndo()) return 'capacity2 setup failed: '+trim.live.textContent; + const ids=api.document_annotations().ghosts.map(g=>g.id); + if(ids[0]!=='g1') out.push('failed record retained allocation'); + if(!s.undo() || !s.redo()) out.push('capacity2 history'); + "#]).await, ""); +} diff --git a/tests/web_ghost_transaction_replay.rs b/tests/web_ghost_transaction_replay.rs new file mode 100644 index 0000000..1f97bf9 --- /dev/null +++ b/tests/web_ghost_transaction_replay.rs @@ -0,0 +1,50 @@ +//! Actual production JS and real WASM checkpoint closures. Compile-only here; +//! execution/Firefox/Chromium and human acceptance require the parent gate. +#![cfg(target_arch = "wasm32")] +mod support; +use support::*; +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn interleaved_native_write_refuses_abort_and_requires_explicit_canonical_reopen() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(),trim=ed.lab.trim;trim.select('sharper'); + const record=s.record;let recordEntered=false; + s.record=function(...args){recordEntered=true;record.apply(this,args);api.set_document_overflow('external notes');throw Error('interleaved');}; + if(trim.done()!==false || !ed.ghostHistoryConflict) out.push('missing conflict');s.record=record; + if(!recordEntered) return 'interleaved fault setup not reached: '+trim.live.textContent; + if(api.document_annotations().overflow!=='external notes') out.push('interleaved data overwritten'); + const canonical=api.save_document(); + ed.openDocument(canonical,'manual-recovery'); + if(ed.ghostHistoryConflict || s.canUndo() || s.canRedo()) out.push('canonical history not reset'); + if(api.document_annotations().overflow!=='external notes') out.push('recovery lost notes'); + "#]).await, ""); +} + +#[wasm_bindgen_test] +async fn all_six_real_operations_and_unicode_guard_use_actual_wasm_bridge() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(),trim=ed.lab.trim;trim.select('sharper'); + if(trim.done()!==true) return 'six-kind Done setup failed: '+trim.live.textContent; + if(!s.history[s.historyIndex]?.edits?.[0]?.ghost?.change) return 'six-kind setup missing actual ghost receipt'; + const body=s.getText(),receipt=s.history[s.historyIndex].edits[0].ghost.change; + const at=body.indexOf('Café'); + const changed=api.alt_create_span('word',at,at+4,'Coffee','human'); + const span=changed.after; + const empty={body,start:0,deletedText:'',insertedText:''}; + const operations=[{kind:'ghost',body,change:receipt,undo:true}, + {...empty,kind:'edit'}, {...empty,kind:'alt',span:span.id,target:span}, + {...empty,kind:'move',move:{start:0,end:body.length,to:body.length}}, + {...empty,kind:'swap',span:span.id,from:0,to:0}, + {...empty,kind:'overflow',before:'',after:''}]; + const begun=api.begin_document_history_replay(body,body,JSON.stringify(operations)); + const aborted=api.abort_document_history_transaction(begun.token); + if(aborted.ok!==true || aborted.rolledBack!==true || aborted.token!==begun.token) out.push('exact ack'); + ed.openDocument('a😀中b','unicode'); + const before=api.save_document(); + try {api.begin_document_history_replay('a😀中b','bad',JSON.stringify([{kind:'edit',body:'bad',start:2,deletedText:'',insertedText:'X'}]));out.push('half-surrogate accepted');}catch(_){} + if(api.save_document()!==before) out.push('unicode refusal mutated'); + "#]).await, ""); +} diff --git a/tests/web_ghost_transaction_rollback.rs b/tests/web_ghost_transaction_rollback.rs new file mode 100644 index 0000000..cfbfc5a --- /dev/null +++ b/tests/web_ghost_transaction_rollback.rs @@ -0,0 +1,40 @@ +//! Actual production JS and real WASM checkpoint closures. Compile-only here; +//! execution/Firefox/Chromium and human acceptance require the parent gate. +#![cfg(target_arch = "wasm32")] +mod support; +use support::*; +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn mixed_typing_and_ghost_group_uses_intermediate_body_and_atomic_last_refusal() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(),trim=ed.lab.trim; + trim.select('sharper');if(trim.done()!==true) return 'mixed Done setup failed: '+trim.live.textContent; + const body=s.getText();s.replaceRange(body.length,body.length,'X',{source:'typing'}); + if(!s.squashHistory(2)) out.push('squash'); + if(!s.undo() || s.getText()!==body || api.document_annotations().ghosts.length) out.push('mixed undo'); + if(!s.redo() || s.getText()!==body+'X') out.push('mixed redo'); + // Interleave real native manual metadata; last ghost replay must refuse + // even though its earlier text deletion would have been valid. + api.ghost_range(0,1); + const history=JSON.stringify(s.history),index=s.historyIndex,text=s.getText(),saved=api.save_document(); + if(s.undo()!==false) out.push('receipt conflict accepted'); + if(s.getText()!==text || api.save_document()!==saved || JSON.stringify(s.history)!==history || s.historyIndex!==index) out.push('partial mixed mutation'); + "#]).await, ""); +} + +#[wasm_bindgen_test] +async fn role_invalidation_during_record_aborts_document_but_never_resurrects_fades() { + support::trim::setup(); + assert_eq!(run_steps(&[r#" + const api=ed.documentApi(),trim=ed.lab.trim; + trim.select('sharper');const saved=api.save_document(),history=JSON.stringify(s.history); + const record=s.record;let recordEntered=false; + s.record=function(...args){recordEntered=true;record.apply(this,args);api.kg_clear_thesaurus();}; + if(trim.done()!==false) out.push('role invalidation accepted');s.record=record; + if(!recordEntered) return 'role fault setup not reached: '+trim.live.textContent; + if(api.save_document()!==saved || JSON.stringify(s.history)!==history) out.push('document rollback failed'); + if(trim.plan || ed.decorations.get('lab-trim').length || trim.kept.size) out.push('role fades resurrected'); + "#]).await, ""); +} diff --git a/tests/web_lab.rs b/tests/web_lab.rs index a2a5ad5..eb264a1 100644 --- a/tests/web_lab.rs +++ b/tests/web_lab.rs @@ -1,23 +1,14 @@ -//! Browser tests for the Lab popover and its marks (issue #14, spec R-8.1, -//! R-8.2). Run with `wasm-pack test --headless --chrome`. Real scripts, real -//! stylesheets, the real exported document and Lab API on -//! `window.wasmBindings` and the committed fixture -//! `tests/fixtures/lab/lab.md`; nothing is mocked. The expected marks are -//! computed in the same test by the `terraphim_lab` engine itself, so the -//! browser must show exactly what the engine returns. See `tests/web.rs` for -//! why the browser tests are split across binaries. +//! Mechanical partition of original real Lab tests; same fixtures and assertions. #![cfg(target_arch = "wasm32")] - mod support; - use support::*; -use terraphim_lab::{mark, LabAction, LabConfig}; - +#[path = "support/lab_partition.rs"] +mod lab_partition; +use lab_partition::*; wasm_bindgen_test_configure!(run_in_browser); -const FIXTURE: &str = include_str!("fixtures/lab/lab.md"); +use terraphim_lab::{mark, LabAction, LabConfig}; -/// `{ action_id: [[start, end, kind], ...] }` from the engine, as JSON. fn expected_marks() -> String { let config = LabConfig::with_defaults().expect("default Lab config"); let mut out = serde_json::Map::new(); @@ -32,34 +23,6 @@ fn expected_marks() -> String { serde_json::Value::Object(out).to_string() } -/// Fresh editor, fixture open, Write_On on, helpers on `window.teLab`. -fn setup() { - let _document = fresh_full_editor(); - let src = format!( - r##"(() => {{ - window.teLab = {{ - fixture: {fixture}, - editor() {{ return window.__teEditor; }}, - lab() {{ return window.__teEditor.lab; }}, - popover() {{ return window.__teEditor.lab.root; }}, - pill() {{ return teTest.control('lab'); }}, - item(id) {{ return teLab.popover().querySelector('[data-action="' + id + '"]'); }}, - marks() {{ return teLab.editor().decorations.get('lab-marks'); }}, - triples() {{ return teLab.marks().map((m) => [m.start, m.end, m.data.kind]); }}, - markEl(id) {{ - return teLab.editor().surface.root.querySelector('[data-te-decoration~="lab-marks:' + id + '"]'); - }}, - }}; - const ed = window.__teEditor; - ed.openDocument(teLab.fixture, 'lab-test-' + Math.random()); - ed.chrome.setMode('write-on'); - return ed.lab ? 'ok' : 'no lab'; - }})()"##, - fixture = js_string_literal(FIXTURE), - ); - assert_eq!(js_string(&src), "ok"); -} - #[wasm_bindgen_test] fn test_each_action_marks_exactly_the_engine_spans_and_clear_works() { setup(); @@ -198,134 +161,3 @@ fn test_accepting_a_proposal_is_one_undo_step_and_edits_drop_touched_marks() { ); assert_eq!(result, ""); } - -#[wasm_bindgen_test] -fn test_keyboard_navigation_mode_change_and_destroy_cleanup() { - setup(); - let result = js_string( - r##"(() => { - const out = []; - const ed = teLab.editor(); - const pill = teLab.pill(); - pill.focus(); - pill.click(); - const pop = teLab.popover(); - const items = Array.from(pop.querySelectorAll('[role="menuitemradio"]')); - const active = () => items.indexOf(document.activeElement); - if (pill.getAttribute('aria-expanded') !== 'true') out.push('aria-expanded not true'); - if (pill.getAttribute('aria-controls') !== pop.id) out.push('aria-controls'); - if (active() !== 0) out.push('first item not focused: ' + active()); - if (items.filter((b) => b.tabIndex === 0).length !== 1) out.push('roving tabindex'); - const key = (k) => teTest.key(document.activeElement, k); - key('ArrowDown'); - if (active() !== 1) out.push('ArrowDown ' + active()); - key('End'); - if (active() !== 5) out.push('End ' + active()); - key('ArrowDown'); - if (active() !== 0) out.push('wrap down ' + active()); - key('ArrowUp'); - if (active() !== 5) out.push('wrap up ' + active()); - key('Home'); - if (active() !== 0) out.push('Home ' + active()); - if (items[0].tabIndex !== 0 || items[5].tabIndex !== -1) out.push('tabindex not moved'); - if (!pop.querySelector('.te-lab-hint').textContent) out.push('no hint for the focused action'); - - // Escape closes and returns focus to the pill. - key('Escape'); - if (!pop.hidden) out.push('Escape did not close'); - if (document.activeElement !== pill) out.push('focus not returned to the pill'); - if (pill.getAttribute('aria-expanded') !== 'false') out.push('aria-expanded not false'); - - // Closed with marks: the chip says what is marked and clears. - pill.click(); - // The cheap actions (long sentences, off-tone) keep this test light; - // the first test covers every action against the engine. - items[2].focus(); - items[2].click(); - if (document.activeElement !== items[2]) out.push('running an action moved focus out of the popover'); - key('Escape'); - const chip = ed.lab.chip; - if (chip.hidden || !/2 long sentences/.test(chip.textContent)) out.push('chip ' + chip.hidden + ' ' + chip.textContent); - chip.querySelector('button').focus(); - chip.querySelector('button').click(); - if (teLab.marks().length || !chip.hidden) out.push('chip clear'); - if (document.activeElement !== pill) out.push('chip clear did not hand focus to the pill'); - - // A pointer press outside closes without moving focus. - pill.click(); - ed.surface.root.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); - if (!pop.hidden) out.push('outside press did not close'); - - // Leaving Write_On closes the popover and clears the marks. - pill.click(); - items[4].click(); - if (!teLab.marks().length) out.push('no off-tone marks'); - ed.chrome.setMode('plain'); - if (!pop.hidden) out.push('plain mode left the popover open'); - if (teLab.marks().length) out.push('plain mode left marks'); - ed.chrome.setMode('write-on'); - - // Destroy removes the DOM, the layer and every listener. - pill.click(); - items[2].click(); - const lab = ed.lab; - const surface = ed.surface; - ed.destroy(); - if (document.querySelector('.te-lab, .te-lab-chip, .te-lab-descriptions')) out.push('DOM left after destroy'); - if (surface.getDecorations().some((d) => d.id.startsWith('lab-marks:'))) out.push('marks left after destroy'); - if (!lab.destroyed) out.push('not destroyed'); - if (pill.hasAttribute('aria-expanded')) out.push('pill attributes left'); - document.dispatchEvent(new CustomEvent('te:lab', { bubbles: true, detail: { editor: ed } })); - if (lab.isOpen || lab.root.isConnected) out.push('reacted to te:lab after destroy'); - return out.join('\n'); - })()"##, - ); - assert_eq!(result, ""); -} - -/// R-8.1, R-8.3 (issue #107), as in the video at 4:56 to 5:30: the five trim -/// buttons sit in one row, each with its target under its name and nothing -/// clipped; the actions are text only; the popover shows no description -/// line and no "TRIM" label, but both remain the menu's description and the -/// radio group's name. -#[wasm_bindgen_test] -fn test_popover_trim_row_and_hidden_texts_match_the_video() { - setup(); - let result = js_string( - r##"(() => { - const out = []; - teLab.pill().click(); - const pop = teLab.popover(); - if (pop.hidden) return 'popover did not open'; - const buttons = Array.from(pop.querySelectorAll('.te-trim-level')); - if (buttons.length !== 5) return buttons.length + ' trim buttons'; - const rects = buttons.map((b) => b.getBoundingClientRect()); - if (new Set(rects.map((r) => Math.round(r.top))).size !== 1) out.push('trim buttons wrap: ' + JSON.stringify(rects.map((r) => r.top))); - if (new Set(rects.map((r) => Math.round(r.width))).size !== 1) out.push('trim buttons unequal: ' + JSON.stringify(rects.map((r) => r.width))); - for (const b of buttons) { - for (const part of b.children) { - if (part.scrollWidth > part.clientWidth + 0.5) out.push('clipped: ' + part.textContent); - } - const name = b.querySelector('.te-trim-level-name').getBoundingClientRect(); - const target = b.querySelector('.te-trim-level-target'); - if (target && !(target.getBoundingClientRect().top >= name.bottom - 1)) out.push('target not under the name: ' + b.textContent); - } - // The actions are text only, as in the video. - if (pop.querySelector('.te-lab-action i')) out.push('action icons shown'); - const pr = pop.getBoundingClientRect(); - if (rects[0].left < pr.left || rects[4].right > pr.right) out.push('row overflows the popover'); - // Hidden, not removed: the menu's description and the group's name. - const tiny = (el) => { const r = el.getBoundingClientRect(); return r.width <= 1 && r.height <= 1; }; - const hint = pop.querySelector('.te-lab-hint'), label = pop.querySelector('.te-trim-label'); - if (!tiny(hint)) out.push('description line visible'); - if (!tiny(label)) out.push('TRIM label visible'); - const menu = pop.querySelector('[role="menu"]'); - if (document.getElementById(menu.getAttribute('aria-describedby')) !== hint || !hint.textContent) out.push('menu description lost'); - const group = pop.querySelector('[role="radiogroup"]'); - if (document.getElementById(group.getAttribute('aria-labelledby')) !== label || label.textContent !== 'Trim') out.push('group name lost'); - teLab.pill().click(); - return out.join('; '); - })()"##, - ); - assert_eq!(result, ""); -} diff --git a/tests/web_lab_lifecycle.rs b/tests/web_lab_lifecycle.rs new file mode 100644 index 0000000..ff605aa --- /dev/null +++ b/tests/web_lab_lifecycle.rs @@ -0,0 +1,139 @@ +//! Mechanical partition of original real Lab tests; same fixtures and assertions. +#![cfg(target_arch = "wasm32")] +mod support; +use support::*; +#[path = "support/lab_partition.rs"] +mod lab_partition; +use lab_partition::*; +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +fn test_keyboard_navigation_mode_change_and_destroy_cleanup() { + setup(); + let result = js_string( + r##"(() => { + const out = []; + const ed = teLab.editor(); + const pill = teLab.pill(); + pill.focus(); + pill.click(); + const pop = teLab.popover(); + const items = Array.from(pop.querySelectorAll('[role="menuitemradio"]')); + const active = () => items.indexOf(document.activeElement); + if (pill.getAttribute('aria-expanded') !== 'true') out.push('aria-expanded not true'); + if (pill.getAttribute('aria-controls') !== pop.id) out.push('aria-controls'); + if (active() !== 0) out.push('first item not focused: ' + active()); + if (items.filter((b) => b.tabIndex === 0).length !== 1) out.push('roving tabindex'); + const key = (k) => teTest.key(document.activeElement, k); + key('ArrowDown'); + if (active() !== 1) out.push('ArrowDown ' + active()); + key('End'); + if (active() !== 5) out.push('End ' + active()); + key('ArrowDown'); + if (active() !== 0) out.push('wrap down ' + active()); + key('ArrowUp'); + if (active() !== 5) out.push('wrap up ' + active()); + key('Home'); + if (active() !== 0) out.push('Home ' + active()); + if (items[0].tabIndex !== 0 || items[5].tabIndex !== -1) out.push('tabindex not moved'); + if (!pop.querySelector('.te-lab-hint').textContent) out.push('no hint for the focused action'); + + // Escape closes and returns focus to the pill. + key('Escape'); + if (!pop.hidden) out.push('Escape did not close'); + if (document.activeElement !== pill) out.push('focus not returned to the pill'); + if (pill.getAttribute('aria-expanded') !== 'false') out.push('aria-expanded not false'); + + // Closed with marks: the chip says what is marked and clears. + pill.click(); + // The cheap actions (long sentences, off-tone) keep this test light; + // the first test covers every action against the engine. + items[2].focus(); + items[2].click(); + if (document.activeElement !== items[2]) out.push('running an action moved focus out of the popover'); + key('Escape'); + const chip = ed.lab.chip; + if (chip.hidden || !/2 long sentences/.test(chip.textContent)) out.push('chip ' + chip.hidden + ' ' + chip.textContent); + chip.querySelector('button').focus(); + chip.querySelector('button').click(); + if (teLab.marks().length || !chip.hidden) out.push('chip clear'); + if (document.activeElement !== pill) out.push('chip clear did not hand focus to the pill'); + + // A pointer press outside closes without moving focus. + pill.click(); + ed.surface.root.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + if (!pop.hidden) out.push('outside press did not close'); + + // Leaving Write_On closes the popover and clears the marks. + pill.click(); + items[4].click(); + if (!teLab.marks().length) out.push('no off-tone marks'); + ed.chrome.setMode('plain'); + if (!pop.hidden) out.push('plain mode left the popover open'); + if (teLab.marks().length) out.push('plain mode left marks'); + ed.chrome.setMode('write-on'); + + // Destroy removes the DOM, the layer and every listener. + pill.click(); + items[2].click(); + const lab = ed.lab; + const surface = ed.surface; + ed.destroy(); + if (document.querySelector('.te-lab, .te-lab-chip, .te-lab-descriptions')) out.push('DOM left after destroy'); + if (surface.getDecorations().some((d) => d.id.startsWith('lab-marks:'))) out.push('marks left after destroy'); + if (!lab.destroyed) out.push('not destroyed'); + if (pill.hasAttribute('aria-expanded')) out.push('pill attributes left'); + document.dispatchEvent(new CustomEvent('te:lab', { bubbles: true, detail: { editor: ed } })); + if (lab.isOpen || lab.root.isConnected) out.push('reacted to te:lab after destroy'); + return out.join('\n'); + })()"##, + ); + assert_eq!(result, ""); +} + +/// R-8.1, R-8.3 (issue #107), as in the video at 4:56 to 5:30: the five trim +/// buttons sit in one row, each with its target under its name and nothing +/// clipped; the actions are text only; the popover shows no description +/// line and no "TRIM" label, but both remain the menu's description and the +/// radio group's name. +#[wasm_bindgen_test] +fn test_popover_trim_row_and_hidden_texts_match_the_video() { + setup(); + let result = js_string( + r##"(() => { + const out = []; + teLab.pill().click(); + const pop = teLab.popover(); + if (pop.hidden) return 'popover did not open'; + const buttons = Array.from(pop.querySelectorAll('.te-trim-level')); + if (buttons.length !== 5) return buttons.length + ' trim buttons'; + const rects = buttons.map((b) => b.getBoundingClientRect()); + if (new Set(rects.map((r) => Math.round(r.top))).size !== 1) out.push('trim buttons wrap: ' + JSON.stringify(rects.map((r) => r.top))); + if (new Set(rects.map((r) => Math.round(r.width))).size !== 1) out.push('trim buttons unequal: ' + JSON.stringify(rects.map((r) => r.width))); + for (const b of buttons) { + for (const part of b.children) { + if (part.scrollWidth > part.clientWidth + 0.5) out.push('clipped: ' + part.textContent); + } + const name = b.querySelector('.te-trim-level-name').getBoundingClientRect(); + const target = b.querySelector('.te-trim-level-target'); + if (target && !(target.getBoundingClientRect().top >= name.bottom - 1)) out.push('target not under the name: ' + b.textContent); + } + // The actions are text only, as in the video. + if (pop.querySelector('.te-lab-action i')) out.push('action icons shown'); + const pr = pop.getBoundingClientRect(); + if (rects[0].left < pr.left || rects[4].right > pr.right) out.push('row overflows the popover'); + // Hidden, not removed: the menu's description and the group's name. + const tiny = (el) => { const r = el.getBoundingClientRect(); return r.width <= 1 && r.height <= 1; }; + const hint = pop.querySelector('.te-lab-hint'), label = pop.querySelector('.te-trim-label'); + if (!tiny(hint)) out.push('description line visible'); + if (!tiny(label)) out.push('TRIM label visible'); + const menu = pop.querySelector('[role="menu"]'); + if (document.getElementById(menu.getAttribute('aria-describedby')) !== hint || !hint.textContent) out.push('menu description lost'); + const group = pop.querySelector('[role="radiogroup"]'); + if (document.getElementById(group.getAttribute('aria-labelledby')) !== label || label.textContent !== 'Trim') out.push('group name lost'); + teLab.pill().click(); + return out.join('; '); + })()"##, + ); + assert_eq!(result, ""); +} diff --git a/tests/web_overflow.rs b/tests/web_overflow.rs index 5761b06..6240b35 100644 --- a/tests/web_overflow.rs +++ b/tests/web_overflow.rs @@ -11,10 +11,11 @@ mod support; use support::*; -use terraphim_alternatives::{write, Document, Source, SpanKind}; wasm_bindgen_test_configure!(run_in_browser); +use terraphim_alternatives::{write, Document, Source, SpanKind}; + /// "Pass me a paperclip. Drop this.\n\nSecond block.\n" with span `s1` over /// "paperclip" (alternative "eraser") and a ghost over " Drop this.". fn fixture() -> String { @@ -149,253 +150,3 @@ async fn test_stash_is_a_move_and_one_undo_step() { .await; assert!(r.is_empty(), "{r}"); } - -#[wasm_bindgen_test] -async fn test_ctrl_enter_and_drop_insert_at_the_caret() { - let _document = fresh_full_editor(); - install_overflow_helpers("Alpha beta gamma.\n"); - let r = run_steps(&[ - // The caret is placed as a click would (a DOM selection in the - // surface), then focus moves to the panel. - r#"ed.openDocument(window.__teSrc); - ed.chrome.setMode('write-on'); - s.setSelectionOffsets(0); - s.focus(); - const point = s.offsetToPoint(6); - getSelection().collapse(point.node, point.offset); - document.dispatchEvent(new Event('selectionchange')); - const ov = ed.overflow; - ov.open(); - teOv.type(ov.area, 'One\nTwo words'); - ov.flush(); - ov.area.setSelectionRange(4, 13); - T.depth = s.historyIndex; - if (!teTest.key(ov.area, 'Enter', { ctrlKey: true })) out.push('Ctrl+Enter not handled'); - if (s.getText() !== 'Alpha Two wordsbeta gamma.\n') out.push('text ' + JSON.stringify(s.getText())); - if (teOv.api().document_body() !== s.getText()) out.push('model body differs'); - if (s.historyIndex !== T.depth + 1) out.push('not one undo step'); - if (teOv.api().document_overflow() !== 'One\nTwo words') out.push('overflow changed: pull back is a copy'); - if (document.activeElement !== ov.area) out.push('focus left the panel'); - if (ov.area.selectionStart !== 4 || ov.area.selectionEnd !== 13) out.push('panel selection lost'); - s.undo(); - if (s.getText() !== 'Alpha beta gamma.\n') out.push('undo ' + JSON.stringify(s.getText()));"#, - // Nothing selected: the current line. At the start of a line it is a - // paragraph of its own, separated as terraphim-lsp separates it. - r#"const ov = ed.overflow; - s.setSelectionOffsets(0); - ov.area.focus(); - ov.area.setSelectionRange(1, 1); - ov.pullBack(); - if (s.getText() !== 'One\n\nAlpha beta gamma.\n') out.push('line ' + JSON.stringify(s.getText())); - if (teOv.api().document_overflow() !== 'One\nTwo words') out.push('overflow changed'); - s.undo(); - if (s.getText() !== 'Alpha beta gamma.\n') out.push('undo line ' + JSON.stringify(s.getText()));"#, - // At the end of a line the text no longer glues to the next line - // ("stuff.Pass me"): one blank line on each side, as in terraphim-lsp. - r#"const ov = ed.overflow; - ed.openDocument('Some stuff.\nPass me a paperclip.\n'); - ed.chrome.setMode('write-on'); - ov.open(); - teOv.type(ov.area, 'Para.\n\nOther.'); - ov.flush(); - s.setSelectionOffsets(11); - ov.area.focus(); - ov.area.setSelectionRange(0, 5); - T.depth = s.historyIndex; - ov.pullBack(); - if (s.getText() !== 'Some stuff.\n\nPara.\n\nPass me a paperclip.\n') out.push('line end ' + JSON.stringify(s.getText())); - if (teOv.api().document_body() !== s.getText()) out.push('model body differs'); - if (s.historyIndex !== T.depth + 1) out.push('not one undo step'); - if (teOv.api().document_overflow() !== 'Para.\n\nOther.') out.push('overflow changed'); - s.undo(); - if (s.getText() !== 'Some stuff.\nPass me a paperclip.\n') out.push('undo ' + JSON.stringify(s.getText()));"#, - // The surface keeps "\n" line ends (it normalises a CRLF file on - // open), so the separators it gets are "\n" too. - r#"const ov = ed.overflow; - ed.openDocument('A.\r\nB.\r\n'); - ed.chrome.setMode('write-on'); - ov.open(); - teOv.type(ov.area, 'Para.\n\nOther.'); - ov.flush(); - s.setSelectionOffsets(2); - ov.area.focus(); - ov.area.setSelectionRange(0, 5); - ov.pullBack(); - if (s.getText() !== 'A.\n\nPara.\n\nB.\n') out.push('crlf file ' + JSON.stringify(s.getText())); - if (teOv.api().document_body() !== s.getText()) out.push('model body differs'); - ed.openDocument('Some stuff.\nPass me a paperclip.\n'); - ed.chrome.setMode('write-on'); - ov.open(); - teOv.type(ov.area, 'Para.\n\nOther.'); - ov.flush();"#, - // Selected panel text dragged into the page is pulled back the same - // way at the drop point, a copy. The drop lands on the surface root, - // where EditorSurface's own drop listener sits: the text goes in once. - r#"const ov = ed.overflow; - ov.area.setSelectionRange(7, 13); - const dt = new DataTransfer(); - dt.setData('text/plain', 'Other.'); - const start = new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dt }); - ov.area.dispatchEvent(start); - if (start.defaultPrevented) out.push('panel drag cancelled'); - const rect = s.getCaretRect(17); - T.depth = s.historyIndex; - s.root.dispatchEvent(new DragEvent('drop', { - bubbles: true, cancelable: true, dataTransfer: dt, - clientX: rect.left + 0.5, clientY: rect.top + rect.height / 2, - })); - if (s.getText() !== 'Some stuff.\nPass Other.me a paperclip.\n') out.push('panel drop ' + JSON.stringify(s.getText())); - if (s.historyIndex !== T.depth + 1) out.push('panel drop not one undo step'); - if (teOv.api().document_overflow() !== 'Para.\n\nOther.') out.push('panel drop changed the overflow'); - s.undo(); - ed.openDocument(window.__teSrc); - ed.chrome.setMode('write-on'); - ov.open(); - teOv.type(ov.area, 'One\nTwo words'); - ov.flush();"#, - // Text dropped from elsewhere is inserted as it is at the drop point. - // (A script-made DataTransfer is read-only for effectAllowed outside - // a trusted drag, so the copy-only effect cannot be asserted here; - // the dragstart must at least not be cancelled.) - r#"const start = new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: new DataTransfer() }); - ed.overflow.area.dispatchEvent(start); - if (start.defaultPrevented) out.push('panel drag cancelled'); - const drop = new DataTransfer(); - drop.setData('text/plain', 'DROP '); - const rect = s.getCaretRect(11); - T.depth = s.historyIndex; - s.root.dispatchEvent(new DragEvent('drop', { - bubbles: true, cancelable: true, dataTransfer: drop, - clientX: rect.left + 0.5, clientY: rect.top + rect.height / 2, - })); - if (s.getText() !== 'Alpha beta DROP gamma.\n') out.push('drop ' + JSON.stringify(s.getText())); - if (s.historyIndex !== T.depth + 1) out.push('drop not one undo step'); - if (teOv.api().document_body() !== s.getText()) out.push('model body differs');"#, - // A page selection dragged onto the panel is stashed. - r#"s.focus(); - s.setSelectionOffsets(11, 16); - s.root.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: new DataTransfer() })); - const dt = new DataTransfer(); - dt.setData('text/plain', 'DROP '); - const over = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }); - ed.overflow.area.dispatchEvent(over); - if (!over.defaultPrevented) out.push('dragover not accepted'); - ed.overflow.area.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt })); - if (s.getText() !== 'Alpha beta gamma.\n') out.push('page drag ' + JSON.stringify(s.getText())); - if (teOv.api().document_overflow() !== 'One\nTwo words\n\nDROP ') out.push('overflow ' + JSON.stringify(teOv.api().document_overflow()));"#, - ]) - .await; - assert!(r.is_empty(), "{r}"); -} - -#[wasm_bindgen_test] -async fn test_panel_text_persists_is_not_exported_and_destroy_cleans_up() { - let _document = fresh_full_editor(); - install_overflow_helpers("Body text.\n"); - let r = run_steps(&[ - r#"ed.openDocument(window.__teSrc); - ed.chrome.setMode('write-on'); - ed.overflow.open(); - teOv.type(ed.overflow.area, 'Stashed idea.'); - if (!ed.overflow.pending()) out.push('input not debounced'); - T.saved = ed.saveDocument(); - if (ed.overflow.pending()) out.push('save did not flush'); - if (!T.saved.includes('"overflow": "Stashed idea."')) out.push('saved ' + JSON.stringify(T.saved)); - const exported = ed.exportDocument(); - if (exported.includes('Stashed idea.') || exported !== 'Body text.\n') out.push('export ' + JSON.stringify(exported));"#, - r#"ed.openDocument('Another file.\n'); - if (ed.overflow.area.value !== '') out.push('stale panel after open'); - ed.chrome.setMode('plain'); - ed.openDocument(T.saved); - if (teOv.api().document_overflow() !== 'Stashed idea.') out.push('reopened ' + teOv.api().document_overflow()); - if (ed.saveDocument() !== T.saved) out.push('plain mode save dropped the overflow'); - ed.chrome.setMode('write-on'); - ed.overflow.open(); - if (ed.overflow.area.value !== 'Stashed idea.') out.push('panel ' + ed.overflow.area.value);"#, - // A preserved malformed block: read-only panel, no stash. - r#"ed.openDocument('Body.\n\n```terraphim-alternatives\n{ "version": 1, "spans": [,] }\n```\n'); - const ov = ed.overflow; - if (!ov.area.readOnly || ov.note.hidden) out.push('not read-only'); - s.focus(); - s.setSelectionOffsets(0, 4); - if (teTest.key(s.root, 'X', { ctrlKey: true, shiftKey: true, code: 'KeyX' })) out.push('stashed into an unsavable overflow'); - if (s.getText() !== 'Body.') out.push('text ' + JSON.stringify(s.getText()));"#, - r#"ed.openDocument('Fine.\n'); - if (ed.overflow.area.readOnly) out.push('still read-only'); - ed.overflow.open(); - ed.destroy(); - if (document.querySelector('.te-overflow')) out.push('panel left in the DOM'); - if (document.body.dataset.teOverflow) out.push('body attribute left'); - document.dispatchEvent(new CustomEvent('te:overflow', { detail: {} })); - if (!ed.overflow.destroyed) out.push('not destroyed');"#, - ]) - .await; - assert!(r.is_empty(), "{r}"); -} - -/// R-6.1: the panel is the same 18% of the window as the alternatives panel, -/// as measured in the demo video, so within 2% of 205px at a 1140px window, -/// clamped to 12rem-20rem; the panel the editor opens takes the width the -/// same stylesheet gives at the test window's width, and its title and -/// buttons fit on one line. -#[wasm_bindgen_test] -async fn test_panel_width_matches_the_video() { - let markup = r#""#; - let at_1140 = width_in_viewport(1140.0, markup, ".te-overflow").await; - assert_video_panel_width("the Overflow panel", at_1140, 1140.0); - assert_panel_clamp(markup, ".te-overflow").await; - - let _document = fresh_full_editor(); - install_overflow_helpers("Hello world.\n"); - let r = run_steps(&[r#"ed.openDocument(window.__teSrc); - ed.chrome.setMode('write-on'); - teTest.control('overflow').click(); - const ov = ed.overflow; - if (!ov.isOpen()) return 'XYZ did not open the panel'; - window.__teOvWidth = ov.root.getBoundingClientRect().width; - const header = ov.root.querySelector('.te-overflow-header'); - if (header.scrollWidth > header.clientWidth) out.push('header overflows: ' + header.scrollWidth + ' > ' + header.clientWidth); - const title = ov.root.querySelector('.te-overflow-title').getBoundingClientRect(); - const close = ov.root.querySelector('.te-overflow-close').getBoundingClientRect(); - if (close.top >= title.bottom) out.push('buttons wrapped below the title'); - ov.close();"#]) - .await; - assert!(r.is_empty(), "{r}"); - let real = js_number("window.__teOvWidth"); - let window_width = js_number("window.innerWidth"); - let expected = width_in_viewport(window_width, markup, ".te-overflow").await; - assert!( - (real - expected).abs() < 0.5, - "opened panel is {real}px; the stylesheet gives {expected}px at {window_width}px" - ); -} - -/// A native selected-text drag needs an accepted drag-over before Chrome emits drop. -#[wasm_bindgen_test] -async fn test_panel_drag_accepts_only_its_owned_plain_text_dragover() { - let _document = fresh_full_editor(); - install_overflow_helpers("Public body."); - let r = run_steps(&[ - r#"ed.openDocument(window.__teSrc); - ed.chrome.setMode('write-on'); - const ov=ed.overflow; - ov.open(); - teOv.type(ov.area,'Public note.'); - ov.area.setSelectionRange(0,12); - const transfer=new DataTransfer(); - transfer.setData('text/plain','Public note.'); - ov.area.dispatchEvent(new DragEvent('dragstart',{bubbles:true,dataTransfer:transfer})); - const over=new DragEvent('dragover',{bubbles:true,cancelable:true,dataTransfer:transfer}); - s.root.dispatchEvent(over); - if (!over.defaultPrevented) out.push('owned plain text drag was not accepted'); - // This constructed event tests acceptance gating; the native driver - // separately proves a trusted copy and exact unchanged Overflow. - ov.area.dispatchEvent(new DragEvent('dragend',{bubbles:true,dataTransfer:transfer})); - const external=new DragEvent('dragover',{bubbles:true,cancelable:true,dataTransfer:transfer}); - s.root.dispatchEvent(external); - if (external.defaultPrevented) out.push('unowned drag was captured'); - if (s.getText()!=='Public body.') out.push('dragover rewrote body'); - if (ov.text()!=='Public note.') out.push('dragover rewrote Overflow');"#, - ]).await; - assert!(r.is_empty(), "{r}"); -} diff --git a/tests/web_overflow_insertion.rs b/tests/web_overflow_insertion.rs new file mode 100644 index 0000000..ffc9179 --- /dev/null +++ b/tests/web_overflow_insertion.rs @@ -0,0 +1,198 @@ +//! Browser tests for the Overflow panel (issue #12, spec R-6.1 to R-6.5): +//! the XYZ toggle, ARIA and Escape, stashing as a move and one undo step +//! (menu and Ctrl+Shift+X), pulling back with Ctrl+Enter and by drag and +//! drop, persistence through save and open, export, the preserved-block +//! refusal and destroy(); Ctrl+Shift+D and the full-height placement are in +//! `web_overflow_layout.rs`. Run with `wasm-pack test --headless --chrome`. +//! Real scripts and the real document API; nothing is mocked. Each test is a +//! short list of `run_steps` steps with one or two model calls each. +#![cfg(target_arch = "wasm32")] + +mod support; + +use support::*; + +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn test_ctrl_enter_and_drop_insert_at_the_caret() { + let _document = fresh_full_editor(); + install_overflow_helpers("Alpha beta gamma.\n"); + let r = run_steps(&[ + // The caret is placed as a click would (a DOM selection in the + // surface), then focus moves to the panel. + r#"ed.openDocument(window.__teSrc); + ed.chrome.setMode('write-on'); + s.setSelectionOffsets(0); + s.focus(); + const point = s.offsetToPoint(6); + getSelection().collapse(point.node, point.offset); + document.dispatchEvent(new Event('selectionchange')); + const ov = ed.overflow; + ov.open(); + teOv.type(ov.area, 'One\nTwo words'); + ov.flush(); + ov.area.setSelectionRange(4, 13); + T.depth = s.historyIndex; + if (!teTest.key(ov.area, 'Enter', { ctrlKey: true })) out.push('Ctrl+Enter not handled'); + if (s.getText() !== 'Alpha Two wordsbeta gamma.\n') out.push('text ' + JSON.stringify(s.getText())); + if (teOv.api().document_body() !== s.getText()) out.push('model body differs'); + if (s.historyIndex !== T.depth + 1) out.push('not one undo step'); + if (teOv.api().document_overflow() !== 'One\nTwo words') out.push('overflow changed: pull back is a copy'); + if (document.activeElement !== ov.area) out.push('focus left the panel'); + if (ov.area.selectionStart !== 4 || ov.area.selectionEnd !== 13) out.push('panel selection lost'); + s.undo(); + if (s.getText() !== 'Alpha beta gamma.\n') out.push('undo ' + JSON.stringify(s.getText()));"#, + // Nothing selected: the current line. At the start of a line it is a + // paragraph of its own, separated as terraphim-lsp separates it. + r#"const ov = ed.overflow; + s.setSelectionOffsets(0); + ov.area.focus(); + ov.area.setSelectionRange(1, 1); + ov.pullBack(); + if (s.getText() !== 'One\n\nAlpha beta gamma.\n') out.push('line ' + JSON.stringify(s.getText())); + if (teOv.api().document_overflow() !== 'One\nTwo words') out.push('overflow changed'); + s.undo(); + if (s.getText() !== 'Alpha beta gamma.\n') out.push('undo line ' + JSON.stringify(s.getText()));"#, + // At the end of a line the text no longer glues to the next line + // ("stuff.Pass me"): one blank line on each side, as in terraphim-lsp. + r#"const ov = ed.overflow; + ed.openDocument('Some stuff.\nPass me a paperclip.\n'); + ed.chrome.setMode('write-on'); + ov.open(); + teOv.type(ov.area, 'Para.\n\nOther.'); + ov.flush(); + s.setSelectionOffsets(11); + ov.area.focus(); + ov.area.setSelectionRange(0, 5); + T.depth = s.historyIndex; + ov.pullBack(); + if (s.getText() !== 'Some stuff.\n\nPara.\n\nPass me a paperclip.\n') out.push('line end ' + JSON.stringify(s.getText())); + if (teOv.api().document_body() !== s.getText()) out.push('model body differs'); + if (s.historyIndex !== T.depth + 1) out.push('not one undo step'); + if (teOv.api().document_overflow() !== 'Para.\n\nOther.') out.push('overflow changed'); + s.undo(); + if (s.getText() !== 'Some stuff.\nPass me a paperclip.\n') out.push('undo ' + JSON.stringify(s.getText()));"#, + // The surface keeps "\n" line ends (it normalises a CRLF file on + // open), so the separators it gets are "\n" too. + r#"const ov = ed.overflow; + ed.openDocument('A.\r\nB.\r\n'); + ed.chrome.setMode('write-on'); + ov.open(); + teOv.type(ov.area, 'Para.\n\nOther.'); + ov.flush(); + s.setSelectionOffsets(2); + ov.area.focus(); + ov.area.setSelectionRange(0, 5); + ov.pullBack(); + if (s.getText() !== 'A.\n\nPara.\n\nB.\n') out.push('crlf file ' + JSON.stringify(s.getText())); + if (teOv.api().document_body() !== s.getText()) out.push('model body differs'); + ed.openDocument('Some stuff.\nPass me a paperclip.\n'); + ed.chrome.setMode('write-on'); + ov.open(); + teOv.type(ov.area, 'Para.\n\nOther.'); + ov.flush();"#, + // Selected panel text dragged into the page is pulled back the same + // way at the drop point, a copy. The drop lands on the surface root, + // where EditorSurface's own drop listener sits: the text goes in once. + r#"const ov = ed.overflow; + ov.area.setSelectionRange(7, 13); + const dt = new DataTransfer(); + dt.setData('text/plain', 'Other.'); + const start = new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dt }); + ov.area.dispatchEvent(start); + if (start.defaultPrevented) out.push('panel drag cancelled'); + const rect = s.getCaretRect(17); + T.depth = s.historyIndex; + s.root.dispatchEvent(new DragEvent('drop', { + bubbles: true, cancelable: true, dataTransfer: dt, + clientX: rect.left + 0.5, clientY: rect.top + rect.height / 2, + })); + if (s.getText() !== 'Some stuff.\nPass Other.me a paperclip.\n') out.push('panel drop ' + JSON.stringify(s.getText())); + if (s.historyIndex !== T.depth + 1) out.push('panel drop not one undo step'); + if (teOv.api().document_overflow() !== 'Para.\n\nOther.') out.push('panel drop changed the overflow'); + s.undo(); + ed.openDocument(window.__teSrc); + ed.chrome.setMode('write-on'); + ov.open(); + teOv.type(ov.area, 'One\nTwo words'); + ov.flush();"#, + // Text dropped from elsewhere is inserted as it is at the drop point. + // (A script-made DataTransfer is read-only for effectAllowed outside + // a trusted drag, so the copy-only effect cannot be asserted here; + // the dragstart must at least not be cancelled.) + r#"const start = new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: new DataTransfer() }); + ed.overflow.area.dispatchEvent(start); + if (start.defaultPrevented) out.push('panel drag cancelled'); + const drop = new DataTransfer(); + drop.setData('text/plain', 'DROP '); + const rect = s.getCaretRect(11); + T.depth = s.historyIndex; + s.root.dispatchEvent(new DragEvent('drop', { + bubbles: true, cancelable: true, dataTransfer: drop, + clientX: rect.left + 0.5, clientY: rect.top + rect.height / 2, + })); + if (s.getText() !== 'Alpha beta DROP gamma.\n') out.push('drop ' + JSON.stringify(s.getText())); + if (s.historyIndex !== T.depth + 1) out.push('drop not one undo step'); + if (teOv.api().document_body() !== s.getText()) out.push('model body differs');"#, + // A page selection dragged onto the panel is stashed. + r#"s.focus(); + s.setSelectionOffsets(11, 16); + s.root.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: new DataTransfer() })); + const dt = new DataTransfer(); + dt.setData('text/plain', 'DROP '); + const over = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }); + ed.overflow.area.dispatchEvent(over); + if (!over.defaultPrevented) out.push('dragover not accepted'); + ed.overflow.area.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt })); + if (s.getText() !== 'Alpha beta gamma.\n') out.push('page drag ' + JSON.stringify(s.getText())); + if (teOv.api().document_overflow() !== 'One\nTwo words\n\nDROP ') out.push('overflow ' + JSON.stringify(teOv.api().document_overflow()));"#, + ]) + .await; + assert!(r.is_empty(), "{r}"); +} + +#[wasm_bindgen_test] +async fn test_panel_text_persists_is_not_exported_and_destroy_cleans_up() { + let _document = fresh_full_editor(); + install_overflow_helpers("Body text.\n"); + let r = run_steps(&[ + r#"ed.openDocument(window.__teSrc); + ed.chrome.setMode('write-on'); + ed.overflow.open(); + teOv.type(ed.overflow.area, 'Stashed idea.'); + if (!ed.overflow.pending()) out.push('input not debounced'); + T.saved = ed.saveDocument(); + if (ed.overflow.pending()) out.push('save did not flush'); + if (!T.saved.includes('"overflow": "Stashed idea."')) out.push('saved ' + JSON.stringify(T.saved)); + const exported = ed.exportDocument(); + if (exported.includes('Stashed idea.') || exported !== 'Body text.\n') out.push('export ' + JSON.stringify(exported));"#, + r#"ed.openDocument('Another file.\n'); + if (ed.overflow.area.value !== '') out.push('stale panel after open'); + ed.chrome.setMode('plain'); + ed.openDocument(T.saved); + if (teOv.api().document_overflow() !== 'Stashed idea.') out.push('reopened ' + teOv.api().document_overflow()); + if (ed.saveDocument() !== T.saved) out.push('plain mode save dropped the overflow'); + ed.chrome.setMode('write-on'); + ed.overflow.open(); + if (ed.overflow.area.value !== 'Stashed idea.') out.push('panel ' + ed.overflow.area.value);"#, + // A preserved malformed block: read-only panel, no stash. + r#"ed.openDocument('Body.\n\n```terraphim-alternatives\n{ "version": 1, "spans": [,] }\n```\n'); + const ov = ed.overflow; + if (!ov.area.readOnly || ov.note.hidden) out.push('not read-only'); + s.focus(); + s.setSelectionOffsets(0, 4); + if (teTest.key(s.root, 'X', { ctrlKey: true, shiftKey: true, code: 'KeyX' })) out.push('stashed into an unsavable overflow'); + if (s.getText() !== 'Body.') out.push('text ' + JSON.stringify(s.getText()));"#, + r#"ed.openDocument('Fine.\n'); + if (ed.overflow.area.readOnly) out.push('still read-only'); + ed.overflow.open(); + ed.destroy(); + if (document.querySelector('.te-overflow')) out.push('panel left in the DOM'); + if (document.body.dataset.teOverflow) out.push('body attribute left'); + document.dispatchEvent(new CustomEvent('te:overflow', { detail: {} })); + if (!ed.overflow.destroyed) out.push('not destroyed');"#, + ]) + .await; + assert!(r.is_empty(), "{r}"); +} diff --git a/tests/web_overflow_panel.rs b/tests/web_overflow_panel.rs new file mode 100644 index 0000000..c9296e9 --- /dev/null +++ b/tests/web_overflow_panel.rs @@ -0,0 +1,76 @@ +//! Browser tests for the Overflow panel (issue #12, spec R-6.1 to R-6.5): +//! the XYZ toggle, ARIA and Escape, stashing as a move and one undo step +//! (menu and Ctrl+Shift+X), pulling back with Ctrl+Enter and by drag and +//! drop, persistence through save and open, export, the preserved-block +//! refusal and destroy(); Ctrl+Shift+D and the full-height placement are in +//! `web_overflow_layout.rs`. Run with `wasm-pack test --headless --chrome`. +//! Real scripts and the real document API; nothing is mocked. Each test is a +//! short list of `run_steps` steps with one or two model calls each. +#![cfg(target_arch = "wasm32")] + +mod support; + +use support::*; + +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn test_panel_width_matches_the_video() { + let markup = r#""#; + let at_1140 = width_in_viewport(1140.0, markup, ".te-overflow").await; + assert_video_panel_width("the Overflow panel", at_1140, 1140.0); + assert_panel_clamp(markup, ".te-overflow").await; + + let _document = fresh_full_editor(); + install_overflow_helpers("Hello world.\n"); + let r = run_steps(&[r#"ed.openDocument(window.__teSrc); + ed.chrome.setMode('write-on'); + teTest.control('overflow').click(); + const ov = ed.overflow; + if (!ov.isOpen()) return 'XYZ did not open the panel'; + window.__teOvWidth = ov.root.getBoundingClientRect().width; + const header = ov.root.querySelector('.te-overflow-header'); + if (header.scrollWidth > header.clientWidth) out.push('header overflows: ' + header.scrollWidth + ' > ' + header.clientWidth); + const title = ov.root.querySelector('.te-overflow-title').getBoundingClientRect(); + const close = ov.root.querySelector('.te-overflow-close').getBoundingClientRect(); + if (close.top >= title.bottom) out.push('buttons wrapped below the title'); + ov.close();"#]) + .await; + assert!(r.is_empty(), "{r}"); + let real = js_number("window.__teOvWidth"); + let window_width = js_number("window.innerWidth"); + let expected = width_in_viewport(window_width, markup, ".te-overflow").await; + assert!( + (real - expected).abs() < 0.5, + "opened panel is {real}px; the stylesheet gives {expected}px at {window_width}px" + ); +} + +#[wasm_bindgen_test] +async fn test_panel_drag_accepts_only_its_owned_plain_text_dragover() { + let _document = fresh_full_editor(); + install_overflow_helpers("Public body."); + let r = run_steps(&[ + r#"ed.openDocument(window.__teSrc); + ed.chrome.setMode('write-on'); + const ov=ed.overflow; + ov.open(); + teOv.type(ov.area,'Public note.'); + ov.area.setSelectionRange(0,12); + const transfer=new DataTransfer(); + transfer.setData('text/plain','Public note.'); + ov.area.dispatchEvent(new DragEvent('dragstart',{bubbles:true,dataTransfer:transfer})); + const over=new DragEvent('dragover',{bubbles:true,cancelable:true,dataTransfer:transfer}); + s.root.dispatchEvent(over); + if (!over.defaultPrevented) out.push('owned plain text drag was not accepted'); + // This constructed event tests acceptance gating; the native driver + // separately proves a trusted copy and exact unchanged Overflow. + ov.area.dispatchEvent(new DragEvent('dragend',{bubbles:true,dataTransfer:transfer})); + const external=new DragEvent('dragover',{bubbles:true,cancelable:true,dataTransfer:transfer}); + s.root.dispatchEvent(external); + if (external.defaultPrevented) out.push('unowned drag was captured'); + if (s.getText()!=='Public body.') out.push('dragover rewrote body'); + if (ov.text()!=='Public note.') out.push('dragover rewrote Overflow');"#, + ]).await; + assert!(r.is_empty(), "{r}"); +} From cd1983887303c58904b95612c056c74b0f5738ab Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sat, 17 Oct 2026 21:52:14 +0100 Subject: [PATCH 03/16] test: resolve explicit fixture types in wasm batches --- tests/support/cycling.rs | 2 +- tests/web_cycling.rs | 2 +- tests/web_cycling_history.rs | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/support/cycling.rs b/tests/support/cycling.rs index 091ddb0..d66bc9c 100644 --- a/tests/support/cycling.rs +++ b/tests/support/cycling.rs @@ -14,7 +14,7 @@ use terraphim_alternatives::{write, Document, Source, SpanKind}; /// "Pass me a paperclip. Drop this.\n\nSecond line.\n" with span `s1` over /// "paperclip" (alternatives "eraser" and "thumbtack") and ghost `g1` over /// " Drop this.", built through the crate so the fixture is always valid. -fn fixture() -> String { +pub(super) fn fixture() -> String { let mut doc = Document::new("Pass me a paperclip. Drop this.\n\nSecond line.\n"); let id = doc.add_span(SpanKind::Word, 10, 19).unwrap(); doc.add_alternative(&id, "eraser", Source::Human, None) diff --git a/tests/web_cycling.rs b/tests/web_cycling.rs index 9685638..3dea199 100644 --- a/tests/web_cycling.rs +++ b/tests/web_cycling.rs @@ -4,7 +4,7 @@ mod support; use support::*; #[path = "support/cycling.rs"] mod cycling; -use cycling::*; +use cycling::{fixture, run, ERASER, PAPERCLIP, THUMBTACK}; wasm_bindgen_test_configure!(run_in_browser); #[wasm_bindgen_test] diff --git a/tests/web_cycling_history.rs b/tests/web_cycling_history.rs index 7d53ae3..df5f386 100644 --- a/tests/web_cycling_history.rs +++ b/tests/web_cycling_history.rs @@ -2,9 +2,10 @@ #![cfg(target_arch = "wasm32")] mod support; use support::*; +use terraphim_alternatives::{write, Document, Source, SpanKind}; #[path = "support/cycling.rs"] mod cycling; -use cycling::*; +use cycling::{run, ERASER, PAPERCLIP, THUMBTACK}; wasm_bindgen_test_configure!(run_in_browser); /// "Give me a eraser." (an article typed against the a/an rule) with span From 016468ea0af0f011248f44d4ff32e2c8c01237e2 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sat, 17 Oct 2026 22:08:27 +0100 Subject: [PATCH 04/16] fix: preserve protected Trim promotion and actual recovery regression --- crates/terraphim_lab/src/trim.rs | 181 ++++++++++++++++++ .../tests/fixtures/public-project-brief.md | 1 + ...-authority-controller.integration.test.cjs | 27 +++ 3 files changed, 209 insertions(+) create mode 100644 crates/terraphim_lab/tests/fixtures/public-project-brief.md diff --git a/crates/terraphim_lab/src/trim.rs b/crates/terraphim_lab/src/trim.rs index 8cfbe2b..8f0780c 100644 --- a/crates/terraphim_lab/src/trim.rs +++ b/crates/terraphim_lab/src/trim.rs @@ -217,8 +217,39 @@ impl TrimPlan { /// faded sentence deletes the rest of the sentence and keeps the word; /// [`make_cuts`](crate::make_cuts) tidies the joins around it. /// + /// A separator shared by crossing sentence cuts belongs to the earlier + /// active fragment, only after Keep subtraction; exact cut coverage remains + /// unchanged. Crossings containing non-whitespace are never normalized. + /// /// Unknown ids in `kept` are ignored. pub fn active(&self, level: TrimLevel, kept: &[CutId]) -> Vec { + let mut active = self.active_fragments(level, kept); + // Adjacent sentence candidates can both own the separator: an opener + // takes its trailing gap while its successor takes that leading gap. + // Assign only that already-cut whitespace to the earlier fragment. + // Keep subtraction runs first, so every kept original byte still wins. + for index in 0..active.len() { + for prior in 0..index { + let before = &active[prior]; + let current = &active[index]; + if before.start < current.start + && current.start < before.end + && before.end < current.end + && self.is_blank(current.start, before.end) + { + let start = before.end; + active[index].start = start; + active[index].words = self.words_in(start, active[index].end); + } + } + } + active.sort_by_key(|cut| (cut.start, cut.end)); + active + } + + // The original Keep subtraction and fragment identities, before assigning + // duplicate whitespace. Retained as the common real implementation seam. + fn active_fragments(&self, level: TrimLevel, kept: &[CutId]) -> Vec { let mut kept_ranges: Vec<(usize, usize)> = kept .iter() .filter_map(|&id| self.get(id)) @@ -838,3 +869,153 @@ fn sentence_candidates( }); } } + +#[cfg(test)] +mod whitespace_ownership_tests { + use super::*; + + const BODY: &str = include_str!("../tests/fixtures/public-project-brief.md"); + + fn crossing(a: &Cut, b: &Cut) -> bool { + a.start < b.end + && b.start < a.end + && !((a.start <= b.start && b.end <= a.end) || (b.start <= a.start && a.end <= b.end)) + } + + #[test] + fn actual_public_brief_active_fragments_have_single_separator_owner() { + let at = BODY.find("decision").unwrap(); + let plan = trim_plan_excluding( + BODY, + &LabConfig::with_defaults().unwrap(), + &[terraphim_alternatives::text::ByteRange { + start: at, + end: at + "decision".len(), + }], + ); + assert_eq!(plan.total_words(), 111); + let active = plan.active(TrimLevel::Half, &[]); + for (index, cut) in active.iter().enumerate() { + for prior in &active[..index] { + assert!( + !crossing(prior, cut), + "published crossing: {prior:?} / {cut:?}" + ); + } + } + } + + fn coverage(plan: &TrimPlan, cuts: &[Cut]) -> Vec { + let mut covered = vec![false; plan.units.len()]; + for cut in cuts { + covered[cut.start..cut.end].fill(true); + } + covered + } + + fn assert_preserved(body: &str, plan: &TrimPlan, level: TrimLevel, kept: &[CutId]) { + let raw = plan.active_fragments(level, kept); + let owned = plan.active(level, kept); + assert_eq!(coverage(plan, &raw), coverage(plan, &owned)); + assert_eq!(crate::make_cuts(body, &raw), crate::make_cuts(body, &owned)); + for cut in &owned { + let original = raw + .iter() + .find(|r| r.id == cut.id && r.end == cut.end) + .unwrap(); + assert_eq!(cut.words, original.words); + assert_eq!(cut.reason, original.reason); + assert_eq!(cut.score, original.score); + assert_eq!(cut.first_level, original.first_level); + assert_eq!(cut.tier, original.tier); + assert!(cut.start >= original.start); + assert!(plan.is_blank(original.start, cut.start)); + for id in kept { + let kept = plan.get(*id).unwrap(); + assert!(cut.end <= kept.start || kept.end <= cut.start); + } + assert!(char::decode_utf16(plan.units[..cut.start].iter().copied()).all(|c| c.is_ok())); + assert!(char::decode_utf16(plan.units[..cut.end].iter().copied()).all(|c| c.is_ok())); + } + } + + #[test] + fn all_public_brief_levels_and_4096_keep_masks_preserve_exact_cuts_and_kept_bytes() { + let at = BODY.find("decision").unwrap(); + let plan = trim_plan_excluding( + BODY, + &LabConfig::with_defaults().unwrap(), + &[terraphim_alternatives::text::ByteRange { + start: at, + end: at + "decision".len(), + }], + ); + assert_eq!(plan.cuts().len(), 12); + for mask in 0..(1 << plan.cuts().len()) { + let kept: Vec<_> = plan + .cuts() + .iter() + .filter(|c| mask & (1 << c.id.0) != 0) + .map(|c| c.id) + .collect(); + for level in TrimLevel::ALL { + assert_preserved(BODY, &plan, level, &kept); + let active = plan.active(level, &kept); + for (index, cut) in active.iter().enumerate() { + assert!(active[..index].iter().all(|prior| !crossing(prior, cut))); + } + } + } + } + + fn two_cuts(body: &str, first_end: usize, second_start: usize) -> TrimPlan { + let mut plan = trim_plan(body, &LabConfig::with_defaults().unwrap()); + let end = body.encode_utf16().count(); + plan.cuts = [(0, first_end), (second_start, end)] + .into_iter() + .enumerate() + .map(|(i, (start, end))| Cut { + id: CutId(i as u32), + start, + end, + tier: Tier::Sentence, + first_level: TrimLevel::Half, + score: 1.0, + reason: "real separator regression".into(), + words: plan.words_in(start, end), + }) + .collect(); + plan + } + + #[test] + fn unicode_whitespace_scalar_boundaries_and_each_keep_choice_preserve_exact_coverage() { + for separator in [" ", "\t", "\r\n", "\u{a0}", "\u{2003}", " \t\u{a0}\r\n"] { + let body = format!("🙂Alpha{separator}Beta"); + let start = "🙂Alpha".encode_utf16().count(); + let end = start + separator.encode_utf16().count(); + let plan = two_cuts(&body, end, start); + for kept in [ + vec![], + vec![CutId(0)], + vec![CutId(1)], + vec![CutId(0), CutId(1)], + ] { + assert_preserved(&body, &plan, TrimLevel::Half, &kept); + } + let active = plan.active(TrimLevel::Half, &[]); + assert_eq!(active[1].start, active[0].end); + assert!(!crossing(&active[0], &active[1])); + } + } + + #[test] + fn crossings_that_include_real_content_are_not_normalized_or_weakened() { + let body = "🙂Alpha Beta"; + let plan = two_cuts(body, 8, 6); + let raw = plan.active_fragments(TrimLevel::Half, &[]); + assert!(crossing(&raw[0], &raw[1])); + assert!(!plan.is_blank(raw[1].start, raw[0].end)); + assert_eq!(plan.active(TrimLevel::Half, &[]), raw); + } +} diff --git a/crates/terraphim_lab/tests/fixtures/public-project-brief.md b/crates/terraphim_lab/tests/fixtures/public-project-brief.md new file mode 100644 index 0000000..c559eec --- /dev/null +++ b/crates/terraphim_lab/tests/fixtures/public-project-brief.md @@ -0,0 +1 @@ +This is teh teh plan , with alot of detail. The team maybe basically really just sort of hopes the plan might possibly work. This bloody awesome solution is super cool. Although the team, which (because the plan (while incomplete) is changing) has concerns, will proceed; however, unless the supplier agrees, the launch cannot continue: this is conditional. In order to facilitate the implementation of the appropriate operational mechanism, which may have been considered by the relevant stakeholders in relation to the proposed initiative, the committee will undertake a comprehensive review of every single aspect of the process before making any kind of final decision about whether to proceed with the project. diff --git a/tests/browser/native-authority-controller.integration.test.cjs b/tests/browser/native-authority-controller.integration.test.cjs index 77947c0..2d4099d 100644 --- a/tests/browser/native-authority-controller.integration.test.cjs +++ b/tests/browser/native-authority-controller.integration.test.cjs @@ -360,3 +360,30 @@ test('authored alternative exports real typed KG synonyms and Undo removes actua await page.evaluate(()=>editor.surface.redo());assert.deepEqual(JSON.parse(await a.query('export_knowledge_graph')),graph); }finally{await page.close();} }); + +test('actual public brief Walk Keep Cuts Undo then Done preserves protected history', {timeout:60000},async()=>{ + const body=await fs.readFile(process.env.TERRAPHIM_TRIM_REGRESSION_FIXTURE||path.resolve(__dirname,'../../crates/terraphim_lab/tests/fixtures/public-project-brief.md'),'utf8'); + const role=await fs.readFile(path.join(process.env.TERRAPHIM_AUTHORITY_ASSETS,'roles/default.json'),'utf8'); + const output=process.env.TERRAPHIM_TRIM_REGRESSION_OUTPUT||path.join(await fs.mkdtemp(path.join(os.tmpdir(),'terraphim-trim-controller-')),'outputs');await fs.mkdir(output,{recursive:false}); + const {page,a}=await setup(body);const receipt={scope:'Real component DOM/controllers + actual native worker; not installed-host UI save evidence',checks:[]}; + try{ + await page.evaluate(async role=>{await editor.authority.control({kind:'role-load',json:role});await editor.chrome.setMode('write-on',{persist:false});},role); + const initial=await a.snapshot(); + for(const action of ['typos_and_punctuation','weakest_sentences','long_sentences','convoluted_sentences','off_tone','hedges_and_filler'])await page.evaluate(action=>editor.lab.run(action),action); + for(const level of ['original','slight','tighten','sharper','half'])await page.evaluate(level=>editor.lab.trim.select(level),level); + const first=await page.evaluate(()=>({plan:editor.lab.trim.plan,status:editor.lab.trim.status}));assert.equal(first.plan.totalWords,111);assert.equal(first.plan.protectionCount,1);assert.equal((await a.snapshot()).source,initial.source);receipt.checks.push('Six marks and five levels preserve111-word source/protectedCount1'); + await page.locator('.te-trim-walk-start').click();await page.locator('.te-trim-next').click();await page.locator('.te-trim-prev').click();await page.locator('.te-trim-keep').click(); + await page.waitForFunction(()=>editor.lab.trim.kept.has(0)&&editor.lab.trim.active.every(c=>c.id!==0));await page.locator('.te-trim-stop').click(); + const clicked=await page.evaluate(()=>{const el=Array.from(editor.surface.root.querySelectorAll('[data-te-decoration]')).find(el=>el.textContent.trim()==='maybe');if(!el)return false;el.click();return true;});assert.equal(clicked,true,'Actual faded maybe segment must exist'); + await page.waitForFunction(()=>editor.lab.trim.kept.has(1)&&editor.lab.trim.active.every(c=>c.id!==1)); + const expected=await a.query('trim_make_cuts',['half','[0,1]']);await page.locator('.te-trim-cut').click();await page.waitForFunction(expected=>editor.surface.getText()===expected,expected.text);await settle(page); + const cut=await a.snapshot();assert.equal(cut.projection.body,expected.text);await fs.writeFile(path.join(output,'actual-make-cuts.md'),cut.source);receipt.checks.push('Actual Walk/Keep/faded-Keep MakeCuts matches native result'); + assert.equal(await page.evaluate(()=>editor.surface.undo()),true);await settle(page);assert.equal((await a.snapshot()).source,initial.source);await fs.writeFile(path.join(output,'actual-cuts-undo.md'),(await a.snapshot()).source);receipt.checks.push('Whole Cuts Undo restores exact original716 bytes'); + await page.evaluate(async()=>{editor.lab.open();await editor.lab.trim.select('half');}); + await fs.writeFile(path.join(output,'actual-before-done.json'),JSON.stringify(await page.evaluate(()=>({plan:editor.lab.trim.plan,status:editor.lab.trim.status})),null,2)+'\n'); + const done=await page.evaluate(()=>editor.lab.trim.done());receipt.done=done;receipt.live=await page.evaluate(()=>editor.lab.trim.live.textContent);await settle(page); + const promoted=await a.snapshot();await fs.writeFile(path.join(output,'actual-done.md'),promoted.source);assert.equal(done,true,receipt.live);assert.equal(promoted.projection.body,body);assert.ok(promoted.projection.annotations.ghosts.length);receipt.checks.push('Done promotes independent actual persistent ghosts without changing body'); + assert.equal(await page.evaluate(()=>editor.surface.undo()),true);assert.equal((await a.snapshot()).source,initial.source);assert.equal(await page.evaluate(()=>editor.surface.redo()),true);assert.equal((await a.snapshot()).source,promoted.source);receipt.checks.push('Whole protected Done Undo/Redo retains exact source and native receipt');receipt.status='PASS'; + }catch(error){receipt.status='FAIL';receipt.error=error.stack;throw error;} + finally{await fs.writeFile(path.join(output,'receipt.json'),JSON.stringify(receipt,null,2)+'\n');await page.close();} +}); From f0efc8ad77b1d17038bf2ec44a9f3a4ecc563cbf Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sat, 17 Oct 2026 22:10:16 +0100 Subject: [PATCH 05/16] fix: count nested browser cases in full CI admission --- .github/workflows/public-root.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/public-root.yml b/.github/workflows/public-root.yml index 60f75ed..ed94784 100644 --- a/.github/workflows/public-root.yml +++ b/.github/workflows/public-root.yml @@ -117,7 +117,7 @@ jobs: for row in receipt['runs']: assert row['status'] == 'PASS' and row['exitCode'] == 0 source = Path('tests', row['binary'] + '.rs').read_text() - count = len(re.findall(r'^#\[wasm_bindgen_test(?:\([^\]]*\))?\]', source, re.M)) + count = len(re.findall(r'^[ \t]*#\[wasm_bindgen_test(?:\([^\]]*\))?\]', source, re.M)) assert count > 0 and row['reportedCounts'] == [[str(count), '0', '0']], row assert hashlib.sha256((folder / (row['binary'] + '.log')).read_bytes()).hexdigest() == row['logSha256'] PY From a90e344aca31ff515ee8f789c66000734072144f Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sat, 17 Oct 2026 23:00:50 +0100 Subject: [PATCH 06/16] fix: preserve retained file writes without global picker support --- public/js/persistence.js | 18 ++- tests/web_files_handles.rs | 5 +- tests/web_files_retained_capabilities.rs | 189 +++++++++++++++++++++++ 3 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 tests/web_files_retained_capabilities.rs diff --git a/public/js/persistence.js b/public/js/persistence.js index ed1964d..63da398 100644 --- a/public/js/persistence.js +++ b/public/js/persistence.js @@ -30,8 +30,11 @@ * else the first heading, else untitled.md); later saves write to the * same handle without asking. Opening a file through the picker, or * dropping one in Chromium, keeps its handle, so saving writes back. - * - otherwise (Firefox, Safari, or config.fileSystemAccess === false): a - * download of the .md through a Blob and an . + * - a retained writable file handle works independently of global picker + * availability, including browser-owned origin-private files. + * - without a usable handle or save picker, or when + * config.fileSystemAccess === false: a download of the .md through a + * Blob and an . * te:written {name, method: 'file' | 'download'} follows a completed write. * save() returns the saved text synchronously (null when cancelled or when * the document model is unavailable); `lastWrite` is the write's promise, @@ -920,9 +923,18 @@ class TePersistence { * cancelled picker writes nothing. The notice tells the user each time. */ writeFile(text, name, { keepHandle = false, generation = this.generation } = {}) { - if (!this.fsAccess) return Promise.resolve(this.download(text, name)); + // An already-open writable handle does not require global picker APIs + // (for example an origin-private file in Firefox). Keep an explicit + // download-only policy authoritative even when a handle was supplied. + const fileAccessEnabled = this.config.fileSystemAccess !== false; + const canReuseHandle = fileAccessEnabled && keepHandle && this.handle; + const canPickSave = fileAccessEnabled && typeof window.showSaveFilePicker === 'function'; + if (!canPickSave && !canReuseHandle) return Promise.resolve(this.download(text, name)); const pick = () => { try { + if (this.config.fileSystemAccess === false || typeof window.showSaveFilePicker !== 'function') { + throw new DOMException('Save picker unavailable', 'NotSupportedError'); + } return window.showSaveFilePicker({ suggestedName: name, types: TE_PICKER_TYPES }); } catch (err) { return Promise.reject(err); diff --git a/tests/web_files_handles.rs b/tests/web_files_handles.rs index 11bb630..a497751 100644 --- a/tests/web_files_handles.rs +++ b/tests/web_files_handles.rs @@ -17,10 +17,9 @@ async fn test_save_writes_back_to_a_kept_file_handle() { let result = js_async( r##" const out = []; - // The File System Access path: the default option, which headless - // Chrome supports for origin-private file handles. + // A real writable origin-private handle is usable independently of + // global picker APIs. The actual file readback below proves support. let ed = teFiles.reinit({ fileSystemAccess: undefined }); - if (!ed.persistence.fsAccess) return 'File System Access not detected'; const dir = await navigator.storage.getDirectory(); const handle = await dir.getFileHandle('kept.md', { create: true }); const canonical = teFiles.canonicalFull(ed); diff --git a/tests/web_files_retained_capabilities.rs b/tests/web_files_retained_capabilities.rs new file mode 100644 index 0000000..4c21d68 --- /dev/null +++ b/tests/web_files_retained_capabilities.rs @@ -0,0 +1,189 @@ +//! Retained real-file writes must not depend on global picker availability. +//! Uses browser-owned OPFS files; no filesystem, permission, or picker shims. +//! WIP: Chrome155 still hits the original6s bound in auto-write and exclusive- +//! lock admission. Preserve these failures; this file is not a qualification claim. +#![cfg(target_arch = "wasm32")] + +mod support; + +use support::*; + +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn retained_writable_file_is_updated_with_automatic_capability_detection() { + let _document = fresh_files_editor(); + let result = js_async( + r##" + const ed = teFiles.reinit({ fileSystemAccess: undefined }); + const root = await navigator.storage.getDirectory(); + const dir = await root.getDirectoryHandle('retained-capability-regression', { create: true }); + const handle = await dir.getFileHandle('actual-retained.md', { create: true }); + const initial = teFiles.canonicalFull(ed); + const writer = await handle.createWritable(); + await writer.write(initial); + await writer.close(); + // Observe/cancel unintended downloads to keep the test's effects inside + // its real OPFS directory. This does not replace any file operation. + const downloads = teFiles.captureDownloads(); + try { + if (!(await ed.persistence.openFile(await handle.getFile(), handle))) return 'real file did not open'; + teFiles.type(ed, ' Actual retained-file edit.'); + const expected = ed.persistence.save(); + if (!(await ed.persistence.lastWrite)) return 'save failed'; + const actual = await (await handle.getFile()).text(); + console.log('TERRAPHIM_RETAINED_FILE_RESULT ' + JSON.stringify({policy:'automatic',expected,actual,downloads:downloads.list.length,fsAccess:ed.persistence.fsAccess,openPicker:typeof window.showOpenFilePicker,savePicker:typeof window.showSaveFilePicker})); + if (actual !== expected) return 'real retained file was not updated'; + if (!actual.includes('Actual retained-file edit.')) return 'edit absent from real file'; + if (downloads.list.length !== 0) return 'download substituted for a retained-file write'; + if (ed.persistence.dirty) return 'successful real write remained dirty'; + return ''; + } finally { + downloads.stop(); + await root.removeEntry('retained-capability-regression', { recursive: true }); + } + "##, + ) + .await; + assert_eq!(result, ""); +} + +#[wasm_bindgen_test] +async fn explicit_file_access_disable_preserves_the_retained_file() { + let _document = fresh_files_editor(); + let result = js_async( + r##" + const ed = teFiles.reinit({ fileSystemAccess: false }); + const root = await navigator.storage.getDirectory(); + const dir = await root.getDirectoryHandle('retained-policy-regression', { create: true }); + const handle = await dir.getFileHandle('policy-owned.md', { create: true }); + const initial = teFiles.canonicalFull(ed); + const writer = await handle.createWritable(); + await writer.write(initial); + await writer.close(); + const downloads = teFiles.captureDownloads(); + try { + if (!(await ed.persistence.openFile(await handle.getFile(), handle))) return 'real file did not open'; + teFiles.type(ed, ' Explicit download-only edit.'); + const expected = ed.persistence.save(); + if (!(await ed.persistence.lastWrite)) return 'download-only save failed'; + const actual = await (await handle.getFile()).text(); + console.log('TERRAPHIM_RETAINED_FILE_RESULT ' + JSON.stringify({policy:'disabled',initial,expected,actual,downloads:downloads.list.length})); + if (actual !== initial) return 'explicitly disabled file access wrote the retained file'; + if (downloads.list.length !== 1) return 'download-only policy did not request one copy'; + if (await teFiles.read(downloads.list[0]) !== expected) return 'download copy differs from serialized document'; + return ''; + } finally { + downloads.stop(); + await root.removeEntry('retained-policy-regression', { recursive: true }); + } + "##, + ) + .await; + assert_eq!(result, ""); +} + +#[wasm_bindgen_test] +async fn actual_retained_file_write_failure_preserves_dirty_document() { + let _document = fresh_files_editor(); + let result = js_async( + r##" + const ed = teFiles.reinit({ fileSystemAccess: undefined }); + const root = await navigator.storage.getDirectory(); + const dir = await root.getDirectoryHandle('retained-locked-regression', { create: true }); + const handle = await dir.getFileHandle('actually-locked.md', { create: true }); + const initial = teFiles.canonicalFull(ed); + const writer = await handle.createWritable(); + await writer.write(initial); + await writer.close(); + const workerSource = ` + let lock = null; + self.onmessage = async ({data}) => { + try { + if (data.kind === 'lock') { + if (typeof data.handle.createSyncAccessHandle !== 'function') throw new Error('Real OPFS sync access handles unavailable'); + lock = await data.handle.createSyncAccessHandle(); + self.postMessage({kind:'locked',size:lock.getSize()}); + } else if (data.kind === 'release') { + if (lock) { lock.close(); lock = null; } + self.postMessage({kind:'released'}); + } + } catch (error) { self.postMessage({kind:'error',name:error.name,message:error.message}); } + }; + `; + const workerURL = URL.createObjectURL(new Blob([workerSource], {type:'text/javascript'})); + const worker = new Worker(workerURL); + const downloads = teFiles.captureDownloads(); + const waiters = new Map(); + const request = (kind, payload = {}) => new Promise((resolve, reject) => { + waiters.set(kind === 'lock' ? 'locked' : 'released', {resolve,reject}); + worker.postMessage({kind,...payload}); + }); + worker.onmessage = ({data}) => { + if (data.kind === 'error') { + for (const waiter of waiters.values()) waiter.reject(new Error(data.name+': '+data.message)); + waiters.clear(); + } else { + const waiter=waiters.get(data.kind); + if (waiter) { waiters.delete(data.kind);waiter.resolve(data); } + } + }; + worker.onerror = (error) => { for (const waiter of waiters.values()) waiter.reject(new Error(error.message));waiters.clear(); }; + try { + if (!(await ed.persistence.openFile(await handle.getFile(), handle))) return 'real file did not open'; + teFiles.type(ed, ' Keep this unsaved edit.'); + const expectedBody = ed.surface.getText(); + const before = await (await handle.getFile()).text(); + if (before !== initial) return 'actual file changed before lock'; + const ready = await request('lock', {handle}); + console.log('TERRAPHIM_REAL_OPFS_LOCK '+JSON.stringify(ready)); + ed.persistence.save(); + const written = await ed.persistence.lastWrite; + const dirtyAfterFailure = ed.persistence.dirty; + const actualBody = ed.surface.getText(); + await request('release'); + const actual = await (await handle.getFile()).text(); + console.log('TERRAPHIM_RETAINED_FILE_RESULT '+JSON.stringify({policy:'real-exclusive-opfs-lock',written,dirty:dirtyAfterFailure,initial,actual,expectedBody,actualBody,downloads:downloads.list.length})); + if (written) return 'exclusively locked real file reported a successful write'; + if (!dirtyAfterFailure) return 'failed real write cleared dirty state'; + if (actualBody !== expectedBody) return 'failed real write changed the document'; + if (actual !== initial) return 'failed real write changed the actual file'; + if (downloads.list.length !== 0) return 'write failure silently substituted a download'; + return ''; + } finally { + // Shutdown only this fixture-owned worker. Termination releases any + // lock even if the test was cancelled while awaiting an I/O result. + worker.terminate(); + URL.revokeObjectURL(workerURL); + downloads.stop(); + await root.removeEntry('retained-locked-regression', { recursive: true }); + } + "##, + ) + .await; + assert_eq!(result, ""); +} + +#[wasm_bindgen_test] +async fn automatic_save_without_a_handle_retains_real_download_payload() { + let _document = fresh_files_editor(); + let result = js_async( + r##" + const ed = teFiles.reinit({ fileSystemAccess: undefined }); + const downloads = teFiles.captureDownloads(); + try { + teFiles.type(ed, ' Preserve the download fallback.'); + const expected = ed.persistence.save(); + if (!(await ed.persistence.lastWrite)) return 'download fallback failed'; + if (downloads.list.length !== 1) return 'expected one actual download request'; + const actual = await teFiles.read(downloads.list[0]); + console.log('TERRAPHIM_RETAINED_FILE_RESULT ' + JSON.stringify({policy:'no-retained-handle',expected,actual,downloads:downloads.list.length})); + if (actual !== expected) return 'download payload differs from serialized document'; + if (ed.persistence.handle) return 'download invented a retained file handle'; + return ''; + } finally { downloads.stop(); } + "##, + ) + .await; + assert_eq!(result, ""); +} From bb5c6c8d49e194ce655abd3f0d294a529129f1d4 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sat, 17 Oct 2026 23:20:00 +0100 Subject: [PATCH 07/16] fix: remeasure indicators after native status flow changes --- public/js/editor.js | 18 ++++++--- tests/web_indicators_registry.rs | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/public/js/editor.js b/public/js/editor.js index 1078539..edb2ebc 100644 --- a/public/js/editor.js +++ b/public/js/editor.js @@ -1849,7 +1849,7 @@ class MarkdownEditor { const retained=this.pendingAuthorityDraft(); this.authorityQueueGeneration=(this.authorityQueueGeneration||0)+1; this.authorityQueue=Promise.resolve();this.authorityFault=null;this.authorityDrafts.clear(); - this.authorityStatus.textContent='';this.surface.root.removeAttribute('aria-readonly'); + this.setAuthorityStatus('');this.surface.root.removeAttribute('aria-readonly'); if(this.altPanel) { this.altPanel.authorityCancelGeneration=(this.altPanel.authorityCancelGeneration||0)+1; if(this.altPanel.live)this.altPanel.endLive(); @@ -1900,21 +1900,29 @@ class MarkdownEditor { if(node && sel && document.activeElement===this.surface.root) sel.setBaseAndExtent(node,Math.min(selection.start,node.length),node,Math.min(selection.end,node.length)); } + /** Update the flow status and remeasure indicators after its line changes. */ + setAuthorityStatus(text) { + this.authorityStatus.textContent = text; + // A fixed-height surface can move without resizing when this sibling + // appears or clears. Its overlay must follow the final text position. + this.indicators?.scheduleLayout(); + } + authorityEnqueue(work) { const generation=this.authorityQueueGeneration; const queued = this.authorityQueue.then(async () => { if(generation!==this.authorityQueueGeneration)throw new Error('Pending operation belongs to a recovered authority'); if (this.destroyed) throw new Error('Editor disposed; pending draft retained by host'); if (this.authorityFault) throw this.authorityFault; - this.authorityStatus.textContent = 'Applying pending changes…'; + this.setAuthorityStatus('Applying pending changes…'); try { return await work(); } - finally { if (generation===this.authorityQueueGeneration && !this.authorityFault) this.authorityStatus.textContent = ''; } + finally { if (generation===this.authorityQueueGeneration && !this.authorityFault) this.setAuthorityStatus(''); } }); // Keep the queue usable after a confirmed refusal; callers still receive it. this.authorityQueue = queued.catch(error => { if(generation!==this.authorityQueueGeneration)return; if (error?.authorityUncertain) this.authorityFault = error; - this.authorityStatus.textContent = `Not accepted: ${error?.message || error}`; + this.setAuthorityStatus(`Not accepted: ${error?.message || error}`); if (this.authorityFault) this.surface.root.setAttribute('aria-readonly','true'); }); return queued; @@ -1967,7 +1975,7 @@ class MarkdownEditor { } finally { this.authorityPainting = false; this.authorityRenderDraft(); } } else if (painted || error?.authorityUncertain) { this.authorityFault = error; - this.authorityStatus.textContent = 'Native outcome is uncertain. Preserve drafts and recover explicitly.'; + this.setAuthorityStatus('Native outcome is uncertain. Preserve drafts and recover explicitly.'); } throw error; } diff --git a/tests/web_indicators_registry.rs b/tests/web_indicators_registry.rs index ae193c5..3322c23 100644 --- a/tests/web_indicators_registry.rs +++ b/tests/web_indicators_registry.rs @@ -248,3 +248,67 @@ async fn test_paragraph_rule_follows_the_column_when_a_panel_moves_it() { ); } } + +/// Native queue status is a real flow sibling of the writing surface. Its +/// pending line must not leave indicators displaced after it disappears. +/// This exercises the actual queue and DOM layout; it does not substitute a +/// native transaction or claim native-worker qualification. +#[wasm_bindgen_test] +async fn test_authority_queue_status_transitions_keep_indicators_aligned() { + setup(); + let result = js_async( + r##" + const ed = teInd.editor(); + teInd.open(); + const status = document.createElement('div'); + status.className = 'te-authority-status'; + status.setAttribute('role', 'status'); + ed.surface.root.before(status); + ed.createdNodes.push(status); + ed.authorityStatus = status; + // Queue state only: no native adapter or fabricated transaction result. + ed.authorityQueue = Promise.resolve(); + ed.authorityQueueGeneration = 0; + const frame = () => new Promise(resolve => requestAnimationFrame(resolve)); + const deltas = () => Array.from(ed.indicators.spans.values(), entry => { + const span = teInd.span(entry.id); + const rects = Array.from(ed.surface.rangeForOffsets(span.anchor.start, span.anchor.end).getClientRects()).filter(rect => rect.width > 0 && rect.height > 0); + const expected = entry.kind === 'paragraph' ? rects[0].top : entry.kind === 'word' + ? Math.max(...rects.map(rect => rect.bottom)) + 2 + : rects[rects.length - 1].bottom + 2; + return [entry.id, entry.headline ? 'headline' : entry.kind, entry.el.getBoundingClientRect().top - expected]; + }); + const aligned = () => { + const rows = deltas(); + return rows.length === 4 && JSON.stringify(rows.map(row => row[1]).sort()) === '["headline","paragraph","sentence","word"]' + && rows.every(row => Math.abs(row[2]) < 1.5); + }; + const saved = ed.saveDocument(); + let pendingHeight = 0; + await ed.authorityEnqueue(async () => { + pendingHeight = status.getBoundingClientRect().height; + // Establish geometry while the real queue's pending status occupies + // its line, as an actual native projection refresh does. Never flush + // after the status clears: that would conceal the regression. + ed.indicators.flush(); + if (!aligned()) throw new Error('pending geometry is not aligned'); + }); + await frame(); await frame(); + console.log('TERRAPHIM_AUTHORITY_STATUS_GEOMETRY ' + JSON.stringify({phase:'cleared',pendingHeight,statusHeight:status.getBoundingClientRect().height,deltas:deltas()})); + if (!(pendingHeight > 0)) return 'pending status did not occupy a real flow line'; + if (status.textContent !== '') return 'completed queue did not clear status'; + if (!aligned()) return 'cleared status left stale indicator positions: ' + JSON.stringify(deltas()); + // An actual refused queue operation must also invalidate geometry. + try { await ed.authorityEnqueue(async () => { throw new Error('Public regression refusal'); }); } + catch (error) { if (error.message !== 'Public regression refusal') throw error; } + await ed.authorityQueue; + await frame(); await frame(); + if (!status.textContent.startsWith('Not accepted:')) return 'refusal status not displayed'; + if (!aligned()) return 'refusal status left stale indicator positions: ' + JSON.stringify(deltas()); + if (ed.saveDocument() !== saved) return 'status transitions changed the saved document'; + return ''; + "##, + ) + .await; + assert_eq!(result, ""); +} From cb26d676db4e69cc612b59c1362867e277191071 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sun, 18 Oct 2026 00:14:59 +0100 Subject: [PATCH 08/16] test: require actual same-candidate Chrome and Firefox clipboard CI --- .github/workflows/public-root.yml | 59 +++ .github/workflows/release.yml | 47 ++- scripts/publish-editor-release.py | 11 +- scripts/qualify-trusted-clipboard.py | 339 ++++++++++++++++++ tests/browser/run-synonym-sharing.cjs | 5 +- .../browser/webdriver-clipboard-user-flow.cjs | 108 ++++++ .../trusted-clipboard-gecko/actual-copied.txt | 2 + .../trusted-clipboard-gecko/actual-pasted.txt | 3 + .../clipboard-receipt.json | 34 ++ .../trusted-clipboard-gecko/provenance.json | 10 + .../runtime-assets.json | 104 ++++++ tests/test_editor_release.py | 9 +- tests/test_trusted_clipboard.py | 163 +++++++++ 13 files changed, 886 insertions(+), 8 deletions(-) create mode 100644 scripts/qualify-trusted-clipboard.py create mode 100644 tests/browser/webdriver-clipboard-user-flow.cjs create mode 100644 tests/fixtures/trusted-clipboard-gecko/actual-copied.txt create mode 100644 tests/fixtures/trusted-clipboard-gecko/actual-pasted.txt create mode 100644 tests/fixtures/trusted-clipboard-gecko/clipboard-receipt.json create mode 100644 tests/fixtures/trusted-clipboard-gecko/provenance.json create mode 100644 tests/fixtures/trusted-clipboard-gecko/runtime-assets.json create mode 100644 tests/test_trusted_clipboard.py diff --git a/.github/workflows/public-root.yml b/.github/workflows/public-root.yml index ed94784..fe9438c 100644 --- a/.github/workflows/public-root.yml +++ b/.github/workflows/public-root.yml @@ -27,6 +27,7 @@ jobs: - name: Exercise source and public dependency admission run: | python3 -m unittest discover -s tests -p 'test_release_*.py' -v + python3 -m unittest discover -s tests -p 'test_trusted_clipboard.py' -v python3 -m unittest discover -s tests -p 'test_public_dependencies.py' -v python3 -m unittest discover -s tests -p 'test_public_cargo_home.py' -v python3 -m unittest discover -s tests -p 'test_rolegraph_public_provenance.py' -v @@ -51,6 +52,8 @@ jobs: bash scripts/ci/run.sh cargo check --locked --workspace --target wasm32-unknown-unknown --lib browser-workspace: + outputs: + clipboard-candidate-artifact-id: ${{ steps.clipboard-candidate.outputs.artifact-id }} # Source verification only: no self-hosted runners, secrets or publication. runs-on: ubuntu-latest timeout-minutes: 45 @@ -121,6 +124,28 @@ jobs: assert count > 0 and row['reportedCounts'] == [[str(count), '0', '0']], row assert hashlib.sha256((folder / (row['binary'] + '.log')).read_bytes()).hexdigest() == row['logSha256'] PY + - name: Build one transferable clipboard candidate from this exact source + run: | + set -euo pipefail + export CI_TOOLS_PATH=1 + source scripts/ci/env.sh + if ! command -v fd >/dev/null && ! command -v fdfind >/dev/null; then + sudo apt-get update + sudo apt-get install --yes fd-find + fi + if ! command -v fd >/dev/null; then + command -v fdfind >/dev/null || { echo 'fd-find bootstrap did not provide fdfind' >&2; exit 1; } + ln -s "$(command -v fdfind)" "$RUNNER_TEMP/fd" + export PATH="$RUNNER_TEMP:$PATH" + fi + python3 scripts/build-host-assets.py --output "$RUNNER_TEMP/editor-clipboard-assets" --wasm-bindgen "$CI_TOOLS/bin/wasm-bindgen" + python3 scripts/qualify-trusted-clipboard.py package-host --assets "$RUNNER_TEMP/editor-clipboard-assets" --output "$RUNNER_TEMP/editor-clipboard-candidate" --source "$GITHUB_SHA" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + id: clipboard-candidate + with: + name: editor-clipboard-candidate-${{ github.sha }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/editor-clipboard-candidate/ + if-no-files-found: error - name: Retain every browser result, including failures if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 @@ -128,3 +153,37 @@ jobs: name: editor-browser-source-${{ github.sha }}-${{ github.run_attempt }} path: ${{ runner.temp }}/editor-browser-evidence if-no-files-found: error + + trusted-clipboard: + # Real headed OS clipboard; no private runners, credentials or publication. + needs: browser-workspace + runs-on: macos-15 + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.12' + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + artifact-ids: ${{ needs.browser-workspace.outputs.clipboard-candidate-artifact-id }} + merge-multiple: true + path: ${{ runner.temp }}/editor-clipboard-candidate + - name: Qualify actual Chrome and Firefox against the same candidate bytes + run: | + sw_vers + uname -a + python3 scripts/qualify-trusted-clipboard.py run --candidate "$RUNNER_TEMP/editor-clipboard-candidate" --evidence "$RUNNER_TEMP/editor-trusted-clipboard" --source "$GITHUB_SHA" + - name: Retain actual clipboard outputs, including failures + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: editor-trusted-clipboard-${{ github.sha }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/editor-trusted-clipboard/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 460e3db..3003d12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,7 @@ jobs: - name: Verify clean public source, immutable RC tag and admission policy run: | python3 -m unittest discover -s tests -p 'test_release_*.py' -v + python3 -m unittest discover -s tests -p 'test_trusted_clipboard.py' -v python3 -m unittest discover -s tests -p 'test_editor_release.py' -v python3 -m unittest discover -s tests -p 'test_public_*.py' -v python3 -m unittest discover -s tests -p 'test_rolegraph_public_provenance.py' -v @@ -92,9 +93,46 @@ jobs: name: editor-observed-${{ github.sha }}-${{ github.run_attempt }} path: ${{ runner.temp }}/editor-evidence/ if-no-files-found: ignore - publish: + trusted-clipboard: needs: candidate if: github.repository == 'terraphim/terraphim-editor' && github.ref_type == 'tag' + runs-on: macos-15 + timeout-minutes: 15 + outputs: + artifact-id: ${{ steps.clipboard-evidence.outputs.artifact-id }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.12' + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + artifact-ids: ${{ needs.candidate.outputs.artifact-id }} + merge-multiple: true + path: ${{ runner.temp }}/editor-qualified + - name: Verify actual Chrome and Firefox clipboard on the exact release archive + run: | + sw_vers + uname -a + python3 scripts/publish-editor-release.py verify --folder "$RUNNER_TEMP/editor-qualified" --tag "$RELEASE_TAG" + python3 scripts/qualify-trusted-clipboard.py run --candidate "$RUNNER_TEMP/editor-qualified" --evidence "$RUNNER_TEMP/editor-trusted-clipboard" --source "$GITHUB_SHA" + - name: Retain actual clipboard outputs, including failures + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + id: clipboard-evidence + with: + name: editor-release-clipboard-${{ github.sha }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/editor-trusted-clipboard/ + if-no-files-found: error + publish: + needs: [candidate, trusted-clipboard] + if: github.repository == 'terraphim/terraphim-editor' && github.ref_type == 'tag' environment: editor-release runs-on: ubuntu-latest timeout-minutes: 20 @@ -114,12 +152,17 @@ jobs: artifact-ids: ${{ needs.candidate.outputs.artifact-id }} merge-multiple: true path: ${{ runner.temp }}/editor-qualified + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + artifact-ids: ${{ needs.trusted-clipboard.outputs.artifact-id }} + merge-multiple: true + path: ${{ runner.temp }}/editor-trusted-clipboard - name: Revalidate exact candidate before protected publication run: python3 scripts/publish-editor-release.py verify --folder "$RUNNER_TEMP/editor-qualified" --tag "$RELEASE_TAG" - name: Publish immutable prerelease and verify anonymous downloads env: GH_TOKEN: ${{ github.token }} - run: python3 scripts/publish-editor-release.py publish --folder "$RUNNER_TEMP/editor-qualified" --tag "$RELEASE_TAG" --receipt "$RUNNER_TEMP/editor-published.json" + run: python3 scripts/publish-editor-release.py publish --folder "$RUNNER_TEMP/editor-qualified" --tag "$RELEASE_TAG" --receipt "$RUNNER_TEMP/editor-published.json" --clipboard-evidence "$RUNNER_TEMP/editor-trusted-clipboard" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: editor-published-${{ github.sha }}-${{ github.run_attempt }} diff --git a/scripts/publish-editor-release.py b/scripts/publish-editor-release.py index 650e32e..5d6fa54 100644 --- a/scripts/publish-editor-release.py +++ b/scripts/publish-editor-release.py @@ -263,9 +263,14 @@ def anonymous_download(url, destination): return destination.read_bytes() -def publish(repo, tag, files, source): +def publish(repo, tag, files, source, clipboard_evidence=None): if repo != REPOSITORY: raise ValueError("Invalid GitHub repository") + if clipboard_evidence is None or not files: + raise ValueError('Fresh actual Chrome/Firefox clipboard admission is required before publication') + clipboard = load_module('trusted_clipboard_release', 'scripts/qualify-trusted-clipboard.py') + import os + clipboard.verify_admission(clipboard_evidence, files[0].parent, source, clipboard.context(os.environ)) repository = api(f"repos/{repo}") if repository.get("private") is not False or repository.get("full_name", "").lower() != repo.lower(): raise ValueError("Publication requires the exact public GitHub repository") @@ -409,7 +414,7 @@ def main(): build = sub.add_parser('package'); build.add_argument('--tag', required=True); build.add_argument('--stage', type=Path, required=True); build.add_argument('--output', type=Path, required=True) test = sub.add_parser('qualify'); test.add_argument('--tag', required=True); test.add_argument('--folder', type=Path, required=True); test.add_argument('--evidence', type=Path, required=True) verify = sub.add_parser('verify'); verify.add_argument('--tag', required=True); verify.add_argument('--folder', type=Path, required=True) - release = sub.add_parser('publish'); release.add_argument('--tag', required=True); release.add_argument('--folder', type=Path, required=True); release.add_argument('--receipt', type=Path, required=True) + release = sub.add_parser('publish'); release.add_argument('--tag', required=True); release.add_argument('--folder', type=Path, required=True); release.add_argument('--receipt', type=Path, required=True); release.add_argument('--clipboard-evidence', type=Path, required=True) args = parser.parse_args() if args.command == 'admit': print(admit_source(args.tag)) @@ -429,7 +434,7 @@ def main(): for path in files: (snapshot / path.name).write_bytes(path.read_bytes()) source, snapshots = verify_package(snapshot, args.tag) - result = publish(REPOSITORY, args.tag, snapshots, source) + result = publish(REPOSITORY, args.tag, snapshots, source, args.clipboard_evidence) args.receipt.parent.mkdir(parents=True, exist_ok=True) with args.receipt.open('x') as output: output.write(json.dumps(result, indent=2) + '\n') diff --git a/scripts/qualify-trusted-clipboard.py b/scripts/qualify-trusted-clipboard.py new file mode 100644 index 0000000..25b2d21 --- /dev/null +++ b/scripts/qualify-trusted-clipboard.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""Fail-closed actual Chrome/Gecko clipboard admission for one immutable candidate.""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import signal +import subprocess +import tarfile +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = 'terraphim/terraphim-editor' +HEX = re.compile(r'[a-f0-9]{64}') +SOURCE = re.compile(r'[a-f0-9]{40}') +FAMILIES = ('chrome', 'firefox') +REQUIRED_ASSETS = {'terraphim-editor.min.js', 'terraphim_editor.js', 'terraphim_editor_bg.wasm'} +HARNESSES = ('tests/browser/webdriver-clipboard-user-flow.cjs', 'tests/browser/run-synonym-sharing.cjs', + 'scripts/qualify-trusted-clipboard.py') + + +def digest(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def load_json(path): + path = Path(path) + if path.is_symlink() or not path.is_file() or path.stat().st_size > 8 * 1024 * 1024: + raise ValueError('Missing, linked or oversized proof file') + return json.loads(path.read_text()) + + +def read_output(path): + path = Path(path) + if path.is_symlink() or not path.is_file() or path.stat().st_size > 4096: + raise ValueError('Actual clipboard output missing, linked or oversized') + return path.read_bytes() + + +def release_module(): + spec = importlib.util.spec_from_file_location('editor_release_clipboard', ROOT / 'scripts/publish-editor-release.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def candidate(folder, source): + folder = Path(folder) + names = [('host-assets.tar.gz', 'host-assets.json'), ('editor-candidate.tar.gz', 'assets-manifest.json')] + found = [(folder / archive, folder / manifest) for archive, manifest in names + if (folder / archive).is_file() and (folder / manifest).is_file()] + if len(found) != 1 or not SOURCE.fullmatch(source): + raise ValueError('One explicit exact-source candidate required') + archive, manifest_path = found[0] + manifest = load_json(manifest_path) + if manifest.get('sourceHead') != source: + raise ValueError('Candidate source differs') + if archive.is_symlink(): + raise ValueError('Linked candidate archive refused') + assets = manifest.get('assets') + if not isinstance(assets, dict) or not REQUIRED_ASSETS <= assets.keys(): + raise ValueError('Candidate lacks required editor runtime assets') + files = release_module().safe_members(archive) + if set(files) != set(assets) or any(not isinstance(value, str) or not HEX.fullmatch(value) + or hashlib.sha256(files[name]).hexdigest() != value + for name, value in assets.items()): + raise ValueError('Candidate archive/member hashes differ') + return manifest, files, archive, manifest_path + + +def validate_browser(folder, family, manifest, manifest_sha): + """Validate actual saved output bytes; this alone never admits a CI run.""" + folder = Path(folder) + receipt = load_json(folder / 'clipboard-receipt.json') + runtime = load_json(folder / 'runtime-assets.json') + browser_path = receipt.get('browserPath', receipt.get('firefoxPath', '')) + browser_sha = receipt.get('browserSha256', receipt.get('firefoxSha256', '')) + declared = receipt.get('browserFamily', 'firefox' if 'Firefox.app/' in browser_path else None) + if family not in FAMILIES or declared != family: + raise ValueError('Browser family differs; no relabelling another engine') + if (receipt.get('status') != 'PASS' or receipt.get('priorClipboardRead') is not False + or receipt.get('priorClipboardRestored') is not False + or not isinstance(receipt.get('browser'), str) or not re.fullmatch(r'\d+(?:\.\d+)+', receipt['browser']) + or not isinstance(browser_path, str) or not browser_path.startswith('/') + or not isinstance(browser_sha, str) or not HEX.fullmatch(browser_sha) + or not isinstance(receipt.get('driverPath'), str) or not receipt['driverPath'].startswith('/') + or not isinstance(receipt.get('driverSha256'), str) or not HEX.fullmatch(receipt['driverSha256']) + or not isinstance(receipt.get('driverVersion'), str) or not receipt['driverVersion']): + raise ValueError('Incomplete actual browser/clipboard proof') + proof = receipt.get('pasteProof', {}) + if (proof.get('caret') != 8 or proof.get('richNodes') != 0 + or not any(event.get('isTrusted') is True and {'text/plain', 'text/html'} <= set(event.get('types', [])) + for event in proof.get('events', []))): + raise ValueError('Trusted mixed-format paste/caret/plain-text proof missing') + if (receipt.get('pastedText') != 'p1\np2\np3Hello' or receipt.get('copiedText') != 'p1\np2' + or read_output(folder / 'actual-pasted.txt') != b'p1\np2\np3Hello' + or read_output(folder / 'actual-copied.txt') != b'p1\np2'): + raise ValueError('Actual complete paste/copy outputs differ') + served = runtime.get('served') + if (runtime.get('status') != 'PASS' or runtime.get('exitCode') != 0 or runtime.get('mismatches') != [] + or runtime.get('manifestSha256') != manifest_sha or not isinstance(served, dict) + or not REQUIRED_ASSETS <= served.keys() + or any(manifest['assets'].get(name) != value for name, value in served.items())): + raise ValueError('Actual served runtime differs from the exact candidate') + return {'family': family, 'browser': receipt['browser'], 'browserSha256': browser_sha, + 'driverSha256': receipt['driverSha256'], 'pastedSha256': digest(folder / 'actual-pasted.txt'), + 'copiedSha256': digest(folder / 'actual-copied.txt')} + + +def context(environment): + if environment.get('GITHUB_ACTIONS') != 'true' or environment.get('GITHUB_REPOSITORY') != REPOSITORY: + raise ValueError('Fresh same-run GitHub workflow context required') + workflow = environment.get('GITHUB_WORKFLOW_REF', '') + if not any(workflow.startswith(REPOSITORY + '/.github/workflows/' + name + '@') + for name in ('public-root.yml', 'release.yml')): + raise ValueError('Unrecognized clipboard qualification workflow') + run_id, attempt = environment.get('GITHUB_RUN_ID', ''), environment.get('GITHUB_RUN_ATTEMPT', '') + if not run_id.isdigit() or int(run_id) < 1 or not attempt.isdigit() or int(attempt) < 1: + raise ValueError('Missing actual run/attempt identity') + return {'runId': run_id, 'runAttempt': attempt, 'workflowRef': workflow, + 'repository': REPOSITORY, 'sourceCommit': environment.get('GITHUB_SHA')} + + +def proof_inventory(folder): + folder = Path(folder) + files = {} + for family in FAMILIES: + for path in sorted((folder / family).rglob('*')): + if path.is_symlink(): + raise ValueError('Linked proof file refused') + if path.is_file(): + files[path.relative_to(folder).as_posix()] = digest(path) + for family in FAMILIES: + log = folder / (family + '.log') + if log.is_symlink() or not log.is_file(): + raise ValueError('Actual browser execution log missing') + files[log.name] = digest(log) + if len(files) > 512 or sum((folder / name).stat().st_size for name in files) > 32 * 1024 * 1024: + raise ValueError('Proof inventory exceeds bounds') + return files + + +def validate_binding(report, expected, source): + """Pure binding validation; browser output verification remains separate.""" + if (report.get('schema') != 1 or report.get('status') != 'PASS' + or expected.get('context', {}).get('sourceCommit') != source + or not SOURCE.fullmatch(source) + or any(report.get(key) != value for key, value in expected.items())): + raise ValueError('Current run/source/candidate/harness/output binding differs') + + +def verify_admission(folder, candidate_folder, source, expected_context, root=ROOT): + """Refuse stale, paste-only, untrusted or mismatched release admission.""" + folder = Path(folder) + report = load_json(folder / 'clipboard-admission.json') + manifest, _, archive, manifest_path = candidate(candidate_folder, source) + expected = {'context': expected_context, 'archiveSha256': digest(archive), + 'manifestSha256': digest(manifest_path), 'cargoLockSha256': digest(root / 'Cargo.lock'), + 'harnesses': {name: digest(root / name) for name in HARNESSES}, + 'outputs': proof_inventory(folder)} + validate_binding(report, expected, source) + verified = [] + for family in FAMILIES: + receipt = load_json(folder / family / 'clipboard-receipt.json') + if (receipt.get('platform') != 'darwin' or receipt.get('headless') is not False + or receipt.get('inputTransport') != 'W3C native clicks/keys' or receipt.get('browserFamily') != family): + raise ValueError('Actual headed Mac engine qualification required') + verified.append(validate_browser(folder / family, family, manifest, digest(manifest_path))) + if report.get('browsers') != verified: + raise ValueError('Browser admission differs from actual outputs') + return report + + +def package_host(assets, output, source): + assets, output = Path(assets), Path(output) + manifest = load_json(assets / 'host-assets.json') + if manifest.get('sourceHead') != source or output.exists(): + raise ValueError('Fresh exact-source candidate output required') + output.mkdir(parents=True) + shutil.copyfile(assets / 'host-assets.json', output / 'host-assets.json') + with tarfile.open(output / 'host-assets.tar.gz', 'w:gz') as archive: + for name, sha in sorted(manifest['assets'].items()): + if not isinstance(name, str) or any(part in ('', '.', '..') for part in name.split('/')) or name.startswith('/') or '\\' in name: + raise ValueError('Unsafe source candidate path') + path = assets / name + if path.is_symlink() or not path.is_file() or digest(path) != sha: + raise ValueError('Actual source candidate assets differ') + archive.add(path, arcname=name, recursive=False) + candidate(output, source) + + +def cleanup_group(group): + """Terminate only a process group created by this verification invocation.""" + if not isinstance(group, int) or group <= 1 or group == os.getpgrp(): + raise ValueError('Refusing an invalid owned process group') + try: + os.killpg(group, signal.SIGTERM) + except ProcessLookupError: + return + end = time.monotonic() + 0.5 + while time.monotonic() < end: + try: + os.killpg(group, 0) + except ProcessLookupError: + return + time.sleep(0.025) + try: + os.killpg(group, signal.SIGKILL) + except ProcessLookupError: + pass + + +def run_owned(command, cwd, environment, log, evidence, driver_path=None, timeout=120): + """Own the wrapper group and the harness's separately owned driver group.""" + evidence = Path(evidence) + evidence.mkdir(parents=True, exist_ok=True) + process = subprocess.Popen(command, cwd=cwd, env=environment, stdout=log, + stderr=subprocess.STDOUT, start_new_session=True) + original_error = None + try: + (evidence / 'owned-wrapper.json').write_text(json.dumps({'processGroup': process.pid, + 'command': command, 'scope': 'Owned verifier wrapper process group only'}, indent=2) + '\n') + return process.wait(timeout=timeout) + except BaseException as error: + original_error = error + raise + finally: + errors = [] + try: + cleanup_group(process.pid) + except (OSError, ValueError, subprocess.SubprocessError) as error: + errors.append('wrapper group cleanup: ' + str(error)) + # Reap the actual child even if a platform denies probing a retired + # group. Otherwise Popen.__del__ warns despite the child having exited. + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=2) + except (OSError, subprocess.SubprocessError) as error: + errors.append('wrapper reap: ' + str(error)) + owned_path = evidence / 'owned-process.json' + # Startup can time out before the driver records its identity. Preserve + # that original error rather than replacing it with missing metadata. + if driver_path is not None and owned_path.exists(): + try: + owned = load_json(owned_path) + if owned.get('driverPath') != str(Path(driver_path).resolve()): + raise ValueError('Owned driver identity differs') + cleanup_group(owned.get('processGroup')) + except (OSError, ValueError, KeyError, TypeError) as error: + errors.append('driver cleanup: ' + str(error)) + if errors: + log.write('\n' + '\n'.join(errors) + '\n') + log.flush() + if original_error is None: + raise ValueError('Owned process cleanup failed: ' + '; '.join(errors)) + + +def run(folder, output, source): + folder, output = Path(folder).resolve(), Path(output).resolve() + actual_context = context(os.environ) + if actual_context['sourceCommit'] != source or output.exists(): + raise ValueError('Fresh matching source/run output required') + manifest, files, archive, manifest_path = candidate(folder, source) + output.mkdir(parents=True) + with tempfile.TemporaryDirectory(prefix='terraphim-clipboard-assets-') as temporary: + assets = Path(temporary) + for name, raw in files.items(): + target = assets / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(raw) + for family in FAMILIES: + evidence = output / family + env = dict(os.environ) + executable = '/Applications/Firefox.app/Contents/MacOS/firefox' if family == 'firefox' else '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + driver_name = 'geckodriver' if family == 'firefox' else 'chromedriver' + driver = shutil.which(driver_name) + if not driver: + driver_dir = os.environ.get('GECKOWEBDRIVER' if family == 'firefox' else 'CHROMEWEBDRIVER', '') + if driver_dir and (Path(driver_dir) / driver_name).is_file(): + driver = str(Path(driver_dir) / driver_name) + if not Path(executable).is_file() or not driver: + raise ValueError('Actual hosted browser/driver absent; no fallback') + env.update(TERRAPHIM_BROWSER_FAMILY=family, TERRAPHIM_BROWSER_EXECUTABLE=executable, + TERRAPHIM_DRIVER_EXECUTABLE=driver, TERRAPHIM_AUTHORITY_ASSETS=str(assets), + TERRAPHIM_STORY_OUT=str(evidence), TERRAPHIM_STORY_SCRIPT='webdriver-clipboard-user-flow.cjs', + TERRAPHIM_ASSET_MANIFEST=str(manifest_path)) + with (output / (family + '.log')).open('x') as log: + subprocess.run(['sw_vers'], stdout=log, stderr=log, check=True) + subprocess.run(['uname', '-a'], stdout=log, stderr=log, check=True) + subprocess.run(['node', '--version'], stdout=log, stderr=log, check=True) + exit_code = run_owned(['node', 'tests/browser/run-synonym-sharing.cjs'], ROOT, env, + log, evidence, driver, timeout=120) + if exit_code: + raise ValueError(f'Actual {family} clipboard failed; retained outputs') + browsers = [validate_browser(output / family, family, manifest, digest(manifest_path)) for family in FAMILIES] + report = {'schema': 1, 'status': 'PASS', 'context': actual_context, + 'archiveSha256': digest(archive), 'manifestSha256': digest(manifest_path), + 'cargoLockSha256': digest(ROOT / 'Cargo.lock'), + 'harnesses': {name: digest(ROOT / name) for name in HARNESSES}, + 'browsers': browsers, 'outputs': proof_inventory(output), + 'runner': {**{name: os.environ.get(name) for name in ('RUNNER_OS', 'RUNNER_ARCH', 'ImageOS', 'ImageVersion')}, + 'nodeVersion': subprocess.check_output(['node', '--version'], text=True).strip()}} + (output / 'clipboard-admission.json').write_text(json.dumps(report, indent=2) + '\n') + verify_admission(output, folder, source, actual_context) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + package = commands.add_parser('package-host') + package.add_argument('--assets', type=Path, required=True) + package.add_argument('--output', type=Path, required=True) + package.add_argument('--source', required=True) + for name in ('run', 'verify'): + command = commands.add_parser(name) + command.add_argument('--candidate', type=Path, required=True) + command.add_argument('--evidence', type=Path, required=True) + command.add_argument('--source', required=True) + args = parser.parse_args() + if args.command == 'package-host': + package_host(args.assets, args.output, args.source) + elif args.command == 'run': + run(args.candidate, args.evidence, args.source) + else: + verify_admission(args.evidence, args.candidate, args.source, context(os.environ)) + + +if __name__ == '__main__': + try: + main() + except (ValueError, KeyError, TypeError, OSError, subprocess.SubprocessError, tarfile.TarError) as error: + raise SystemExit(f'trusted clipboard admission refused: {error}') diff --git a/tests/browser/run-synonym-sharing.cjs b/tests/browser/run-synonym-sharing.cjs index a7db5b5..0638859 100644 --- a/tests/browser/run-synonym-sharing.cjs +++ b/tests/browser/run-synonym-sharing.cjs @@ -34,9 +34,10 @@ const server = http.createServer(async (request,response) => { if (!/^[a-z0-9-]+\.cjs$/.test(script)) throw Error('A browser fixture filename is required'); const child = spawn(process.execPath,[path.join(__dirname,script)],{stdio:'inherit',env:{...process.env,TERRAPHIM_STORY_URL:origin}}); exitCode = await new Promise((resolve,reject)=>{child.once('error',reject);child.once('exit',(code,signal)=>resolve(signal?1:code));}); - const manifest = JSON.parse(await fs.readFile(path.join(assets,'host-assets.json'),'utf8')); + const manifestPath = process.env.TERRAPHIM_ASSET_MANIFEST || path.join(assets,'host-assets.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath,'utf8')); const mismatches = Object.entries(served).filter(([name,digest])=>manifest.assets[name]!==digest); - await fs.writeFile(path.join(output,'runtime-assets.json'),JSON.stringify({status:exitCode===0&&mismatches.length===0?'PASS':'FAIL',exitCode,assets,manifestSha256:hash(await fs.readFile(path.join(assets,'host-assets.json'))),served,mismatches},null,2)+'\n'); + await fs.writeFile(path.join(output,'runtime-assets.json'),JSON.stringify({status:exitCode===0&&mismatches.length===0?'PASS':'FAIL',exitCode,assets,manifestSha256:hash(await fs.readFile(manifestPath)),served,mismatches},null,2)+'\n'); if(mismatches.length)exitCode=1; } finally {await new Promise(resolve=>server.close(resolve));} process.exitCode=exitCode; diff --git a/tests/browser/webdriver-clipboard-user-flow.cjs b/tests/browser/webdriver-clipboard-user-flow.cjs new file mode 100644 index 0000000..191abe6 --- /dev/null +++ b/tests/browser/webdriver-clipboard-user-flow.cjs @@ -0,0 +1,108 @@ +'use strict'; +// Real W3C WebDriver input in the explicitly selected installed browser. No clipboard seeding, +// synthetic keyboard/clipboard events, prior clipboard capture, or restore. +const fs = require('node:fs/promises'); +const path = require('node:path'); +const net = require('node:net'); +const {spawn, spawnSync} = require('node:child_process'); +const {createHash} = require('node:crypto'); +const assert = require('node:assert/strict'); +const output = process.env.TERRAPHIM_STORY_OUT; +const family = process.env.TERRAPHIM_BROWSER_FAMILY; +assert(['chrome','firefox'].includes(family),'Explicit actual browser family required'); +const executable = process.env.TERRAPHIM_BROWSER_EXECUTABLE; +const driverExecutable = process.env.TERRAPHIM_DRIVER_EXECUTABLE; +assert(executable && driverExecutable,'Explicit actual browser and driver paths required'); +const elementKey = 'element-6066-11e4-a52e-4f735466cecf'; +const KEY = {meta:'\uE03D',null:'\uE000',up:'\uE013',down:'\uE015',right:'\uE014',shift:'\uE008',backspace:'\uE003',home:'\uE011'}; +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const pause = ms => new Promise(resolve => setTimeout(resolve,ms)); +const assertPublicClipboard = expected => { + const script = 'ObjC.import("AppKit"); function run(argv) { return JSON.parse(argv[0]).includes(ObjC.unwrap($.NSPasteboard.generalPasteboard.stringForType($.NSPasteboardTypeString))); }'; + const result = spawnSync('/usr/bin/osascript',['-l','JavaScript','-e',script,JSON.stringify(Array.isArray(expected)?expected:[expected])],{encoding:'utf8'}); + assert.equal(result.status,0,'OS public-fixture clipboard equality check failed'); + assert.equal(result.stdout.trim(),'true','Clipboard differs from public fixture; refusing to paste'); +}; +const freePort = () => new Promise((resolve,reject) => { + const server=net.createServer();server.once('error',reject); + server.listen(0,'127.0.0.1',()=>{const port=server.address().port;server.close(error=>error?reject(error):resolve(port));}); +}); +(async () => { + assert.equal(process.platform,'darwin','This fixture verifies the actual macOS clipboard'); + assert(output && process.env.TERRAPHIM_STORY_URL,'Explicit output and frozen asset server URL required'); + await fs.mkdir(output,{recursive:true}); + const browserPath=await fs.realpath(executable),driverPath=await fs.realpath(driverExecutable); + const port=await freePort(),origin=`http://127.0.0.1:${port}`; + const driver=spawn(driverPath,family==='firefox'?['--host','127.0.0.1','--port',String(port)]:['--port='+port,'--allowed-ips=127.0.0.1'],{detached:true,stdio:['ignore','pipe','pipe']}); + await fs.writeFile(path.join(output,'owned-process.json'),JSON.stringify({processGroup:driver.pid,driverPath,browserPath},null,2)+'\n'); + let driverLog='',session,startupError; + driver.on('error',error=>{startupError=error;}); + driver.stdout.on('data',bytes=>{driverLog+=bytes.toString();});driver.stderr.on('data',bytes=>{driverLog+=bytes.toString();}); + const command=async(method,route,body)=>{ + const response=await fetch(origin+route,{method,headers:{'content-type':'application/json'},...(body===undefined?{}:{body:JSON.stringify(body)}),signal:AbortSignal.timeout(30000)}); + const packet=await response.json(); + if(!response.ok || packet.value?.error)throw new Error(`WebDriver ${route}: ${packet.value?.error||response.status}: ${packet.value?.message||''}`); + return packet.value; + }; + const waitFor=async(check,label)=>{const end=Date.now()+5000;do{const result=await check();if(result)return result;await pause(25);}while(Date.now()`/session/${session.sessionId}${tail}`; + const script=(source,args=[])=>command('POST',route('/execute/sync'),{script:source,args}); + const locate=selector=>command('POST',route('/element'),{using:'css selector',value:selector}).then(value=>value[elementKey]); + const keys=(element,text)=>command('POST',route(`/element/${element}/value`),{text}); + const property=(element,name)=>command('GET',route(`/element/${element}/property/${name}`)); + const pasted='p1\np2\np3',rawPasted='p1\r\np2\rp3',copied='p1\np2'; + let failure; + try { + await waitFor(async()=>{if(startupError)throw startupError;try{return await command('GET','/status');}catch{return false;}},'owned WebDriver ready'); + session=await command('POST','/session',{capabilities:{alwaysMatch:{browserName:family,...(family==='firefox'?{'moz:firefoxOptions':{binary:browserPath,args:[]}}:{'goog:chromeOptions':{binary:browserPath,args:['--no-first-run','--no-default-browser-check']}})}}}); + assert.equal(session.capabilities.browserName,family,'Actual driver browser differs from requested family'); + await command('POST',route('/window/rect'),{width:1140,height:900}); + await command('POST',route('/timeouts'),{implicit:0,pageLoad:30000,script:5000}); + await command('POST',route('/url'),{url:process.env.TERRAPHIM_STORY_URL}); + await waitFor(()=>script('return Boolean(window.embeddedEditor && window.terraphimEditor)'), 'actual editor startup complete'); + const source=await waitFor(async()=>{try{return await locate('[role="textbox"][aria-label="Markdown source"]');}catch{return false;}},'actual Markdown source'); + await command('POST',route(`/element/${source}/click`),{}); + await keys(source,KEY.meta+'a'+KEY.null); + await keys(source,KEY.backspace); + await keys(source,'Hello'); + await waitFor(async()=>(await property(source,'innerText'))==='Hello','actual keyboard setup accepted by the editor'); + await script(`window.__publicPasteEvents=[];document.addEventListener('paste',event=>window.__publicPasteEvents.push({isTrusted:event.isTrusted,types:Array.from(event.clipboardData?.types||[]),target:event.target.id||event.target.getAttribute('aria-label')}),{capture:true,passive:true});const raw=arguments[0];const button=document.createElement('button');button.id='public-clipboard-fixture';button.textContent='Copy public clipboard fixture';button.onclick=async()=>{try{if(typeof ClipboardItem!=='function')throw new Error('ClipboardItem unsupported');await navigator.clipboard.write([new ClipboardItem({'text/plain':new Blob([raw],{type:'text/plain'}),'text/html':new Blob(['rich'],{type:'text/html'})})]);button.dataset.copied='true';}catch(error){button.dataset.error=error.name+': '+error.message;}};document.body.append(button);`,[rawPasted]); + const button=await locate('#public-clipboard-fixture'); + await command('POST',route(`/element/${button}/click`),{}); + await waitFor(async()=>{const state=await script("const b=document.querySelector('#public-clipboard-fixture');return {copied:b.dataset.copied,error:b.dataset.error}");if(state.error)throw new Error('Trusted ClipboardItem setup failed: '+state.error);return state.copied==='true';},'trusted public mixed-format copy activation'); + await keys(source,KEY.meta+KEY.up+KEY.null); + assertPublicClipboard([rawPasted,pasted,'p1\rp2\rp3','p1\r\np2\r\np3']); + await keys(source,KEY.meta+'v'+KEY.null); + await waitFor(async()=>(await property(source,'innerText'))===pasted+'Hello','actual public paste output'); + const pasteProof=await script(`const root=arguments[0],selection=window.getSelection();if(!selection||!selection.isCollapsed||!root.contains(selection.anchorNode))throw new Error('Caret outside source after paste');const range=document.createRange();range.selectNodeContents(root);range.setEnd(selection.anchorNode,selection.anchorOffset);return {caret:range.toString().length,richNodes:root.querySelectorAll('b,img').length,events:window.__publicPasteEvents.slice()};`,[{[elementKey]:source}]); + assert.equal(pasteProof.caret,pasted.length,'Native caret differs after normalized plain-text paste'); + assert.equal(pasteProof.richNodes,0,'Rich HTML entered the Markdown source'); + assert(pasteProof.events.some(event=>event.isTrusted&&event.types.includes('text/plain')&&event.types.includes('text/html')),'Actual trusted paste did not carry both plain and HTML formats'); + await fs.writeFile(path.join(output,'paste-proof.json'),JSON.stringify({status:'PASS_PASTE_ONLY',...pasteProof,pastedText:await property(source,'innerText')},null,2)+'\n'); + await keys(source,KEY.meta+KEY.up+KEY.null); + await keys(source,KEY.shift+KEY.down+KEY.null); + await keys(source,KEY.shift+KEY.right+KEY.null); + await keys(source,KEY.shift+KEY.right+KEY.null); + assert.equal(await script('return window.getSelection().toString()'),copied,'Actual selected source differs from public fixture'); + await keys(source,KEY.meta+'c'+KEY.null); + await script("const field=document.createElement('textarea');field.id='clipboard-roundtrip';document.body.append(field);"); + const target=await locate('#clipboard-roundtrip'); + assertPublicClipboard(copied); + await keys(target,KEY.meta+'v'+KEY.null); + const actual=await property(target,'value'); + assert(actual===copied,'Clipboard output differs from selected public fixture'); + const actualPasted=await property(source,'innerText');assert(actualPasted===pasted+'Hello','Public paste source changed unexpectedly'); + await fs.writeFile(path.join(output,'actual-pasted.txt'),actualPasted); + await fs.writeFile(path.join(output,'actual-copied.txt'),actual); + await fs.writeFile(path.join(output,'clipboard-receipt.json'),JSON.stringify({status:'PASS',scope:'Actual installed browser via W3C native clicks/keys and public clipboard fixture; not synthetic ClipboardEvent qualification',platform:process.platform,headless:false,inputTransport:'W3C native clicks/keys',browserFamily:family,browser:session.capabilities.browserVersion,buildID:session.capabilities['moz:buildID'],browserPath,browserSha256:hash(await fs.readFile(browserPath)),driverPath,driverSha256:hash(await fs.readFile(driverPath)),driverVersion:spawnSync(driverPath,['--version'],{encoding:'utf8'}).stdout.trim(),viewport:await script('return [innerWidth,innerHeight]'),priorClipboardRead:false,priorClipboardRestored:false,preflight:'Only OS boolean equality to public text before each native paste',pasteProof,pastedText:actualPasted,copiedText:actual},null,2)+'\n'); + } catch(error) { + failure=error; + await fs.writeFile(path.join(output,'failure.json'),JSON.stringify({status:'FAIL',error:String(error),priorClipboardRead:false,priorClipboardRestored:false},null,2)+'\n'); + } + finally { + if(session){try{await command('DELETE',route(''));}catch(error){driverLog+='\nSession cleanup: '+error.message;}} + if(Number.isInteger(driver.pid)){try{process.kill(-driver.pid,'SIGTERM');}catch(error){if(error.code!=='ESRCH')throw error;}} + await fs.writeFile(path.join(output,'webdriver.log'),driverLog); + } + if(failure)throw failure; +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/tests/fixtures/trusted-clipboard-gecko/actual-copied.txt b/tests/fixtures/trusted-clipboard-gecko/actual-copied.txt new file mode 100644 index 0000000..a4d1c56 --- /dev/null +++ b/tests/fixtures/trusted-clipboard-gecko/actual-copied.txt @@ -0,0 +1,2 @@ +p1 +p2 \ No newline at end of file diff --git a/tests/fixtures/trusted-clipboard-gecko/actual-pasted.txt b/tests/fixtures/trusted-clipboard-gecko/actual-pasted.txt new file mode 100644 index 0000000..4abb4aa --- /dev/null +++ b/tests/fixtures/trusted-clipboard-gecko/actual-pasted.txt @@ -0,0 +1,3 @@ +p1 +p2 +p3Hello \ No newline at end of file diff --git a/tests/fixtures/trusted-clipboard-gecko/clipboard-receipt.json b/tests/fixtures/trusted-clipboard-gecko/clipboard-receipt.json new file mode 100644 index 0000000..27a92a7 --- /dev/null +++ b/tests/fixtures/trusted-clipboard-gecko/clipboard-receipt.json @@ -0,0 +1,34 @@ +{ + "status": "PASS", + "scope": "Actual installed Firefox via W3C GeckoDriver native clicks/keys and public clipboard fixture; not synthetic ClipboardEvent qualification", + "browser": "153.0", + "buildID": "20260715202819", + "firefoxPath": "/Applications/Firefox.app/Contents/MacOS/firefox", + "firefoxSha256": "a5ee1d6124f8b42b7b985c8e766def5eeb2141a443279116db773bcba41945e8", + "driverPath": "/Users/alex/Library/Caches/.wasm-pack/geckodriver-654dd0c3a88b5d6f/geckodriver", + "driverSha256": "5d82307edc8549124bd4e7b6f275e1228e0a530e5abbfb294be3f310486561a4", + "driverVersion": "geckodriver 0.37.1 (300705c65d1b 2026-07-17 09:25 +0000)\n\nThe source code of this program is available from\ntesting/geckodriver in https://hg.mozilla.org/mozilla-central.\n\nThis program is subject to the terms of the Mozilla Public License 2.0.\nYou can obtain a copy of the license at https://mozilla.org/MPL/2.0/.", + "viewport": [ + 1140, + 805 + ], + "priorClipboardRead": false, + "priorClipboardRestored": false, + "preflight": "Only OS boolean equality to public text before each native paste", + "pasteProof": { + "caret": 8, + "events": [ + { + "isTrusted": true, + "target": "Markdown source", + "types": [ + "text/html", + "text/plain" + ] + } + ], + "richNodes": 0 + }, + "pastedText": "p1\np2\np3Hello", + "copiedText": "p1\np2" +} diff --git a/tests/fixtures/trusted-clipboard-gecko/provenance.json b/tests/fixtures/trusted-clipboard-gecko/provenance.json new file mode 100644 index 0000000..eb59ef1 --- /dev/null +++ b/tests/fixtures/trusted-clipboard-gecko/provenance.json @@ -0,0 +1,10 @@ +{ + "scope": "Actual historical recorded Firefox153 outputs for parser regressions only; not current candidate/CI admission", + "evidence": "publication/extension-marketplaces-20261013/execution-20261017/native-authority-verification/firefox-gecko-clipboard-v9-headed-separate-keys", + "sha256": { + "clipboard-receipt.json": "556ece18d73b554a9d814c4e953919b6365963d86a5f1864c7495ffe88547c95", + "actual-pasted.txt": "5593f540d4b1ca35a9ab6ebc1b020b9ddf14937aeabf6f744d9905e1fc7e6d8c", + "actual-copied.txt": "b98d848751a3c1557a6b378fb3cc331ca8fa3ec54f8e6ab124e70ac897050b13", + "runtime-assets.json": "eabb8ccbebe7351a2407a71c9fec530dff4d2dc8bc3a793076d68db4e510a484" + } +} diff --git a/tests/fixtures/trusted-clipboard-gecko/runtime-assets.json b/tests/fixtures/trusted-clipboard-gecko/runtime-assets.json new file mode 100644 index 0000000..6846905 --- /dev/null +++ b/tests/fixtures/trusted-clipboard-gecko/runtime-assets.json @@ -0,0 +1,104 @@ +{ + "status": "PASS", + "exitCode": 0, + "assets": "/private/tmp/terraphim-native-authority-engine-20261017/assets-status-layout-v9", + "manifestSha256": "7bd55b8849edfe53610c06b87bed0d2584a83b0ceb474cf887c3ecd1227a978f", + "served": { + "terraphim-editor.min.js": "1f3ec88eda489856401a196eafaecacf8e354bc08cead7adc4e8a412e193cb38", + "vendor/fontawesome/6.5.2/css/fontawesome.min.css": "137b1cf3cea9a8adb7884343a9a5ddddf4280f59153f74dc782fb7f7bf0d0519", + "vendor/fontawesome/6.5.2/css/solid.min.css": "e65dc5b48fb5f39b142360c57c3a215744c94e56c755c929cc3e88fe12aab4d3", + "vendor/shoelace/2.12.0/cdn/themes/light.css": "7bc8fb762a0f357d171df3126797e53cc0210d798080472fac6f72093acaf708", + "vendor/fontawesome/6.5.2/css/regular.min.css": "6f4f16d58da1c82c0c3a3436e021a3d39b4742f741192c546e73e947eacfd92f", + "vendor/fontawesome/6.5.2/css/brands.min.css": "e10425ad768bc98ff1fb272a0ac8420f9d1ba22f0612c08ff1010c95080ffe7e", + "vendor/shoelace/2.12.0/cdn/utilities/base-path.js": "81b967daca7f541a910bdfabf297dbb0ec3d7fbf4588a9fb8bb8bae1b2ae28e1", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.3Y6SB6QS.js": "f1cea1705e0c978806be53c89f206e9c4be9cac5348d77fae76b8d759ff2a9c9", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.YZETUBD6.js": "78fb7c2ef495d0c74629aa6aee0ae3592bbd4c5103d6d37bc179b7695f2f74b3", + "vendor/shoelace/2.12.0/cdn/utilities/icon-library.js": "dbbfd83ee038e1c5d94f7107e5117fdf835523cbf595bcc87c7ec21a76157b19", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.HAL7R4WT.js": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.ZL53POKZ.js": "1f0c820ee81e8cd783cefcba1b28f075b64014f72932823c134cfbd45aee61c1", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.P7ZG6EMR.js": "11da9e127aa1b30b0969bf5d90bfbeb62e0c6e35c7e0c165d35d7171358b06d5", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.3TFKS637.js": "14f82f632089ec1a68784a8aa36aec1e4a713623749ad880fbbd0119f78da043", + "vendor/shoelace/2.12.0/cdn/components/input/input.js": "084441c5b23b3842cc405fbc5e9fa54676f558b6a3bba5884582c667d8b057c3", + "vendor/shoelace/2.12.0/cdn/components/icon/icon.js": "b2eb2f56da87f532693ad0bce20a9c258d19964a37f4ebc51db6893cf306c636", + "vendor/shoelace/2.12.0/cdn/components/button/button.js": "d2c4baaf63d05b25d7e5d9950ae2a4cecdedff13f484387767dffa937beaf3c9", + "vendor/shoelace/2.12.0/cdn/components/button-group/button-group.js": "d40c5473fa0a54722f6bbb5442339e9ae7fc10b34a8bc7d5bc5ac6138713ed31", + "vendor/shoelace/2.12.0/cdn/components/tooltip/tooltip.js": "7e435dd2b5b7aa59e00e2967b90c8a9db61df5de781d55ec7f6b689007499d5e", + "vendor/shoelace/2.12.0/cdn/components/badge/badge.js": "d8ee2039ef00280a52f17661d088647e328c677e090830381f3749fa343eccd9", + "vendor/shoelace/2.12.0/cdn/components/divider/divider.js": "79e2fd331684c7fc545ce40cfdf9bcc92f3e26ec40566a08703da4c9dde8ad6a", + "vendor/shoelace/2.12.0/cdn/components/dialog/dialog.js": "62df6c895cd87fc0c7731b094695ed80b62a23fb13b895b124fcb9f1d9154ea9", + "vendor/shoelace/2.12.0/cdn/components/split-panel/split-panel.js": "e1780bedaa9c3b17612eae9560c39f448fd03c1f224c953fdc4c6df44ad38ae4", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.6U4GOUZA.js": "68684064e5e3af813bcf7022ce4cfe32f9169cc87de96cd5a6541c51a2904813", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.XQ4KBI5G.js": "6b8ea2bf689231baa347d7c13c65e018f0876cbfc73b2f32d217dd7c5b070899", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.HPQ3PGLN.js": "bffc0c50bd516e7403b794eb9af5e894775bc1a13bd2b5ecc66ddbed1b53f31b", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.2SZPKEN5.js": "a06102043565da0ed6dbff17f50fdacadbb9cae0cf43f1904535101fc8938413", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.2AIXAFCV.js": "243637209fe98b86324a9e50bc4b119786f749087a83f769fa5d35540e9cee8c", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.RIBKWZXP.js": "1f1d6889fc039c40fbd5ef439b79c05fba241aa1e221619f1d6e8ee7042ded6b", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.OQXQSIH6.js": "f72e99e832ab8262e441cd7fc4aef6cae99dfa32e7aa2d663184dcde34e5be0d", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.PNPJ7KDG.js": "0cdc32350809cc64b71ecca105bd7ea95f177ca11a7e65f0a8cdb70202f8bfde", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.NH3SRVOC.js": "76b91e0a54ce01582a347456246f7433e936029ab1f9e80a87ced715864c4fa9", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.DOYC4G7X.js": "cbccc5fca4cded950114904fb9db9471eb581c03eea6242c0c8bc47bbd56ee9a", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.O27EHOBW.js": "1a9aeba601f49a8ddb7507d1be6dac6ada4757edd7a69c9252f3421040854476", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.NYIIDP5N.js": "32772b78bca61210992bc3a593669ce2621d213e72b0b382e60cc09ddd87a539", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.QPSNFEB2.js": "9363a2487d32bff55b2ac56dcb57bfe1c07630c74d35171cd00ab492d16a3a47", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.B3ILE7LN.js": "0fb77ecfeabdd945703ef57a818c219a87889d5409f0287e9e8d3c6be3241d48", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.XAOA43RZ.js": "223cef3722caa4e3fb6efb90d5d4bc67ec30d51683e6df6e163a1c29884f72ce", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.JJNPZ52S.js": "e869c821cc70ece9f594c1cf6cb2d8458ee6de5b33d2f7c4abb75544aa1cb29d", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.TDLFM7SW.js": "a1d916205572ca7dea93311989aac9706f7d66147bb7619ce8104107fc1ab5cc", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.24R5WE5A.js": "d820009490ad7b49fb616d2eac3eab89a9159c7e3d29796e522b6b903fa01be1", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.7I3HGH5I.js": "5ff9e71578f1148596cd76ff6518f4a9350c3a78dcdc7ec83a1f968bb575e113", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.M2U2QT2K.js": "7c7463cbf1286d791c43b09b02ddf87828b07f2d2156454e7c59edf593641f91", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.27ILGUWR.js": "c7a8c01697a327f9a24f20a80892eba4289b275114876996b7468d95a889ad94", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.52OC5JIK.js": "8e6b5b64330fa64475326259787126ac9a69fd19e63e0d872461ad8f42d00f79", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.XXIXGO5R.js": "1ed2486116dcc58a15b579402169f054027b1e7bef36ee258d98014976656e03", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.GL3UMKIL.js": "ebb9e48d84cb4d0a9dffe8c8f6e63bb4c59d0e8555710fb7adf5e34052edb226", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.MOUXRZGR.js": "4b6060f74725dfa6d2e00708eeb68622e80032c29e8a29690cd32c3e7cf43b16", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.43GPPE74.js": "bb0054a347cbb37a414c64621c5151385e305c5a51451b9346ecd636dfde9c1b", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.B4BZKR24.js": "8bdb8b4bf886773489e9b8436aaf39a63de270073002ff62341649d857ea27cd", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.OZPGMRHW.js": "3ba2b5e16c8626a2ed2df8f71152bcc0c95df1b1903093bbfab76dae8082c65a", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.7G6CFCYN.js": "008d0d801c35fdea8829b0a18af142786bac4aa5403d8d2450140f0523a57363", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.52GJFLW5.js": "1c85cb5aba7188eb4a8587a8076781f49a5819be22740985e9b0c31e3fd8e0a3", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.6LJMKLWZ.js": "14a4528f459174f043fc09a0c85870a3a13c622e99db52a52f653cc12cc49de3", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.G2QSU2BF.js": "7828cb2a3a091d166721f1080ea93372f7dda91dc08f2f85878b5f87d58cb52f", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.3GITBIC4.js": "65f42f91445f0b5123f13e695f2973cbeb3a643a10dccb86baa620de0f0a32ef", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.MKSGYDJ7.js": "8ec92f56da768dd469e9c4e1b2d84bf305598eeb487b5172f54f390973c4ebb0", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.BXWBYEBW.js": "fe753faaca5c419872f5816aebc4af4afa97ded7380ec52675cf0908929652f9", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.2X5EERK2.js": "b08f0ec5efc3540bab614d7b5f6c4ccbdbf2ef9b517cf1e3ddf7d204d223aaa2", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.HTWHAKXT.js": "86ba3477e5fff6f7844ac3343b2b2812ca299c7f537d6904855a112111f90ac3", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.XZMYMJVO.js": "b4f349ee02e854ba3dd6888bdbda3248c91da52772de86261d630b22c1f865e4", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.YPBP7XEQ.js": "65de5249054ea64c13504137a773c08a3ecedf2f6c9762bf9ca8e670b762a20a", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.ABG77QVN.js": "eb49eecff1bdad654b2ccd12a899c841708f42b4941d06a3a588c6e24fb2ae5c", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.HQAAUCQS.js": "e0c9a4907115439faf7167b42a66b711858b930809afbe5ef3d04ff733811b98", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.WFFSQ5E5.js": "408c5d6195fcd0e23b8d97797d96289a2cff9b298ca6d37680243b564121d1d0", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.E7LAXGG4.js": "4a6f1bf858fb8735a9fde90e6c7ba7a1aff940d5d1240f3701802cb504208cd2", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.WAIE6M2P.js": "047a6b9e403b0e2c6e7986dedfee63031379cb31ec23a2a08bf5b5ca1fa4cace", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.PCNTRSPK.js": "d50534e993927bedc5b34e6d1561e767e7b8f72fa6357e62bfcb7b954ccc0e21", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.YGMDV7QR.js": "f81b5a698455691fc798d0c778899017d2935cb288692f0ccb837b899bf46243", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.QOTN4UPW.js": "f0a57a7554f90327e24beea70b9d933fe294549d9c3a3352051bb5403bf27bae", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.FZX37GM3.js": "2cbb7c5c4a53cb51a470e8d569ea78933aeaf5d5ed17e5380059fb70aa1fab9c", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.MTYRJPGI.js": "66a0947074c14ad59db09bca8d072469ef928736c3705cc7deeb98e029b5324d", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.RK73WSZS.js": "06df6b1b1b699b5a462620b1aeacad9bef7ca4706d3d0fe146292d7316bc3357", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.FDFOFJDR.js": "fb369159ef9c20b824925cee0f9978ffdb3a6c521d2f07a8fc135291d535a177", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.NEZCQBYJ.js": "89adf06f2beac497d981a3fc37b45698bb90fb349a72938ba4a60ad7cd22d959", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.YCHBWCKL.js": "bd70677c7a805a7f0af02c88b2c217f96ae6391679b04938252238efaaf4710f", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.I5LOVG5T.js": "637e9bc9f021553298c96430c3057a266f3cf4d6746b687a4d5057bd4342acfd", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.RBTTBYTX.js": "6be2e7bdfc5e93af07b81a78bd188f24e22e7b3f0f7597626a924e17bb30601a", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.ESELY2US.js": "c4097a6aa14847ee845e5f0cfe35a3a4291962c383d08d3523e3056878af9720", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.HF7GESMZ.js": "4f1d59a3fb42615764c821c9f633ef85fd2ec5e7701355c82ea22eac263950a4", + "vendor/shoelace/2.12.0/cdn/chunks/chunk.4S3RCQCG.js": "822f662c32036ec0fd981cee20a7726a5d21c160a57dcc5597bddcc639349a98", + "terraphim_editor.js": "20af363a4d9710f77cff2a4cae4d356eb61345b839f55439f6d722a130fe085b", + "terraphim_editor_bg.wasm": "baaa605f5d1fe91373f6810616ccafc496f596136934bd53d538491265ed7bbd", + "vendor/shoelace/2.12.0/cdn/assets/icons/question-circle.svg": "b9408c681dfb0002a76f91c9ac864b7127ae52428aa81eca188985504a61d7e0", + "vendor/shoelace/2.12.0/cdn/assets/icons/type-bold.svg": "8e7adb4a29d9fd60445e2e9d96461ddc1212ef14bfd76f41ffb951fa9968eade", + "vendor/shoelace/2.12.0/cdn/assets/icons/type-italic.svg": "29722685928ea226f897ebb7784132e810e976ffb61e21f625d875467f853153", + "vendor/shoelace/2.12.0/cdn/assets/icons/code.svg": "e66b460f5fd8ca4327d155aa79510cb114d11c50d1b62d25c7520ad1baa02e1b", + "vendor/shoelace/2.12.0/cdn/assets/icons/link.svg": "0634bc395f62df3ff42772c56af5c07bb6eba6ab23a70613ad0f255737e6511f", + "vendor/shoelace/2.12.0/cdn/assets/icons/type-h1.svg": "9a211647fe7df5c57c0b86e6625cd9c661e3df540f646c30465890aece8e1207", + "vendor/shoelace/2.12.0/cdn/assets/icons/type-h3.svg": "c588ec487945d9306c1005196ed7f84bd314633423171559f0aefa6b18c97211", + "vendor/shoelace/2.12.0/cdn/assets/icons/type-underline.svg": "e1038bb0001fe6f8e4e7f250bd497b71df555a093a1970aa99be189a83a3089e", + "vendor/shoelace/2.12.0/cdn/assets/icons/type-h2.svg": "0a5ce744585975247d8784149d3807ad57f1cbf834a34a5ab8e394a8660ba934", + "vendor/fontawesome/6.5.2/webfonts/fa-solid-900.woff2": "ae17c16afbea216707b2203ea1cf9bdb45b9bfe47d0f4ae3258ddbc6294dd02f", + "vendor/fontawesome/6.5.2/webfonts/fa-regular-400.woff2": "c27da6f833431da5aa295c44540bfac0fd8270ba6a3c4346427006d8a7b34b76", + "vendor/fontawesome/6.5.2/webfonts/fa-brands-400.woff2": "232c6f6a7678304f9efaa26f30b1610debc2ba9f4cd636b5e6751c8d73761b92" + }, + "mismatches": [] +} diff --git a/tests/test_editor_release.py b/tests/test_editor_release.py index 0a91bb7..4254a03 100644 --- a/tests/test_editor_release.py +++ b/tests/test_editor_release.py @@ -147,6 +147,10 @@ def test_log_counts_or_missing_annotated_output_cannot_be_forged_by_rehashing(se def test_wrong_publication_repository_is_refused_before_any_api_call(self): with self.assertRaises(ValueError): M.publish('other/repository', self.tag, [], self.source) + def test_correct_repository_cannot_publish_without_fresh_clipboard_admission(self): + with self.assertRaisesRegex(ValueError, 'clipboard'): + M.publish(M.REPOSITORY, self.tag, [], self.source) + def test_checksum_manifest_rejects_malformed_duplicate_names(self): valid = 'a' * 64 + ' editor-candidate.tar.gz\n' self.assertEqual(M.checksums(valid), {'editor-candidate.tar.gz': 'a' * 64}) @@ -221,7 +225,10 @@ def test_only_protected_dependent_job_can_publish_exact_qualified_artifact(self) build, publish = workflow.split(' publish:', 1) self.assertNotIn('contents: write', build) self.assertNotIn('GH_TOKEN:', build) - self.assertIn('needs: candidate', publish) + self.assertIn('needs: [candidate, trusted-clipboard]', publish) + self.assertIn('artifact-ids: ${{ needs.trusted-clipboard.outputs.artifact-id }}', publish) + self.assertIn('--clipboard-evidence', publish) + self.assertIn('runs-on: macos-15', build) self.assertIn('environment: editor-release', publish) self.assertIn('artifact-ids: ${{ needs.candidate.outputs.artifact-id }}', publish) self.assertLess(publish.index('publish-editor-release.py verify'), publish.index('GH_TOKEN:')) diff --git a/tests/test_trusted_clipboard.py b/tests/test_trusted_clipboard.py new file mode 100644 index 0000000..9c491b3 --- /dev/null +++ b/tests/test_trusted_clipboard.py @@ -0,0 +1,163 @@ +"""Real recorded Gecko output parser tests; historical fixtures never admit CI.""" +import copy +import importlib.util +import json +import os +import subprocess +import sys +import time +from pathlib import Path +import shutil +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location('trusted_clipboard', ROOT / 'scripts/qualify-trusted-clipboard.py') +M = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(M) + + +class TrustedClipboardTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.output = Path(self.temp.name) / 'firefox' + shutil.copytree(ROOT / 'tests/fixtures/trusted-clipboard-gecko', self.output) + self.receipt = json.loads((self.output / 'clipboard-receipt.json').read_text()) + self.runtime = json.loads((self.output / 'runtime-assets.json').read_text()) + self.manifest = {'assets': self.runtime['served']} + + def write(self): + (self.output / 'clipboard-receipt.json').write_text(json.dumps(self.receipt)) + (self.output / 'runtime-assets.json').write_text(json.dumps(self.runtime)) + + def test_actual_historical_gecko_outputs_validate_only_intrinsic_proof(self): + value = M.validate_browser(self.output, 'firefox', self.manifest, self.runtime['manifestSha256']) + self.assertEqual(value['pastedSha256'], M.digest(self.output / 'actual-pasted.txt')) + self.assertEqual(value['copiedSha256'], M.digest(self.output / 'actual-copied.txt')) + self.assertEqual(value['browser'], '153.0') + + def test_recorded_gecko_cannot_be_relabelled_chrome(self): + with self.assertRaises(ValueError): + M.validate_browser(self.output, 'chrome', self.manifest, self.runtime['manifestSha256']) + + def test_missing_copy_or_changed_actual_outputs_refused(self): + for name in ('actual-copied.txt', 'actual-pasted.txt'): + old = (self.output / name).read_bytes() + (self.output / name).write_bytes(b'wrong actual output') + with self.subTest(name=name), self.assertRaises(ValueError): + M.validate_browser(self.output, 'firefox', self.manifest, self.runtime['manifestSha256']) + (self.output / name).write_bytes(old) + (self.output / 'actual-copied.txt').unlink() + with self.assertRaises((ValueError, OSError)): + M.validate_browser(self.output, 'firefox', self.manifest, self.runtime['manifestSha256']) + + def test_paste_only_untrusted_missing_html_caret_or_rich_nodes_refused(self): + original = copy.deepcopy(self.receipt) + changes = [lambda x: x.update(status='PASS_PASTE_ONLY'), + lambda x: x['pasteProof'].update(caret=7), + lambda x: x['pasteProof'].update(richNodes=1), + lambda x: x['pasteProof']['events'][0].update(isTrusted=False), + lambda x: x['pasteProof']['events'][0].update(types=['text/plain']), + lambda x: x.update(priorClipboardRead=True), + lambda x: x.update(priorClipboardRestored=True), + lambda x: x.update(firefoxSha256='not a digest')] + for change in changes: + self.receipt = copy.deepcopy(original) + change(self.receipt) + self.write() + with self.subTest(receipt=self.receipt), self.assertRaises(ValueError): + M.validate_browser(self.output, 'firefox', self.manifest, self.runtime['manifestSha256']) + + def test_stale_runtime_or_mutated_served_asset_refused(self): + original = copy.deepcopy(self.runtime) + for change in ({'status': 'FAIL'}, {'exitCode': 1}, {'manifestSha256': '0' * 64}, {'mismatches': ['bad']}): + self.runtime = {**original, **change} + self.write() + with self.subTest(change=change), self.assertRaises(ValueError): + M.validate_browser(self.output, 'firefox', self.manifest, original['manifestSha256']) + self.runtime = original + self.runtime['served']['terraphim_editor_bg.wasm'] = '0' * 64 + self.write() + with self.assertRaises(ValueError): + M.validate_browser(self.output, 'firefox', self.manifest, original['manifestSha256']) + + def test_old_recorded_receipt_cannot_admit_fresh_ci(self): + with self.assertRaises((ValueError, OSError)): + M.verify_admission(self.output, self.output, 'a' * 40, {'runId': '123', 'runAttempt': '1'}) + + def test_real_recorded_output_hashes_cannot_bind_stale_source_run_or_candidate(self): + # Metadata protocol fixture; its recorded bytes do not claim a new run. + source = 'a' * 40 + expected = {'context': {'runId': '12', 'runAttempt': '1', 'sourceCommit': source}, + 'archiveSha256': self.runtime['manifestSha256'], + 'manifestSha256': self.runtime['manifestSha256'], + 'cargoLockSha256': 'b' * 64, + 'harnesses': {'fixture-scope': 'c' * 64}, + 'outputs': {name: M.digest(self.output / name) for name in ('actual-pasted.txt', 'actual-copied.txt')}} + report = {'schema': 1, 'status': 'PASS', **expected} + M.validate_binding(report, expected, source) + for key in expected: + changed = copy.deepcopy(report) + changed[key] = {'stale': True} if isinstance(expected[key], dict) else '0' * 64 + with self.subTest(key=key), self.assertRaises(ValueError): + M.validate_binding(changed, expected, source) + with self.assertRaises(ValueError): + M.validate_binding(report, expected, 'd' * 40) + + def test_run_context_requires_actual_same_repository_workflow_and_attempt(self): + environment = {'GITHUB_ACTIONS': 'true', 'GITHUB_REPOSITORY': M.REPOSITORY, + 'GITHUB_WORKFLOW_REF': M.REPOSITORY + '/.github/workflows/public-root.yml@refs/pull/7/merge', + 'GITHUB_RUN_ID': '123', 'GITHUB_RUN_ATTEMPT': '1', 'GITHUB_SHA': 'a' * 40} + self.assertEqual(M.context(environment)['sourceCommit'], 'a' * 40) + for key, value in [('GITHUB_ACTIONS', 'false'), ('GITHUB_REPOSITORY', 'other/repository'), + ('GITHUB_WORKFLOW_REF', M.REPOSITORY + '/.github/workflows/untrusted.yml@main'), + ('GITHUB_RUN_ID', ''), ('GITHUB_RUN_ATTEMPT', '0')]: + with self.subTest(key=key), self.assertRaises(ValueError): + M.context({**environment, key: value}) + + +class OwnedProcessTests(unittest.TestCase): + """Real subprocess lifecycle checks, not mocked browser/native outcomes.""" + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.folder = Path(self.temp.name) + + def assert_not_running(self, pid): + end = time.monotonic() + 2 + while time.monotonic() < end: + status = subprocess.run(['ps', '-o', 'stat=', '-p', str(pid)], text=True, capture_output=True) + if not status.stdout.strip() or status.stdout.strip().startswith('Z'): + return + time.sleep(0.025) + self.fail('Owned subprocess remains running: ' + str(pid)) + + def test_timeout_cleans_actual_wrapper_and_child_without_driver_metadata(self): + script = ("import subprocess,sys,time,pathlib; " + "child=subprocess.Popen([sys.executable,'-c','import time;time.sleep(60)']); " + "pathlib.Path(sys.argv[1]).write_text(str(child.pid));time.sleep(60)") + child_file = self.folder / 'child.pid' + with (self.folder / 'timeout.log').open('w') as log: + with self.assertRaises(subprocess.TimeoutExpired): + M.run_owned([sys.executable, '-c', script, str(child_file)], ROOT, dict(os.environ), + log, self.folder, '/missing-driver', timeout=1) + self.assert_not_running(int(child_file.read_text())) + wrapper = json.loads((self.folder / 'owned-wrapper.json').read_text()) + self.assert_not_running(wrapper['processGroup']) + self.assertFalse((self.folder / 'owned-process.json').exists()) + + def test_failed_wrapper_cleans_actual_lingering_child_and_preserves_exit_code(self): + script = ("import subprocess,sys,pathlib,os; " + "child=subprocess.Popen([sys.executable,'-c','import time;time.sleep(60)']); " + "pathlib.Path(sys.argv[1]).write_text(str(child.pid));os._exit(7)") + child_file = self.folder / 'child.pid' + with (self.folder / 'failure.log').open('w') as log: + actual = M.run_owned([sys.executable, '-c', script, str(child_file)], ROOT, dict(os.environ), + log, self.folder, timeout=5) + self.assertEqual(actual, 7) + self.assert_not_running(int(child_file.read_text())) + + +if __name__ == '__main__': + unittest.main() From 4bb32eb81c8d50319ffd2355bfb377d1bb080e6e Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sun, 18 Oct 2026 00:32:58 +0100 Subject: [PATCH 09/16] test: retain both browser outcomes and passive clipboard diagnostics --- scripts/qualify-trusted-clipboard.py | 18 +++++++++++++++++- .../browser/webdriver-clipboard-user-flow.cjs | 19 +++++++++++++++++-- tests/test_trusted_clipboard.py | 17 +++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/scripts/qualify-trusted-clipboard.py b/scripts/qualify-trusted-clipboard.py index 25b2d21..77c508d 100644 --- a/scripts/qualify-trusted-clipboard.py +++ b/scripts/qualify-trusted-clipboard.py @@ -262,6 +262,17 @@ def run_owned(command, cwd, environment, log, evidence, driver_path=None, timeou raise ValueError('Owned process cleanup failed: ' + '; '.join(errors)) +def observe_families(observe): + """Collect both real family outcomes; any failure still denies admission.""" + failures = [] + for family in FAMILIES: + try: + observe(family) + except (ValueError, OSError, subprocess.SubprocessError) as error: + failures.append({'family': family, 'error': str(error)}) + return failures + + def run(folder, output, source): folder, output = Path(folder).resolve(), Path(output).resolve() actual_context = context(os.environ) @@ -275,7 +286,7 @@ def run(folder, output, source): target = assets / name target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(raw) - for family in FAMILIES: + def observe(family): evidence = output / family env = dict(os.environ) executable = '/Applications/Firefox.app/Contents/MacOS/firefox' if family == 'firefox' else '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' @@ -299,6 +310,11 @@ def run(folder, output, source): log, evidence, driver, timeout=120) if exit_code: raise ValueError(f'Actual {family} clipboard failed; retained outputs') + validate_browser(evidence, family, manifest, digest(manifest_path)) + failures = observe_families(observe) + if failures: + (output / 'family-failures.json').write_text(json.dumps({'status': 'FAIL', 'failures': failures}, indent=2) + '\n') + raise ValueError('Actual clipboard qualification failed: ' + ', '.join(item['family'] for item in failures)) browsers = [validate_browser(output / family, family, manifest, digest(manifest_path)) for family in FAMILIES] report = {'schema': 1, 'status': 'PASS', 'context': actual_context, 'archiveSha256': digest(archive), 'manifestSha256': digest(manifest_path), diff --git a/tests/browser/webdriver-clipboard-user-flow.cjs b/tests/browser/webdriver-clipboard-user-flow.cjs index 191abe6..fe5a48d 100644 --- a/tests/browser/webdriver-clipboard-user-flow.cjs +++ b/tests/browser/webdriver-clipboard-user-flow.cjs @@ -1,6 +1,7 @@ 'use strict'; -// Real W3C WebDriver input in the explicitly selected installed browser. No clipboard seeding, -// synthetic keyboard/clipboard events, prior clipboard capture, or restore. +// Real W3C input in the explicitly selected installed browser. Public payload +// is written only by an actual trusted fixture click. No privileged clipboard +// injection, synthetic key/clipboard events, prior clipboard capture or restore. const fs = require('node:fs/promises'); const path = require('node:path'); const net = require('node:net'); @@ -50,12 +51,14 @@ const freePort = () => new Promise((resolve,reject) => { const locate=selector=>command('POST',route('/element'),{using:'css selector',value:selector}).then(value=>value[elementKey]); const keys=(element,text)=>command('POST',route(`/element/${element}/value`),{text}); const property=(element,name)=>command('GET',route(`/element/${element}/property/${name}`)); + const publicPageSnapshot=()=>script(`const s=window.terraphimEditor?.surface,root=document.querySelector('[role="textbox"][aria-label="Markdown source"]'),active=document.activeElement,selection=getSelection(),offset=(node,n)=>{if(!root||!node||!root.contains(node))return null;const range=document.createRange();range.selectNodeContents(root);range.setEnd(node,n);return range.toString().length;};return {sourceText:root?.innerText??null,modelText:s?.getText()??null,sourceFocused:active===root,documentFocused:document.hasFocus(),visibilityState:document.visibilityState,nativeSelection:selection?{anchorNode:selection.anchorNode?.nodeName,anchorOffset:selection.anchorOffset,focusNode:selection.focusNode?.nodeName,focusOffset:selection.focusOffset,isCollapsed:selection.isCollapsed,anchorInSource:root?.contains(selection.anchorNode),focusInSource:root?.contains(selection.focusNode)}:null,activeElement:active?{tag:active.tagName,id:active.id,label:active.getAttribute('aria-label')}:null,selectionDomUtf16:selection?{anchor:offset(selection.anchorNode,selection.anchorOffset),focus:offset(selection.focusNode,selection.focusOffset)}:null,selectedText:selection?.toString()??null,pasteEvents:window.__publicPasteEvents??[],richNodes:root?.querySelectorAll('b,img').length??null};`); const pasted='p1\np2\np3',rawPasted='p1\r\np2\rp3',copied='p1\np2'; let failure; try { await waitFor(async()=>{if(startupError)throw startupError;try{return await command('GET','/status');}catch{return false;}},'owned WebDriver ready'); session=await command('POST','/session',{capabilities:{alwaysMatch:{browserName:family,...(family==='firefox'?{'moz:firefoxOptions':{binary:browserPath,args:[]}}:{'goog:chromeOptions':{binary:browserPath,args:['--no-first-run','--no-default-browser-check']}})}}}); assert.equal(session.capabilities.browserName,family,'Actual driver browser differs from requested family'); + await fs.writeFile(path.join(output,'browser-identity.json'),JSON.stringify({browserFamily:family,platform:process.platform,headless:false,browser:session.capabilities.browserVersion,buildID:session.capabilities['moz:buildID'],platformName:session.capabilities.platformName,browserPath,browserSha256:hash(await fs.readFile(browserPath)),driverPath,driverSha256:hash(await fs.readFile(driverPath)),driverVersion:spawnSync(driverPath,['--version'],{encoding:'utf8'}).stdout.trim(),nodeVersion:process.version},null,2)+'\n'); await command('POST',route('/window/rect'),{width:1140,height:900}); await command('POST',route('/timeouts'),{implicit:0,pageLoad:30000,script:5000}); await command('POST',route('/url'),{url:process.env.TERRAPHIM_STORY_URL}); @@ -72,6 +75,7 @@ const freePort = () => new Promise((resolve,reject) => { await waitFor(async()=>{const state=await script("const b=document.querySelector('#public-clipboard-fixture');return {copied:b.dataset.copied,error:b.dataset.error}");if(state.error)throw new Error('Trusted ClipboardItem setup failed: '+state.error);return state.copied==='true';},'trusted public mixed-format copy activation'); await keys(source,KEY.meta+KEY.up+KEY.null); assertPublicClipboard([rawPasted,pasted,'p1\rp2\rp3','p1\r\np2\r\np3']); + await fs.writeFile(path.join(output,'before-native-paste-public-page.json'),JSON.stringify(await publicPageSnapshot(),null,2)+'\n'); await keys(source,KEY.meta+'v'+KEY.null); await waitFor(async()=>(await property(source,'innerText'))===pasted+'Hello','actual public paste output'); const pasteProof=await script(`const root=arguments[0],selection=window.getSelection();if(!selection||!selection.isCollapsed||!root.contains(selection.anchorNode))throw new Error('Caret outside source after paste');const range=document.createRange();range.selectNodeContents(root);range.setEnd(selection.anchorNode,selection.anchorOffset);return {caret:range.toString().length,richNodes:root.querySelectorAll('b,img').length,events:window.__publicPasteEvents.slice()};`,[{[elementKey]:source}]); @@ -98,6 +102,17 @@ const freePort = () => new Promise((resolve,reject) => { } catch(error) { failure=error; await fs.writeFile(path.join(output,'failure.json'),JSON.stringify({status:'FAIL',error:String(error),priorClipboardRead:false,priorClipboardRestored:false},null,2)+'\n'); + // Read only this fresh public-fixture page after failure; no clipboard + // contents are read, no state/selection/handler is changed, no retry occurs. + if(session){ + try{ + const observed=await publicPageSnapshot(); + await fs.writeFile(path.join(output,'failure-public-page.json'),JSON.stringify(observed,null,2)+'\n'); + }catch(diagnostic){await fs.writeFile(path.join(output,'failure-readback-error.txt'),String(diagnostic)+'\n');} + try{const png=await command('GET',route('/screenshot'));await fs.writeFile(path.join(output,'failure-public-page.png'),Buffer.from(png,'base64'));} + catch(diagnostic){await fs.writeFile(path.join(output,'failure-screenshot-error.txt'),String(diagnostic)+'\n');} + } + } finally { if(session){try{await command('DELETE',route(''));}catch(error){driverLog+='\nSession cleanup: '+error.message;}} diff --git a/tests/test_trusted_clipboard.py b/tests/test_trusted_clipboard.py index 9c491b3..8957644 100644 --- a/tests/test_trusted_clipboard.py +++ b/tests/test_trusted_clipboard.py @@ -158,6 +158,23 @@ def test_failed_wrapper_cleans_actual_lingering_child_and_preserves_exit_code(se self.assertEqual(actual, 7) self.assert_not_running(int(child_file.read_text())) + def test_both_real_failed_processes_are_observed_without_admission(self): + attempted = [] + def observe(family): + attempted.append(family) + folder = self.folder / family + folder.mkdir() + with (folder / 'actual.log').open('w') as log: + code = M.run_owned([sys.executable, '-c', 'import sys;sys.exit(7)'], ROOT, + dict(os.environ), log, folder, timeout=5) + if code: + raise ValueError('Real child exit ' + str(code)) + failures = M.observe_families(observe) + self.assertEqual(attempted, ['chrome', 'firefox']) + self.assertEqual([row['family'] for row in failures], attempted) + self.assertTrue(all('Real child exit 7' in row['error'] for row in failures)) + self.assertFalse((self.folder / 'clipboard-admission.json').exists()) + if __name__ == '__main__': unittest.main() From 1a69ae32d5042fa770590cbc1d55d4ce9f0f93e5 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sun, 18 Oct 2026 00:52:30 +0100 Subject: [PATCH 10/16] test: verify native caret before clipboard actions --- .../browser/webdriver-clipboard-user-flow.cjs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/browser/webdriver-clipboard-user-flow.cjs b/tests/browser/webdriver-clipboard-user-flow.cjs index fe5a48d..0dcfe15 100644 --- a/tests/browser/webdriver-clipboard-user-flow.cjs +++ b/tests/browser/webdriver-clipboard-user-flow.cjs @@ -15,7 +15,7 @@ const executable = process.env.TERRAPHIM_BROWSER_EXECUTABLE; const driverExecutable = process.env.TERRAPHIM_DRIVER_EXECUTABLE; assert(executable && driverExecutable,'Explicit actual browser and driver paths required'); const elementKey = 'element-6066-11e4-a52e-4f735466cecf'; -const KEY = {meta:'\uE03D',null:'\uE000',up:'\uE013',down:'\uE015',right:'\uE014',shift:'\uE008',backspace:'\uE003',home:'\uE011'}; +const KEY = {meta:'\uE03D',null:'\uE000',up:'\uE013',left:'\uE012',down:'\uE015',right:'\uE014',shift:'\uE008',backspace:'\uE003',home:'\uE011'}; const hash = bytes => createHash('sha256').update(bytes).digest('hex'); const pause = ms => new Promise(resolve => setTimeout(resolve,ms)); const assertPublicClipboard = expected => { @@ -73,9 +73,12 @@ const freePort = () => new Promise((resolve,reject) => { const button=await locate('#public-clipboard-fixture'); await command('POST',route(`/element/${button}/click`),{}); await waitFor(async()=>{const state=await script("const b=document.querySelector('#public-clipboard-fixture');return {copied:b.dataset.copied,error:b.dataset.error}");if(state.error)throw new Error('Trusted ClipboardItem setup failed: '+state.error);return state.copied==='true';},'trusted public mixed-format copy activation'); - await keys(source,KEY.meta+KEY.up+KEY.null); + await keys(source,KEY.left.repeat(5)); assertPublicClipboard([rawPasted,pasted,'p1\rp2\rp3','p1\r\np2\r\np3']); - await fs.writeFile(path.join(output,'before-native-paste-public-page.json'),JSON.stringify(await publicPageSnapshot(),null,2)+'\n'); + const beforePaste=await publicPageSnapshot(); + await fs.writeFile(path.join(output,'before-native-paste-public-page.json'),JSON.stringify(beforePaste,null,2)+'\n'); + assert.equal(beforePaste.sourceFocused,true,'Actual source must retain native input focus'); + assert.deepEqual(beforePaste.selectionDomUtf16,{anchor:0,focus:0},'Actual native ArrowLeft must position paste at source start'); await keys(source,KEY.meta+'v'+KEY.null); await waitFor(async()=>(await property(source,'innerText'))===pasted+'Hello','actual public paste output'); const pasteProof=await script(`const root=arguments[0],selection=window.getSelection();if(!selection||!selection.isCollapsed||!root.contains(selection.anchorNode))throw new Error('Caret outside source after paste');const range=document.createRange();range.selectNodeContents(root);range.setEnd(selection.anchorNode,selection.anchorOffset);return {caret:range.toString().length,richNodes:root.querySelectorAll('b,img').length,events:window.__publicPasteEvents.slice()};`,[{[elementKey]:source}]); @@ -83,10 +86,12 @@ const freePort = () => new Promise((resolve,reject) => { assert.equal(pasteProof.richNodes,0,'Rich HTML entered the Markdown source'); assert(pasteProof.events.some(event=>event.isTrusted&&event.types.includes('text/plain')&&event.types.includes('text/html')),'Actual trusted paste did not carry both plain and HTML formats'); await fs.writeFile(path.join(output,'paste-proof.json'),JSON.stringify({status:'PASS_PASTE_ONLY',...pasteProof,pastedText:await property(source,'innerText')},null,2)+'\n'); - await keys(source,KEY.meta+KEY.up+KEY.null); - await keys(source,KEY.shift+KEY.down+KEY.null); - await keys(source,KEY.shift+KEY.right+KEY.null); - await keys(source,KEY.shift+KEY.right+KEY.null); + await keys(source,KEY.left.repeat(pasted.length)); + const beforeCopy=await publicPageSnapshot(); + await fs.writeFile(path.join(output,'before-native-copy-public-page.json'),JSON.stringify(beforeCopy,null,2)+'\n'); + assert.equal(beforeCopy.sourceFocused,true,'Actual source must retain native focus before selection'); + assert.deepEqual(beforeCopy.selectionDomUtf16,{anchor:0,focus:0},'Actual native ArrowLeft must position selection at source start'); + await keys(source,KEY.shift+KEY.right.repeat(copied.length)+KEY.null); assert.equal(await script('return window.getSelection().toString()'),copied,'Actual selected source differs from public fixture'); await keys(source,KEY.meta+'c'+KEY.null); await script("const field=document.createElement('textarea');field.id='clipboard-roundtrip';document.body.append(field);"); From 2e16646854b1f7bb1c85d5ffdddaec5106154dc6 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sun, 18 Oct 2026 01:20:07 +0100 Subject: [PATCH 11/16] test: use trusted clipboard gate for positive paste coverage --- tests/web_surface.rs | 56 ++++++++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/tests/web_surface.rs b/tests/web_surface.rs index bc2e6c6..82ad07e 100644 --- a/tests/web_surface.rs +++ b/tests/web_surface.rs @@ -144,7 +144,7 @@ fn test_browser_dom_shapes_read_back_as_plain_text() { } #[wasm_bindgen_test] -fn test_native_typing_newlines_and_paste() { +fn test_native_typing_and_newlines() { let _document = fresh_full_editor(); let result = js_string( r##"(() => { @@ -170,22 +170,44 @@ fn test_native_typing_newlines_and_paste() { document.execCommand('insertText', false, 'next'); if (s.getText() !== 'Hello world\nnext') out.push('typed after enter ' + JSON.stringify(s.getText())); if (!teTest.canonical()) out.push('not canonical after enter'); - // Paste strips formatting and normalises line endings. - const dt = new DataTransfer(); - dt.setData('text/html', 'rich'); - dt.setData('text/plain', 'p1\r\np2\rp3'); - s.setSelectionOffsets(0); - const paste = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); - s.root.dispatchEvent(paste); - if (!paste.defaultPrevented) out.push('paste not cancelled'); - if (s.getText() !== 'p1\np2\np3Hello world\nnext') out.push('paste text ' + JSON.stringify(s.getText())); - if (s.root.querySelector('b, img')) out.push('rich content inserted'); - if (s.getSelectionOffsets().start !== 8) out.push('caret after paste'); - // Copy produces plain text with newlines. - const copyData = new DataTransfer(); - s.setSelectionOffsets(0, 5); - s.root.dispatchEvent(new ClipboardEvent('copy', { clipboardData: copyData, bubbles: true, cancelable: true })); - if (copyData.getData('text/plain') !== 'p1\np2') out.push('copy ' + JSON.stringify(copyData.getData('text/plain'))); + // Actual rich/plain paste, CRLF normalization, caret and selected + // plain-text copy are mandatory in trusted-clipboard CI for both + // Chrome and Firefox (tests/browser/webdriver-clipboard-user-flow.cjs). + return out.join('; '); + })()"##, + ); + assert_eq!(result, ""); +} + +#[wasm_bindgen_test] +fn test_empty_clipboard_paste_preserves_body_history_selection_and_dirty_state() { + let _document = fresh_full_editor(); + let result = js_string( + r##"(() => { + const ed = window.__teEditor, s = ed.surface, p = ed.persistence; + const out = []; + const check = (label) => { + s.focus(); + s.setSelectionOffsets(0, 5); + const before = { body: s.getText(), history: JSON.stringify(s.history), + index: s.historyIndex, selection: JSON.stringify(s.getSelectionOffsets()), dirty: p.dirty }; + // A browser-constructed empty ClipboardEvent; no supplied DataTransfer, clipboard + // payload, permission shim or replacement event/handler. + const paste = new ClipboardEvent('paste', { bubbles: true, cancelable: true }); + if (paste.clipboardData && paste.clipboardData.getData('text/plain') !== '') out.push(label + ' unexpected payload'); + s.root.dispatchEvent(paste); + if (!paste.defaultPrevented) out.push(label + ' paste not cancelled'); + if (s.getText() !== before.body) out.push(label + ' body changed'); + if (JSON.stringify(s.history) !== before.history || s.historyIndex !== before.index) out.push(label + ' history changed'); + if (JSON.stringify(s.getSelectionOffsets()) !== before.selection) out.push(label + ' selection changed'); + if (p.dirty !== before.dirty) out.push(label + ' dirty state changed'); + if (!teTest.canonical()) out.push(label + ' noncanonical body'); + }; + if (p.dirty) out.push('fresh document unexpectedly dirty'); + check('clean'); + s.replaceRange(0, 0, 'Public note. '); + if (!p.dirty) out.push('actual edit did not establish dirty state'); + check('dirty'); return out.join('; '); })()"##, ); From 8b4bbf59b4aefedd39d7c7c0053e4ff50525ddfc Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sun, 18 Oct 2026 09:41:42 +0100 Subject: [PATCH 12/16] fix: keep editor chrome history shortcuts in the guarded document --- public/js/chrome.js | 8 +++ tests/web_chrome.rs | 119 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/public/js/chrome.js b/public/js/chrome.js index ebbe96a..c2dfee4 100644 --- a/public/js/chrome.js +++ b/public/js/chrome.js @@ -278,6 +278,14 @@ class WriteOnChrome { root.append(counter, corners, this.buildShortcutReference(listen)); this.mount.appendChild(root); + // Escape can return focus to a chrome button. Keep its history keys in + // this editor instead of allowing a host's normal Markdown Undo route. + listen(root, 'keydown', (e) => { + if (e.defaultPrevented || !(e.target instanceof Element) || !e.target.closest('button')) return; + this.editor.surface?.onKeyDown(e); + if (e.defaultPrevented) e.stopPropagation(); + }); + // The tools toggle, from anywhere inside the editor (the surface, a // toolbar button, the preview). Exactly Ctrl+Shift+L: Shift is matched // as teConfigShortcutMatches does, so Ctrl+L stays the link shortcut. diff --git a/tests/web_chrome.rs b/tests/web_chrome.rs index e4e6c26..d9998c3 100644 --- a/tests/web_chrome.rs +++ b/tests/web_chrome.rs @@ -625,3 +625,122 @@ fn test_plain_mode_has_the_video_palette_and_column() { ); assert_eq!(result, ""); } + +/// Escape keeps its chrome-button focus contract, while history shortcuts +/// must still update the real Rust document and authored synonym registry. +#[wasm_bindgen_test] +async fn test_chrome_button_history_after_panel_escape() { + let _document = fresh_full_editor(); + sleep(0).await; + install_alt_panel_helpers(); + let setup = js_string( + r##"(() => { + const ed = window.__teEditor, s = ed.surface; + ed.openDocument('Risk needs mitigation.\n', 'chrome-history-owned.md'); + ed.chrome.setMode('write-on'); + s.focus(); s.setSelectionOffsets(0, 4); + const control = teTest.control('alternatives'); + control.focus(); control.click(); + const panel = ed.altPanel; + if (!panel.isOpen()) return 'panel not open'; + const input = teAlt.newInput(); + input.focus(); input.value = 'Concern'; + if (!teTest.key(input, 'Enter')) return 'alternative Enter not committed'; + const id = panel.target.spanId; + if (!id || s.getText() !== 'Concern needs mitigation.\n') return 'real alternative not active'; + teTest.key(document.activeElement, 'Escape'); + if (panel.isOpen() || document.activeElement !== control) return 'Escape did not retain chrome focus'; + window.__teChromeHistory = {id, source: ed.saveDocument(), history: JSON.stringify(s.history), index: s.historyIndex}; + return ''; + })()"##, + ); + assert_eq!(setup, ""); + let id = js_string("window.__teChromeHistory.id"); + let synonyms: Vec = + serde_json::from_str(&terraphim_editor::document_synonyms(&id, "Concern")).unwrap(); + assert_eq!(synonyms, ["risk", "concern"]); + + let undone = js_string( + r##"(() => { + const ed = window.__teEditor, s = ed.surface; + const control = teTest.control('alternatives'); + if (document.activeElement !== control) return 'chrome lost focus before Undo'; + if (!teTest.key(control, 'z', {ctrlKey: true})) return 'chrome Ctrl+Z was not consumed'; + if (s.getText() !== 'Risk needs mitigation.\n') return 'chrome Undo did not restore Risk'; + if (ed.documentApi().document_body() !== s.getText()) return 'Undo Rust body diverged'; + if (s.historyIndex !== window.__teChromeHistory.index - 1) return 'Undo history index'; + return ''; + })()"##, + ); + assert_eq!(undone, ""); + assert_eq!(document_body(), "Risk needs mitigation.\n"); + let removed: Vec = + serde_json::from_str(&terraphim_editor::document_synonyms(&id, "Concern")).unwrap(); + assert!(removed.is_empty()); + + let redone = js_string( + r##"(() => { + const ed = window.__teEditor, s = ed.surface, control = teTest.control('alternatives'); + const checkRedo = () => { + if (s.getText() !== 'Concern needs mitigation.\n') return 'Redo body'; + if (ed.saveDocument() !== window.__teChromeHistory.source) return 'Redo saved source'; + if (JSON.stringify(s.history) !== window.__teChromeHistory.history || s.historyIndex !== window.__teChromeHistory.index) return 'Redo history changed'; + return ''; + }; + control.focus(); + if (!teTest.key(control, 'z', {metaKey: true, shiftKey: true})) return 'chrome Meta+Shift+Z not consumed'; + let failure = checkRedo(); if (failure) return failure; + control.focus(); + if (!teTest.key(control, 'z', {ctrlKey: true})) return 'second chrome Undo not consumed'; + control.focus(); + if (!teTest.key(control, 'y', {ctrlKey: true})) return 'chrome Ctrl+Y not consumed'; + return checkRedo(); + })()"##, + ); + assert_eq!(redone, ""); + let restored: Vec = + serde_json::from_str(&terraphim_editor::document_synonyms(&id, "Concern")).unwrap(); + assert_eq!(restored, ["risk", "concern"]); + + let boundaries = js_string( + r##"(() => { + const ed = window.__teEditor, s = ed.surface, chrome = ed.chrome; + const control = teTest.control('alternatives'), before = ed.saveDocument(); + const outside = document.createElement('button'); + const input = document.createElement('input'); + ed.root.append(outside); chrome.root.append(input); + const seen = []; + const observe = e => seen.push(e.key); + ed.root.addEventListener('keydown', observe); + try { + outside.focus(); + if (teTest.key(outside, 'z', {ctrlKey: true})) return 'outside host button intercepted'; + input.focus(); input.value = 'Editable chrome input'; + if (teTest.key(input, 'z', {ctrlKey: true})) return 'editable input Undo intercepted'; + control.focus(); + if (teTest.key(control, 'a', {ctrlKey: true})) return 'unhandled chrome shortcut consumed'; + if (teTest.key(control, 'z', {ctrlKey: true, altKey: true})) return 'Alt-modified shortcut consumed'; + if (teTest.key(control, 'z', {ctrlKey: true, isComposing: true})) return 'composition shortcut consumed'; + if (ed.saveDocument() !== before) return 'unhandled key changed source'; + if (seen.length !== 5) return 'unhandled events did not bubble'; + const count = seen.length; + if (!teTest.key(control, 'z', {ctrlKey: true})) return 'owned chrome Undo not consumed'; + if (seen.length !== count) return 'consumed history key escaped to host'; + control.focus(); teTest.key(control, 'y', {ctrlKey: true}); + if (ed.saveDocument() !== before) return 'boundary replay changed source'; + } finally { + ed.root.removeEventListener('keydown', observe); + outside.remove(); input.remove(); + } + const oldRoot = chrome.root; + ed.destroy(); + document.body.append(oldRoot); + try { + control.focus(); + if (teTest.key(control, 'z', {ctrlKey: true})) return 'destroy retained chrome history listener'; + } finally { oldRoot.remove(); delete window.__teChromeHistory; } + return ''; + })()"##, + ); + assert_eq!(boundaries, ""); +} From a9ccb4b21831f77a191a0f937b37aaf01bb5a2e4 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sun, 18 Oct 2026 11:00:30 +0100 Subject: [PATCH 13/16] fix: align alternative tabs in embedded editors and settle benchmark setup --- public/css/alternatives-panel.css | 2 +- public/js/alternatives-panel.js | 79 ++++++++++++++++++++ tests/support/bench.rs | 26 ++++++- tests/web_alt_panel_tabs.rs | 120 ++++++++++++++++++++++++++++++ tests/web_bench.rs | 2 +- tests/web_bench_logged.rs | 2 +- 6 files changed, 224 insertions(+), 7 deletions(-) diff --git a/public/css/alternatives-panel.css b/public/css/alternatives-panel.css index fbbd37a..779a26f 100644 --- a/public/css/alternatives-panel.css +++ b/public/css/alternatives-panel.css @@ -44,7 +44,7 @@ the document's first line (the page's top margin), and the key hint at the foot, clear of the bottom-left corner controls (1.25rem offset plus a 1.75rem control, and air), as on the Overflow panel. */ - padding: max(1.5rem, calc(var(--te-page-margin-top) - 2.5rem)) 1.25rem 4.25rem; + padding: var(--te-alt-panel-padding-top, max(1.5rem, calc(var(--te-page-margin-top) - 2.5rem))) 1.25rem 4.25rem; container-type: inline-size; background-color: var(--te-color-panel); border-right: 1px solid var(--te-color-panel-border); diff --git a/public/js/alternatives-panel.js b/public/js/alternatives-panel.js index 0600ff7..b6c8cf1 100644 --- a/public/js/alternatives-panel.js +++ b/public/js/alternatives-panel.js @@ -172,6 +172,9 @@ class TeAlternativesPanel { this.returnFocus = null; this.frame = 0; this.retargetFrame = 0; + this.positionFrame = 0; + this.positionObserver = null; + this.positionAbortController = null; // The new alternative being typed in the add line (issue #106), or null: // { spanId, index, base } where base is the surface history entry from // before it began (so its steps can be folded into one, or undone). @@ -301,6 +304,78 @@ class TeAlternativesPanel { this.host = host; } + /** Position only: host reflow must never rebuild a pending input. */ + startPositioning() { + if (this.positionAbortController || !this.opened || this.destroyed) return; + const controller = new AbortController(); + this.positionAbortController = controller; + const signal = controller.signal; + const schedule = () => this.schedulePosition(); + window.addEventListener('resize', schedule, { signal, passive: true }); + document.addEventListener('scroll', schedule, { signal, capture: true, passive: true }); + if (window.visualViewport) window.visualViewport.addEventListener('resize', schedule, { signal, passive: true }); + if (typeof ResizeObserver === 'function') { + this.positionObserver = new ResizeObserver(schedule); + for (const node of new Set([this.surface.root, this.host, this.host.parentElement, document.body])) { + if (node) this.positionObserver.observe(node); + } + } + if (document.fonts) { + document.fonts.addEventListener('loadingdone', schedule, { signal }); + document.fonts.ready.then(schedule); + } + } + + stopPositioning() { + if (this.positionFrame) cancelAnimationFrame(this.positionFrame); + this.positionFrame = 0; + if (this.positionObserver) this.positionObserver.disconnect(); + this.positionObserver = null; + if (this.positionAbortController) this.positionAbortController.abort(); + this.positionAbortController = null; + } + + schedulePosition() { + if (!this.opened || this.destroyed || this.positionFrame) return; + this.positionFrame = requestAnimationFrame(() => { + this.positionFrame = 0; + this.positionTabs(); + }); + } + + /** Read the actual first line without Surface selection/DOM repair APIs. */ + positionTabs() { + if (!this.opened || this.destroyed || !this.root || !this.surface.root.isConnected) return; + const source = this.surface.root; + const walker = document.createTreeWalker(source, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT); + let firstTop = null; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const range = document.createRange(); + if (node.nodeType === Node.TEXT_NODE && node.length) { + range.setStart(node, 0); + range.setEnd(node, 1); + } else if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') { + // Native contenteditable can represent the first empty line as a + // block containing BR, before any text node. Do not skip that line. + range.selectNode(node); + } else if (node.nodeType === Node.ELEMENT_NODE && /^(DIV|P)$/.test(node.tagName) + && !node.textContent && !node.querySelector('br')) { + const rect = node.getBoundingClientRect(); + if (rect.height > 0) { firstTop = rect.top + (parseFloat(getComputedStyle(node).paddingTop) || 0); break; } + continue; + } else continue; + const rect = Array.from(range.getClientRects()).find(r => r.height > 0); + if (rect) { firstTop = rect.top; break; } + } + if (firstTop === null) firstTop = source.getBoundingClientRect().top + (parseFloat(getComputedStyle(source).paddingTop) || 0); + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + const top = Math.max(1.5 * rem, firstTop - this.root.getBoundingClientRect().top - 2.5 * rem); + const value = `${top}px`; + if (this.root.style.getPropertyValue('--te-alt-panel-padding-top') !== value) { + this.root.style.setProperty('--te-alt-panel-padding-top', value); + } + } + chromeControl() { const chrome = this.editor.chrome; return chrome && chrome.root ? chrome.root.querySelector('[data-control="alternatives"]') : null; @@ -394,6 +469,7 @@ class TeAlternativesPanel { control.setAttribute('aria-expanded', 'true'); control.setAttribute('aria-controls', this.root.id); } + this.startPositioning(); this.render(); if (options.focus !== false) this.focusLine(this.activeLineIndex()); return true; @@ -408,6 +484,7 @@ class TeAlternativesPanel { this.finishLive(); this.commitFocused(); this.opened = false; + this.stopPositioning(); this.root.hidden = true; this.host.classList.remove('te-alt-open'); const control = this.chromeControl(); @@ -664,6 +741,7 @@ class TeAlternativesPanel { } render() { + this.positionTabs(); this.renderCount += 1; for (const kind of TE_ALT_KINDS) { const on = kind === this.tab; @@ -1496,6 +1574,7 @@ class TeAlternativesPanel { } this.destroyed = true; this.opened = false; + this.stopPositioning(); if (this.frame) cancelAnimationFrame(this.frame); if (this.retargetFrame) cancelAnimationFrame(this.retargetFrame); this.frame = 0; diff --git a/tests/support/bench.rs b/tests/support/bench.rs index f207712..3f906c1 100644 --- a/tests/support/bench.rs +++ b/tests/support/bench.rs @@ -126,13 +126,31 @@ pub async fn bench_run_textarea( time_keystrokes_yielding(n).await } -/// Load `doc_text` into the editing surface and run the render `setText` -/// scheduled, so it cannot fire during a later yield and be counted as a -/// keystroke render. -pub fn bench_load_surface(doc_text: &str) { +/// Load the real document and wait for its initial heading decorations. +/// Setup's scheduled decoration render must settle before either benchmark +/// captures its baseline; later typing keeps every original render assertion. +pub async fn bench_load_surface(doc_text: &str) { js_eval(&format!( "teTest.surface().setText({})", js_string_literal(doc_text) )); flush_preview(); + assert!( + wait_until(|| { + js_number( + r##"Number((() => { + const ed = window.__teEditor, s = ed.surface, layer = ed.headings; + if (!layer || !layer.active || layer.frame) return false; + const expected = TeHeadingLayer.scan(s.getText()); + if (JSON.stringify(layer.headings) !== JSON.stringify(expected)) return false; + const decorations = s.getDecorations(); + return expected.every(h => + decorations.some(d => d.start === h.start && d.end === h.end && d.className === 'te-heading') && + decorations.some(d => d.start === h.start && d.end === h.markerEnd && d.className === 'te-heading-marker')); + })())"##, + ) == 1.0 + }) + .await, + "initial heading decorations did not settle before the benchmark baseline" + ); } diff --git a/tests/web_alt_panel_tabs.rs b/tests/web_alt_panel_tabs.rs index 7480a70..0b0fa0d 100644 --- a/tests/web_alt_panel_tabs.rs +++ b/tests/web_alt_panel_tabs.rs @@ -232,3 +232,123 @@ async fn test_tabs_one_line_quiet_count_and_video_layout() { .await; assert_eq!(result, ""); } + +#[wasm_bindgen_test] +async fn test_embedded_header_tracks_tabs_without_rebuilding_pending_input() { + let _document = fresh_full_editor(); + sleep(0).await; + install_alt_panel_helpers(); + let fixture = js_string_literal(&provenance_fixture()); + js_eval(&format!( + r##"(() => {{ + const header = document.createElement('header'); + header.id = 'te-public-embedding-header'; + const title = document.createElement('h1'); title.textContent = 'Public host heading'; + const paragraph = document.createElement('p'); paragraph.textContent = 'Host content before the embedded editor.'; + header.append(title, paragraph); + document.body.insertBefore(header, document.getElementById('app')); + const ed = window.__teEditor, s = ed.surface; + ed.openDocument({fixture}); ed.chrome.setMode('write-on'); + s.focus(); s.setSelectionOffsets(12, 12); teTest.control('alternatives').click(); + window.__teHeaderPanel = teAlt.P(); window.__teHeaderState = {{}}; + return 0; + }})()"## + )); + let first = run_steps(&[r##" + const P = window.__teHeaderPanel; + const line = s.rangeForOffsets(0, 4).getClientRects()[0]; + const tb = P.tablist.getBoundingClientRect(); + if (!(tb.bottom <= line.top && line.top - tb.top < 64)) out.push('tabs not just above the first line: tabs ' + JSON.stringify([tb.top, tb.bottom]) + ' line ' + line.top); + window.__teHeaderState.headerDraft = P.lineInputs().find(input => !input.readOnly && !input.classList.contains('te-alt-input--new')); + if (!window.__teHeaderState.headerDraft) return 'missing editable alternative'; + window.__teHeaderState.headerDraft.focus(); window.__teHeaderState.headerDraft.value = 'public pending alternative'; + window.__teHeaderState.headerDraft.dispatchEvent(new Event('input', { bubbles: true })); + window.__teHeaderState.headerBody = s.getText(); window.__teHeaderState.headerHistory = s.history.length; window.__teHeaderState.headerRenders = P.renderCount; + const paragraph = document.createElement('p'); + paragraph.textContent = 'More real host content changes the editor position while its draft remains open.'; + document.getElementById('te-public-embedding-header').appendChild(paragraph); + "##]).await; + let resized = wait_until(|| js_number(r##"Number((() => { + const P = window.__teHeaderPanel, s = window.__teEditor.surface, range = document.createRange(); + const text = document.createTreeWalker(s.root, NodeFilter.SHOW_TEXT).nextNode(); + if (!text || !P.root) return false; + range.setStart(text, 0); range.setEnd(text, Math.min(1, text.length)); + const line = range.getClientRects()[0], tabs = P.tablist.getBoundingClientRect(); + return !!line && tabs.bottom <= line.top && line.top - tabs.top < 64; + })())"##) == 1.0).await; + let second = run_steps(&[r##" + const P = window.__teHeaderPanel; + if (document.activeElement !== window.__teHeaderState.headerDraft || window.__teHeaderState.headerDraft.value !== 'public pending alternative') out.push('header resize lost pending input/focus'); + if (P.renderCount !== window.__teHeaderState.headerRenders) out.push('geometry rebuilt panel inputs'); + if (s.getText() !== window.__teHeaderState.headerBody || s.history.length !== window.__teHeaderState.headerHistory) out.push('geometry changed document/history'); + window.__teHeaderState.headerRoot = P.root; window.__teHeaderState.headerStyle = window.__teHeaderState.headerRoot.getAttribute('style'); + // Queue a real position update, then destroy before its frame executes. + if (typeof P.schedulePosition === 'function') P.schedulePosition(); + window.__teHeaderState.headerDrafts = P.destroy(); + document.getElementById('te-public-embedding-header').appendChild(document.createElement('p')).textContent = 'Host changes after teardown.'; + "##]).await; + // Negative lifecycle check: allow native resize notifications and frames. + sleep(40).await; + let last = run_steps(&[r##" + const P = window.__teHeaderPanel; + if (window.__teHeaderState.headerRoot.isConnected || window.__teHeaderState.headerRoot.getAttribute('style') !== window.__teHeaderState.headerStyle) out.push('destroyed geometry observer wrote panel'); + if (P.positionFrame || P.positionObserver || P.positionAbortController) out.push('position lifecycle retained work'); + if (!window.__teHeaderState.headerDrafts.some(d => d.value === 'public pending alternative')) out.push('teardown lost pending draft'); + document.getElementById('te-public-embedding-header').remove(); + "##]).await; + assert_eq!(first, ""); + assert!(resized, "actual header resize did not realign the tabs"); + assert_eq!(second, ""); + assert_eq!(last, ""); +} + +#[wasm_bindgen_test] +async fn test_first_visual_blank_line_geometry_does_not_repair_raw_dom() { + let _document = fresh_full_editor(); + sleep(0).await; + install_alt_panel_helpers(); + js_eval( + r##"(() => { + const ed = window.__teEditor, s = ed.surface; + const header = document.createElement('header'); header.id = 'te-blank-line-header'; + const title = document.createElement('h1'); title.textContent = 'Public embedding heading'; + header.appendChild(title); document.body.insertBefore(header, document.getElementById('app')); + s.setText('\nRisk'); ed.chrome.setMode('write-on'); + ed.altPanel.open({ start: 1, end: 5, kind: 'word' }, { focus: false }); + window.__teBlankPanel = ed.altPanel; + return 0; + })()"##, + ); + let setup_settled = wait_until(|| js_number("Number(!window.__teEditor.headings.frame && window.__teEditor.headings.headings.length === 0)") == 1.0).await; + let first = run_steps(&[r##" + const P = window.__teBlankPanel, range = document.createRange(); + const text = document.createTreeWalker(s.root, NodeFilter.SHOW_TEXT).nextNode(); + range.setStart(text, 0); range.setEnd(text, 1); + const line = range.getClientRects()[0], tabs = P.tablist.getBoundingClientRect(); + if (!line || !(tabs.bottom <= line.top && line.top - tabs.top < 64)) out.push('leading newline first line alignment'); + // Real pending contenteditable shape for the same body, without input + // dispatch or a model operation. Geometry must not canonicalize it. + const empty = document.createElement('div'); empty.appendChild(document.createElement('br')); + const paragraph = document.createElement('div'); paragraph.textContent = 'Risk'; + s.root.replaceChildren(empty, paragraph); + window.__teBlankBefore = { html: s.root.innerHTML, body: s.getText(), history: s.history.length, renders: s.renderCount }; + const more = document.createElement('p'); more.textContent = 'More real host content repositions the same raw editor DOM.'; + document.getElementById('te-blank-line-header').appendChild(more); + "##]).await; + let positioned = wait_until(|| js_number(r##"Number((() => { + const P = window.__teBlankPanel, s = window.__teEditor.surface, br = s.root.querySelector('div > br'); + if (!br) return false; + const range = document.createRange(); range.selectNode(br); + const line = Array.from(range.getClientRects()).find(r => r.height > 0), tabs = P.tablist.getBoundingClientRect(); + return !!line && tabs.bottom <= line.top && line.top - tabs.top < 64; + })())"##) == 1.0).await; + let last = run_steps(&[r##" + const P = window.__teBlankPanel, before = window.__teBlankBefore; + if (s.root.innerHTML !== before.html || s.getText() !== before.body || s.history.length !== before.history || s.renderCount !== before.renders) out.push('geometry repaired raw DOM or changed document/history'); + P.destroy(); document.getElementById('te-blank-line-header').remove(); + "##]).await; + assert!(setup_settled, "initial real heading setup did not settle"); + assert_eq!(first, ""); + assert!(positioned, "tabs skipped the first empty visual paragraph"); + assert_eq!(last, ""); +} diff --git a/tests/web_bench.rs b/tests/web_bench.rs index 7a07de0..c332693 100644 --- a/tests/web_bench.rs +++ b/tests/web_bench.rs @@ -93,7 +93,7 @@ async fn bench_typing_latency_textarea_vs_debounced_surface() { (t, flush_ms) }; - bench_load_surface(&doc_text); + bench_load_surface(&doc_text).await; sleep(0).await; let renders_before = js_number("teTest.surface().renderCount"); let preview_renders_before = preview_render_count(); diff --git a/tests/web_bench_logged.rs b/tests/web_bench_logged.rs index b4cc58a..1c47f58 100644 --- a/tests/web_bench_logged.rs +++ b/tests/web_bench_logged.rs @@ -79,7 +79,7 @@ async fn bench_typing_latency_logged_paths() { now() - t0 }; - bench_load_surface(&doc_text); + bench_load_surface(&doc_text).await; sleep(0).await; let renders_before = js_number("teTest.surface().renderCount"); let preview_renders_before = preview_render_count(); From 2bd4062c0182fcfbea3651a17a528f92a2299d28 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sun, 18 Oct 2026 11:28:28 +0100 Subject: [PATCH 14/16] fix: preserve native Ghost history and pending input during view updates --- public/js/editor.js | 119 +++++++--- src/document.rs | 205 +++++++++++++++++- ...-authority-controller.integration.test.cjs | 147 ++++++++++++- tests/history_checkpoints_native.rs | 148 +++++++++++++ tests/web_ghosts.rs | 31 +++ 5 files changed, 606 insertions(+), 44 deletions(-) diff --git a/public/js/editor.js b/public/js/editor.js index edb2ebc..9746acc 100644 --- a/public/js/editor.js +++ b/public/js/editor.js @@ -165,6 +165,7 @@ class EditorSurface { // A text move ({ start, end, to }, see MarkdownEditor.moveRange) rides // on the edit and its history step, so undo and redo can replay it as a // move in the document model rather than a deletion plus an insertion. + if (options.ghostEdit) edit.ghostEdit = { ...options.ghostEdit }; if (options.move) edit.move = { ...options.move }; // A swap of alternatives ({ span, from, to }, see // MarkdownEditor.swapAlternative, issue #9) rides the same way: undo and @@ -226,6 +227,9 @@ class EditorSurface { if (s === e && inserted === '') continue; if (old.slice(s, e) === inserted) continue; const edit = { start: s, deletedLength: e - s, deletedText: old.slice(s, e), insertedText: inserted }; + const nativeEdit = options.historyEdits?.[applied.length]; + if (options.historyEdits && (!nativeEdit || nativeEdit.start !== edit.start || nativeEdit.deletedText !== edit.deletedText || nativeEdit.insertedText !== edit.insertedText)) throw new Error('Native batch history differs from its exact surface edit'); + if (nativeEdit?.ghostEdit) edit.ghostEdit = { ...nativeEdit.ghostEdit }; this.text = old.slice(0, s) + inserted + old.slice(e); this.decorations = EditorSurface.mapRanges(this.decorations, edit); applied.push(edit); @@ -242,6 +246,7 @@ class EditorSurface { if (entry && entry.text === this.text) { entry.edits = applied.map((edit) => ({ start: edit.start, deletedText: edit.deletedText, insertedText: edit.insertedText, + ...(edit.ghostEdit ? {ghostEdit:{...edit.ghostEdit}} : {}), })); } this.dispatchInput('insertReplacementText', null); @@ -398,6 +403,12 @@ class EditorSurface { }); } next.sort((a, b) => a.start - b.start || a.end - b.end); + if (this.authorityEditor && !this.authorityEditor.authorityPainting && this.authorityEditor.authorityDrafts.has('body')) { + // A decoration frame must not replace newer raw input with the older accepted projection. + this.decorations = next; + this.authorityDecorationsPending = true; + return this.getDecorations(); + } const sel = this.getSelectionOffsets(); this.decorations = next; this.render(); @@ -489,7 +500,7 @@ class EditorSurface { source: source || last.source, edits: group.flatMap((entry) => entry.edits), }; - if (group.some((entry) => entry.ghostHistory || entry.edits.some((step) => step.ghost))) merged.ghostHistory = true; + if (group.some((entry) => entry.ghostHistory || entry.edits.some((step) => step.ghost || step.ghostEdit))) merged.ghostHistory = true; this.history.splice(first, count, merged); this.historyIndex = first; return true; @@ -532,7 +543,7 @@ class EditorSurface { if (this.historyOwner) { this.historyOwner.undo(); return true; } if (!this.canUndo()) return false; const undone = this.history[this.historyIndex]; - if (undone.ghostHistory || (undone.edits && undone.edits.some((step) => step.ghost))) { + if (undone.ghostHistory || (undone.edits && undone.edits.some((step) => step.ghost || step.ghostEdit))) { return this.replayGhostHistory(this.historyIndex - 1, 'undo', undone.edits); } this.historyIndex -= 1; @@ -546,7 +557,7 @@ class EditorSurface { if (this.historyOwner) { this.historyOwner.redo(); return true; } if (!this.canRedo()) return false; const next = this.history[this.historyIndex + 1]; - if (next.ghostHistory || (next.edits && next.edits.some((step) => step.ghost))) { + if (next.ghostHistory || (next.edits && next.edits.some((step) => step.ghost || step.ghostEdit))) { return this.replayGhostHistory(this.historyIndex + 1, 'redo', next.edits); } this.historyIndex += 1; @@ -834,6 +845,7 @@ class EditorSurface { const step = edit ? { start: edit.start, deletedText: edit.deletedText, insertedText: edit.insertedText } : null; + if (step && edit.ghostEdit) step.ghostEdit = {...edit.ghostEdit}; if (step && edit.move) step.move = edit.move; if (step && edit.swap) step.swap = edit.swap; if (step && edit.overflow) step.overflow = edit.overflow; @@ -875,6 +887,7 @@ class EditorSurface { !top.ghostHistory && !(top.edits && top.edits.some((step) => step.ghost)) && top.source === source && now - top.time < this.coalesceMs; + const previousEdits = coalesce ? top.edits : null; if (coalesce) { // The burst's entry keeps every step; contiguous steps are combined. entry.edits = top.edits && step ? EditorSurface.appendStep(top.edits, step, top.text) : null; @@ -884,6 +897,22 @@ class EditorSurface { if (this.history.length > this.historyLimit) this.history.shift(); this.historyIndex = this.history.length - 1; } + if (edit && step) { + this.recordedEdits ||= new WeakMap(); + this.recordedEdits.set(edit, {entry,step,previousEdits}); + } + } + + /** Retain native-issued mapping on the exact edit before text coalescing loses it. */ + retainGhostEdit(edit, ghostEdit) { + if (!ghostEdit || typeof ghostEdit.receipt !== 'string' || typeof ghostEdit.undo !== 'boolean') throw new Error('Invalid native text Ghost receipt'); + edit.ghostEdit = {...ghostEdit}; + const recorded = this.recordedEdits?.get(edit); + // Batch edits notify before their single history entry is recorded. + if (!recorded) return; + if (this.history[this.historyIndex] !== recorded.entry || recorded.entry.text !== this.text) throw new Error('Native text Ghost receipt has no matching history entry'); + recorded.step.ghostEdit = {...ghostEdit}; + recorded.entry.edits = recorded.previousEdits ? [...recorded.previousEdits,recorded.step] : [recorded.step]; } /** @@ -1229,6 +1258,7 @@ class EditorSurface { if (step.move) inverse.move = { ...step.move, ...EditorSurface.invertMove(step.move) }; if (step.swap) inverse.swap = { ...step.swap, span: step.swap.span, from: step.swap.to, to: step.swap.from }; if (step.overflow) inverse.overflow = { ...step.overflow, before: step.overflow.after, after: step.overflow.before }; + if (step.ghostEdit) inverse.ghostEdit = {...step.ghostEdit,undo:!step.ghostEdit.undo}; if (step.ghost) inverse.ghost = { ...step.ghost, change: step.ghost.change, undo: typeof step.ghost.undo === 'boolean' ? !step.ghost.undo : step.ghost.undo }; return inverse; } @@ -1253,7 +1283,7 @@ class EditorSurface { static appendStep(steps, next, before) { const last = steps[steps.length - 1]; if (!last) return [next]; - if ([last, next].some((step) => step.ghost || step.alt || step.move || step.swap || step.overflow)) return [...steps, next]; + if ([last, next].some((step) => step.ghost || step.ghostEdit || step.alt || step.move || step.swap || step.overflow)) return [...steps, next]; const lastEnd = last.start + last.insertedText.length; const nextEnd = next.start + next.deletedText.length; if (next.start > lastEnd || nextEnd < last.start) return [...steps, next]; @@ -1293,6 +1323,7 @@ class EditorSurface { if (step.swap) edit.swap = step.swap; if (step.overflow) edit.overflow = step.overflow; if (step.ghost) edit.ghost = step.ghost; + if (step.ghostEdit) edit.ghostEdit = step.ghostEdit; out.push({ edit, text: t }); } return t === expected ? out : null; @@ -1849,7 +1880,7 @@ class MarkdownEditor { const retained=this.pendingAuthorityDraft(); this.authorityQueueGeneration=(this.authorityQueueGeneration||0)+1; this.authorityQueue=Promise.resolve();this.authorityFault=null;this.authorityDrafts.clear(); - this.setAuthorityStatus('');this.surface.root.removeAttribute('aria-readonly'); + this.setAuthorityStatus('');this.surface.root.removeAttribute('aria-readonly');this.surface.authorityDecorationsPending=false; if(this.altPanel) { this.altPanel.authorityCancelGeneration=(this.altPanel.authorityCancelGeneration||0)+1; if(this.altPanel.live)this.altPanel.endLive(); @@ -1872,6 +1903,12 @@ class MarkdownEditor { authorityClearDraft(key, sequence) { if (this.authorityDrafts.get(key)?.sequence === sequence) this.authorityDrafts.delete(key); + if (!this.surface.destroyed && !this.authorityDrafts.has('body') && this.surface.authorityDecorationsPending && this.surface.serialise().text === this.surface.getText()) { + const selection = this.surface.readSelection(); + this.surface.authorityDecorationsPending = false; + this.surface.render(); + if (selection) this.surface.setSelectionOffsets(selection.start,selection.end,selection.direction); + } if (this.authority.reportDraft) this.authority.reportDraft(this.pendingAuthorityDraft()); } @@ -1892,12 +1929,34 @@ class MarkdownEditor { return this.authority.historyChanged(); } - authorityRenderDraft() { + authorityRawSelection() { const draft=this.authorityDrafts.get('body'); - if (!draft || draft.input.text===this.surface.getText()) return; + if (!draft || this.surface.serialise().text !== draft.input.text) return null; + return {sequence:draft.sequence,text:draft.input.text,selection:this.surface.readSelection()}; + } + + authorityRenderDraft(rawBeforePaint = null) { + const draft=this.authorityDrafts.get('body'); + if (!draft) return; + const preserved = rawBeforePaint?.sequence===draft.sequence && rawBeforePaint.text===draft.input.text ? rawBeforePaint.selection : null; + if (this.surface.serialise().text===draft.input.text) { + // Accepted paints and confirmed rollbacks can restore correct text with an older caret. + if (!preserved || document.activeElement!==this.surface.root) return; + const current=this.surface.readSelection(); + if (current && current.start===preserved.start && current.end===preserved.end && current.direction===preserved.direction) return; + if (draft.input.text===this.surface.getText()) { + this.surface.setSelectionOffsets(preserved.start,preserved.end,preserved.direction); + return; + } + // A rollback may reattach pre-paint raw nodes while the accepted model is older. + // Preserve the raw caret without clamping it to that model or canonicalizing via it. + } this.surface.root.textContent=draft.input.text; - const node=this.surface.root.firstChild, sel=document.getSelection(), selection=draft.input.selection; - if(node && sel && document.activeElement===this.surface.root) sel.setBaseAndExtent(node,Math.min(selection.start,node.length),node,Math.min(selection.end,node.length)); + const node=this.surface.root.firstChild, sel=document.getSelection(), selection=preserved || draft.input.selection; + if(node && sel && document.activeElement===this.surface.root) { + const start=Math.min(selection.start,node.length),end=Math.min(selection.end,node.length); + sel.setBaseAndExtent(node,selection.direction==='backward'?end:start,node,selection.direction==='backward'?start:end); + } } /** Update the flow status and remeasure indicators after its line changes. */ @@ -1940,6 +1999,7 @@ class MarkdownEditor { const accepted = await this.authority.transact(request, async started => { if(generation!==this.authorityQueueGeneration)throw new Error('Prepared outcome belongs to a recovered authority'); if (!started || started.ok !== true || typeof started.token !== 'string' || typeof started.source !== 'string') throw new Error('Invalid prepared native outcome'); + const rawBeforePaint=this.authorityRawSelection(); const focus=document.activeElement; const focusCaret=focus instanceof HTMLInputElement||focus instanceof HTMLTextAreaElement ? [focus.selectionStart,focus.selectionEnd] : null; this.authorityPainting = true; @@ -1955,7 +2015,7 @@ class MarkdownEditor { if(generation===this.authorityQueueGeneration && focus?.isConnected && focus!==this.surface.root && typeof focus.focus==='function') { focus.focus();if(focusCaret)focus.setSelectionRange(...focusCaret); } - this.authorityPainting = false; this.authorityRenderDraft(); + this.authorityPainting = false; this.authorityRenderDraft(rawBeforePaint); } }); if(generation!==this.authorityQueueGeneration)throw new Error('Accepted outcome belongs to a recovered authority'); @@ -1966,13 +2026,14 @@ class MarkdownEditor { } catch (error) { if(generation!==this.authorityQueueGeneration)throw error; if (error?.authorityRolledBack || error?.authorityUnchanged) { + const rawBeforeRollback=this.authorityRawSelection(); this.authorityPainting = true; try { api.open_document(source); this.restoreGhostHistoryCheckpoint(saved, api); for (let index=0;index { edit=this.surface.replaceRange(a,b,text,options); }); + const accepted=await this.authorityTransaction({route:'action',expectedBody:before,meta,textArgs:[text]}, started => { edit=this.surface.replaceRange(a,b,text,{...options,ghostEdit:started.result?.edit?.ghostEdit}); }); return accepted ? edit : null; }); } @@ -2011,12 +2072,12 @@ class MarkdownEditor { const before = this.surface.getText(); return this.authorityEnqueue(async () => { if(before!==this.surface.getText()) throw new Error('Batch action base changed; input retained'); - const ordered=edits.map(edit=>({start:Math.min(edit.start,edit.end),end:Math.max(edit.start,edit.end),insert:EditorSurface.normaliseNewlines(String(edit.insert||''))})).sort((a,b)=>b.start-a.start); + const ordered=edits.map(edit=>({start:Math.min(edit.start,edit.end),end:Math.max(edit.start,edit.end),insert:EditorSurface.normaliseNewlines(String(edit.insert||''))})).sort((a,b)=>a.start-b.start||a.end-b.end).reverse(); let body=before; const batch=[], args=[]; - for(const edit of ordered) { batch.push(await this.authorityEditMeta(body,edit.start,edit.end,edit.insert,args.length));args.push(edit.insert);body=body.slice(0,edit.start)+edit.insert+body.slice(edit.end); } + for(const edit of ordered) { if(body.slice(edit.start,edit.end)===edit.insert)continue; batch.push(await this.authorityEditMeta(body,edit.start,edit.end,edit.insert,args.length));args.push(edit.insert);body=body.slice(0,edit.start)+edit.insert+body.slice(edit.end); } if(!batch.length)return []; let applied; - const accepted=await this.authorityTransaction({route:'action',expectedBody:before,meta:{kind:'text_edits',edits:batch},textArgs:args},()=>{applied=this.surface.replaceRanges(edits,options);}); + const accepted=await this.authorityTransaction({route:'action',expectedBody:before,meta:{kind:'text_edits',edits:batch},textArgs:args},started=>{applied=this.surface.replaceRanges(edits,{...options,historyEdits:started.result?.edits?.map(value=>value.edit)});}); return accepted ? applied : []; }); } @@ -2028,16 +2089,11 @@ class MarkdownEditor { const before=this.surface.getText(), edit=EditorSurface.diff(before,text,hint); if(!edit) {this.authorityClearDraft('body',sequence);return;} const meta={kind:'text_edit',...await this.authorityEditMeta(before,edit.start,edit.start+edit.deletedLength,edit.insertedText,0)}; - await this.authorityTransaction({route:'action',expectedBody:before,meta,textArgs:[edit.insertedText]},()=> { - this.surface.replaceRange(edit.start,edit.start+edit.deletedLength,edit.insertedText,{source,selectStart:selection.start,selectEnd:selection.end}); + await this.authorityTransaction({route:'action',expectedBody:before,meta,textArgs:[edit.insertedText]},started=> { + this.surface.replaceRange(edit.start,edit.start+edit.deletedLength,edit.insertedText,{source,selectStart:selection.start,selectEnd:selection.end,ghostEdit:started.result?.edit?.ghostEdit}); }); this.authorityClearDraft('body',sequence); - const later=this.authorityDrafts.get('body'); - if(later && later.sequence>sequence) { - this.surface.root.textContent=later.input.text; - const node=this.surface.root.firstChild, sel=document.getSelection(); - if(node && sel && document.activeElement===this.surface.root) sel.setBaseAndExtent(node,Math.min(later.input.selection.start,node.length),node,Math.min(later.input.selection.end,node.length)); - } + this.authorityRenderDraft(); }).catch(error=>this.ghostHistoryNotice(error)); } @@ -2060,7 +2116,7 @@ class MarkdownEditor { if(edit.move)return {...base,kind:'move',move:edit.move}; if(edit.swap)return {...base,kind:'swap',...edit.swap}; if(edit.overflow)return {...base,kind:'overflow',...edit.overflow}; - return {...base,kind:'edit'}; + return {...base,kind:'edit',...(edit.ghostEdit ? {ghostEdit:edit.ghostEdit} : {})}; }); const accepted=await this.authorityTransaction({route:'history',expectedBody:body,targetBody:target.text,operationsJson:JSON.stringify(operations)},started=> { surface.historyIndex=index;this.paintGhostHistory(started.annotations,applied); @@ -2244,20 +2300,21 @@ class MarkdownEditor { } static nativeGhostHistoryOperations(body, steps, target) { - if (!Array.isArray(steps) || !steps.some((step) => step && step.ghost)) throw new Error('Ghost history metadata is missing; text-diff fallback is forbidden'); + if (!Array.isArray(steps) || !steps.some((step) => step && (step.ghost || step.ghostEdit))) throw new Error('Ghost history metadata is missing; text-diff fallback is forbidden'); if (steps.length > 4096) throw new Error('Too many history operations'); - const allowed = ['start','deletedText','insertedText','ghost','alt','move','swap','overflow']; + const allowed = ['start','deletedText','insertedText','ghost','ghostEdit','alt','move','swap','overflow']; for (const step of steps) { if (Object.keys(step).some((key) => !allowed.includes(key)) || - ['ghost','alt','move','swap','overflow'].filter((key) => step[key]).length > 1 || + ['ghost','ghostEdit','alt','move','swap','overflow'].filter((key) => step[key]).length > 1 || !Number.isInteger(step.start) || step.start < 0 || step.start > 0xffffffff || typeof step.deletedText !== 'string' || typeof step.insertedText !== 'string') { throw new Error('Invalid or conflicting ghost-group history metadata'); } + if (step.ghostEdit && (typeof step.ghostEdit.receipt !== 'string' || typeof step.ghostEdit.undo !== 'boolean')) throw new Error('Invalid native text Ghost receipt'); if (step.ghost && (typeof step.ghost.undo !== 'boolean' || step.deletedText || step.insertedText)) { throw new Error('Ghost receipt must be annotation-only with an exact direction'); } - for (const [key, fields] of Object.entries({ghost:['change','undo'],alt:['span','before','after'], + for (const [key, fields] of Object.entries({ghost:['change','undo'],ghostEdit:['receipt','undo'],alt:['span','before','after'], move:['start','end','to'],swap:['span','from','to'],overflow:['before','after']})) { if (step[key] && (Object.keys(step[key]).length !== fields.length || fields.some((field) => !Object.hasOwn(step[key], field)))) { @@ -2274,7 +2331,7 @@ class MarkdownEditor { if (edit.move) return {...base,kind:'move',move:edit.move}; if (edit.swap) return {...base,kind:'swap',...edit.swap}; if (edit.overflow) return {...base,kind:'overflow',...edit.overflow}; - return {...base,kind:'edit'}; + return {...base,kind:'edit',...(edit.ghostEdit ? {ghostEdit:edit.ghostEdit} : {})}; }); const json = JSON.stringify(operations); MarkdownEditor.ghostTransportBounds(body, target, json); @@ -2364,6 +2421,10 @@ class MarkdownEditor { this.reflectSync(api.sync_document_body(text)); return; } + if (outcome?.ghostEdit && change.source !== 'undo' && change.source !== 'redo') { + this.surface.retainGhostEdit(edit,outcome.ghostEdit); + this.surface.ghostHistoryAdapter = this; + } if (outcome) this.reflectSetAside(outcome.setAside, outcome.notice, outcome.warning); } diff --git a/src/document.rs b/src/document.rs index ddb0d82..5010fab 100644 --- a/src/document.rs +++ b/src/document.rs @@ -49,8 +49,8 @@ //! overlapping a live item, is re-attached. Undoing the edit that detached a //! span therefore brings its alternatives back. -use std::cell::RefCell; -use std::collections::HashSet; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use crate::providers::Suggestion; @@ -109,6 +109,8 @@ pub struct EditOutcome { /// [`DocumentSession::set_aside_notice`]), `None` when nothing is set /// aside. pub notice: Option, + /// Native-issued exact Ghost mapping metadata, absent when Ghosts did not change. + pub ghost_edit: Option, } /// One open document: the span model plus, after a malformed open, the raw @@ -129,6 +131,8 @@ pub struct DocumentSession { reserved_ids: HashSet, /// Exact receipts issued by this open session; rejects forged replay targets. ghost_receipts: HashSet, + /// Session-private snapshots; serialized history can only name an issued receipt. + text_ghost_receipts: HashMap, } #[derive(Debug, Clone)] @@ -517,6 +521,54 @@ impl DocumentSession { start: usize, deleted_len: usize, inserted: &str, + ) -> Result { + if self.doc.annotations.ghosts.is_empty() && self.set_aside.ghosts.is_empty() { + return self.apply_edit_mapped(start, deleted_len, inserted); + } + let next = TEXT_GHOST_WATERMARK + .with(|n| n.get().checked_add(1)) + .ok_or(EditError::IdAllocation)?; + let before = self.ghost_snapshot(); + let before_body_hash = crate::host::source_hash(self.body()); + let end = start + .checked_add(deleted_len) + .ok_or(EditError::InvalidRange { + start, + end: usize::MAX, + })?; + let deleted_hash = utf16_to_byte(self.body(), start) + .zip(utf16_to_byte(self.body(), end)) + .map(|(a, b)| crate::host::source_hash(&self.body()[a..b])); + let mut outcome = self.apply_edit_mapped(start, deleted_len, inserted)?; + let after = self.ghost_snapshot(); + if before.live != after.live || before.set_aside != after.set_aside { + let receipt = format!("text-ghost-{next}"); + self.text_ghost_receipts.insert( + receipt.clone(), + TextGhostReceipt { + before_body_hash, + after_body_hash: crate::host::source_hash(self.body()), + start, + deleted_hash: deleted_hash.expect("successful mapped edit validated range"), + inserted_hash: crate::host::source_hash(inserted), + before, + after, + }, + ); + TEXT_GHOST_WATERMARK.with(|n| n.set(next)); + outcome.ghost_edit = Some(GhostEditReference { + receipt, + undo: false, + }); + } + Ok(outcome) + } + + fn apply_edit_mapped( + &mut self, + start: usize, + deleted_len: usize, + inserted: &str, ) -> Result { let end = start .checked_add(deleted_len) @@ -559,6 +611,7 @@ impl DocumentSession { set_aside: self.set_aside.len(), warning, notice: self.set_aside_notice(), + ghost_edit: None, }) } @@ -823,6 +876,35 @@ impl GhostSnapshot { } } +/// Reference to an actual native text-edit Ghost mapping, not a supplied snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GhostEditReference { + /// Opaque name looked up only in the issuing native session. + pub receipt: String, + /// Whether this operation reverses the original edit. + pub undo: bool, +} + +impl GhostEditReference { + fn to_json(&self) -> Value { + json!({"receipt":self.receipt,"undo":self.undo}) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TextGhostReceipt { + before_body_hash: String, + after_body_hash: String, + start: usize, + deleted_hash: String, + inserted_hash: String, + before: GhostSnapshot, + after: GhostSnapshot, +} + +// This watermark is outside DocumentSession::open/reset; old names are never reissued. +thread_local! { static TEXT_GHOST_WATERMARK: Cell = const { Cell::new(0) }; } + /// Exact receipt for independent ghost promotion; body is only a guard. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GhostChange { @@ -976,6 +1058,10 @@ impl DocumentSession { { return Err("ghost transaction state conflict".into()); } + self.restore_issued_ghost_snapshot(target) + } + + fn restore_issued_ghost_snapshot(&mut self, target: &GhostSnapshot) -> Result<(), String> { let mut ids = HashSet::new(); for ghost in target.live.iter().chain(target.set_aside.iter()) { if !ids.insert(&ghost.id) @@ -1068,14 +1154,18 @@ impl EditOutcome { /// `{ detached, detachedGhosts, reattached, setAside, warning, notice }` /// for JavaScript. pub fn to_json(&self) -> Value { - json!({ + let mut value = json!({ "detached": self.detached, "detachedGhosts": self.detached_ghosts, "reattached": self.reattached, "setAside": self.set_aside, "warning": self.warning, "notice": self.notice, - }) + }); + if let Some(reference) = &self.ghost_edit { + value["ghostEdit"] = reference.to_json(); + } + value } } @@ -1829,6 +1919,7 @@ impl DocumentSession { set_aside: self.set_aside.len(), warning: None, notice: self.set_aside_notice(), + ghost_edit: None, }) } } @@ -2718,7 +2809,9 @@ struct HistoryTextEdit { #[derive(Debug, Clone)] enum HistoryKind { - Edit, + Edit { + ghost_edit: Option, + }, Ghost { change: GhostChange, undo: bool, @@ -2762,6 +2855,14 @@ pub fn parse_history_operations( .as_str() .ok_or_else(|| history_error("invalid-payload", "history kind is required"))?; let fields: &[&str] = match kind { + "edit" if value.get("ghostEdit").is_some() => &[ + "kind", + "body", + "start", + "deletedText", + "insertedText", + "ghostEdit", + ], "edit" => &["kind", "body", "start", "deletedText", "insertedText"], "ghost" => &["kind", "body", "change", "undo"], "alt" => &[ @@ -2814,7 +2915,24 @@ pub fn parse_history_operations( }) }; let kind = match kind { - "edit" => HistoryKind::Edit, + "edit" => { + let ghost_edit = value + .get("ghostEdit") + .map(|reference| { + history_fields(reference, &["receipt", "undo"])?; + Ok(GhostEditReference { + receipt: history_string(reference, "receipt")?, + undo: reference["undo"].as_bool().ok_or_else(|| { + history_error( + "invalid-payload", + "Ghost edit direction must be boolean", + ) + })?, + }) + }) + .transpose()?; + HistoryKind::Edit { ghost_edit } + } "ghost" => { let change = &value["change"]; history_fields(change, &["body", "before", "after"])?; @@ -2908,6 +3026,56 @@ impl HistoryTextEdit { } impl DocumentSession { + fn replay_text_ghost_edit( + &mut self, + edit: &HistoryTextEdit, + reference: &GhostEditReference, + ) -> Result<(), String> { + let issued = self + .text_ghost_receipts + .get(&reference.receipt) + .cloned() + .ok_or("unknown or stale text Ghost receipt")?; + let (expected, target, body_hash, deleted_hash, inserted_hash) = if reference.undo { + ( + &issued.after, + &issued.before, + &issued.after_body_hash, + &issued.inserted_hash, + &issued.deleted_hash, + ) + } else { + ( + &issued.before, + &issued.after, + &issued.before_body_hash, + &issued.deleted_hash, + &issued.inserted_hash, + ) + }; + if self.raw_block.is_some() + || crate::host::source_hash(self.body()) != *body_hash + || edit.start != issued.start + || crate::host::source_hash(&edit.deleted) != *deleted_hash + || crate::host::source_hash(&edit.inserted) != *inserted_hash + || self.doc.annotations.ghosts != expected.live + || self.set_aside.ghosts != expected.set_aside + { + return Err("text Ghost receipt state or edit conflict".into()); + } + self.apply_edit_mapped(edit.start, utf16_len(&edit.deleted), &edit.inserted) + .map_err(|e| e.to_string())?; + let target_body_hash = if reference.undo { + &issued.before_body_hash + } else { + &issued.after_body_hash + }; + if crate::host::source_hash(self.body()) != *target_body_hash { + return Err("text Ghost target body conflict".into()); + } + self.restore_issued_ghost_snapshot(target) + } + /// Simulate the complete ordered group on one clone, publish only after /// every metadata/body guard passes. Ghost-free legacy APIs are unchanged. pub fn replay_history_group_atomic( @@ -2939,11 +3107,17 @@ impl DocumentSession { HistoryKind::Ghost { change, undo } => trial .replay_ghost_change(change, *undo) .map_err(model_error)?, - HistoryKind::Edit => { + HistoryKind::Edit { ghost_edit } => { let e = op.edit.as_ref().expect("decoded edit"); - trial - .apply_edit(e.start, utf16_len(&e.deleted), &e.inserted) - .map_err(|e| model_error(e.to_string()))?; + if let Some(reference) = ghost_edit { + trial + .replay_text_ghost_edit(e, reference) + .map_err(model_error)?; + } else { + trial + .apply_edit_mapped(e.start, utf16_len(&e.deleted), &e.inserted) + .map_err(|e| model_error(e.to_string()))?; + } } HistoryKind::Alt { span, target } => { let e = op.edit.as_ref().expect("decoded edit"); @@ -2976,7 +3150,7 @@ impl DocumentSession { HistoryKind::Overflow { before, after } => { let e = op.edit.as_ref().expect("decoded edit"); trial - .apply_edit(e.start, utf16_len(&e.deleted), &e.inserted) + .apply_edit_mapped(e.start, utf16_len(&e.deleted), &e.inserted) .map_err(|e| model_error(e.to_string()))?; if !trial .replay_overflow(before, after) @@ -3241,7 +3415,14 @@ fn apply_action_edit( )); } let mut result = outcome.to_json(); - result.as_object_mut().expect("outcome object").insert("edit".into(), json!({"start":start,"deletedLength":deleted_len,"deletedText":deleted,"insertedText":inserted})); + let mut actual_edit = json!({"start":start,"deletedLength":deleted_len,"deletedText":deleted,"insertedText":inserted}); + if let Some(reference) = &outcome.ghost_edit { + actual_edit["ghostEdit"] = reference.to_json(); + } + result + .as_object_mut() + .expect("outcome object") + .insert("edit".into(), actual_edit); Ok(result) } diff --git a/tests/browser/native-authority-controller.integration.test.cjs b/tests/browser/native-authority-controller.integration.test.cjs index 2d4099d..e418592 100644 --- a/tests/browser/native-authority-controller.integration.test.cjs +++ b/tests/browser/native-authority-controller.integration.test.cjs @@ -52,14 +52,14 @@ async function setup(source='Risk needs a decision.\n',existing,authorityEnabled await page.goto(origin+'/terraphim-editor.min.js');await page.setContent('
'); await page.addScriptTag({url:origin+'/terraphim-editor.min.js'});await page.addScriptTag({url:origin+'/history-checkpoint.js'}); await page.evaluate(async({source,role,authorityEnabled})=>{ - window.actual=await TeraphimEditor.create(document.querySelector('#fixture'),{loadShoelace:false,value:source,dirtyTitle:false,config:{hostManaged:true,fileSystemAccess:false}});window.editor=actual.markdownEditor; + window.actual=await TeraphimEditor.create(document.querySelector('#fixture'),{loadShoelace:false,value:source,dirtyTitle:false,config:{hostManaged:true,fileSystemAccess:false}});window.editor=actual.markdownEditor;actual.openDocument(source); editor.loadThesaurus(role);window.nativeSnapshot=await nativeOperation('snapshot');window.captured=null;window.drafts=[]; if(!authorityEnabled)return; let queue=Promise.resolve();function serial(fn){const next=queue.then(fn);queue=next.catch(()=>{});return next;} editor.setAuthority({get roleGeneration(){return nativeSnapshot.roleGeneration;},get providerGeneration(){return nativeSnapshot.providerGeneration;},reportDraft(value){drafts.push(structuredClone(value));}, transact(command,paint){return serial(async()=>{let prepared;try{prepared=await nativeOperation('prepare',command);}catch(error){if(/context exited/.test(error.message))error.authorityUncertain=true;throw error;}if(prepared.kind==='no-change'){window.nativeFixtureNoChangePending=true;if(window.nativeFixtureNoChangeDelay)await new Promise(resolve=>setTimeout(resolve,window.nativeFixtureNoChangeDelay));return null;} - try{const started={...prepared.result,source:prepared.presentationSource,presentationSource:prepared.presentationSource};captured=await paint(started);if(window.nativeFixturePaintFailure)throw new Error('Actual post-paint checkpoint failure');if(window.nativeFixtureDelay)await new Promise(resolve=>setTimeout(resolve,window.nativeFixtureDelay));nativeSnapshot=await nativeOperation('finish');return started;} - catch(error){try{nativeSnapshot=await nativeOperation('abort');error.authorityRolledBack=true;}catch{error.authorityUncertain=true;}throw error;}});}, + try{const started={...prepared.result,source:prepared.presentationSource,presentationSource:prepared.presentationSource};captured=await paint(started);if(window.nativeFixtureFailNextPaint){window.nativeFixtureFailNextPaint=false;throw new Error('Actual one-shot post-paint checkpoint failure');}if(window.nativeFixturePaintFailure)throw new Error('Actual post-paint checkpoint failure');if(window.nativeFixtureDelay)await new Promise(resolve=>setTimeout(resolve,window.nativeFixtureDelay));nativeSnapshot=await nativeOperation('finish');return started;} + catch(error){window.nativeFixtureAbortPending=true;try{if(window.nativeFixtureAbortDelay)await new Promise(resolve=>setTimeout(resolve,window.nativeFixtureAbortDelay));nativeSnapshot=await nativeOperation('abort');error.authorityRolledBack=true;}catch{error.authorityUncertain=true;}finally{window.nativeFixtureAbortPending=false;}throw error;}});}, historyChanged(){return serial(async()=>{captured=TerraphimHistoryCheckpoint.capture(editor.surface,editor.documentApi().save_document(),editor.altPanel);nativeSnapshot=await nativeOperation('history');});}, query(name,args=[]){return serial(()=>nativeOperation('query',{name,args}));},control(value){return serial(async()=>{const r=await nativeOperation('control',value);nativeSnapshot=r.snapshot;return r.result;});} }); @@ -387,3 +387,144 @@ test('actual public brief Walk Keep Cuts Undo then Done preserves protected hist }catch(error){receipt.status='FAIL';receipt.error=error.stack;throw error;} finally{await fs.writeFile(path.join(output,'receipt.json'),JSON.stringify(receipt,null,2)+'\n');await page.close();} }); + + +test('native Ghost text mapping survives real typing Undo Redo and saved-source reopening', {timeout:60000},async()=>{ + const body='Risk needs mitigation.\n\nThe public project needs careful review.\n'; + for(const mode of ['burst','tail']){ + const {page,a}=await setup(body);let reopened; + try{ + await page.evaluate(()=>{window.ghostInputTrace=[];for(const name of ['beforeinput','input'])editor.surface.root.addEventListener(name,event=>{const selection=document.getSelection();ghostInputTrace.push({type:name,trusted:event.isTrusted,inputType:event.inputType,data:event.data,text:editor.surface.root.textContent,selection:selection?{text:selection.toString(),anchor:selection.anchorOffset,focus:selection.focusOffset}:null,ranges:event.getTargetRanges?Array.from(event.getTargetRanges(),range=>({start:range.startOffset,end:range.endOffset})):[],sequence:editor.authorityDraftSequence,painting:editor.authorityPainting});},{capture:true,passive:true});}); + await page.evaluate(()=>editor.ghosts.ghost(0,4));await settle(page); + const before=await a.snapshot(); + await page.evaluate(mode=>{editor.surface.focus();editor.surface.setSelectionOffsets(mode==='tail'?3:1,4);},mode); + if(mode==='burst')await page.keyboard.type('isk-long');else await page.keyboard.press('Backspace'); + await settle(page);const after=await a.snapshot(); + if(process.env.TERRAPHIM_GHOST_HISTORY_OUTPUT){await fs.mkdir(process.env.TERRAPHIM_GHOST_HISTORY_OUTPUT,{recursive:true});await fs.writeFile(path.join(process.env.TERRAPHIM_GHOST_HISTORY_OUTPUT,mode+'.json'),JSON.stringify({scope:'Real headless controller with actual native owner; not native Mac UI',before,after,events:await page.evaluate(()=>ghostInputTrace)},null,2)+'\n');} + assert.equal(after.projection.body,mode==='burst'?body.replace('Risk','Risk-long'):body.replace('Risk','Ris')); + assert.equal(await page.evaluate(()=>editor.surface.undo()),true);await settle(page);assert.equal((await a.snapshot()).source,before.source); + assert.equal(await page.evaluate(()=>editor.surface.redo()),true);await settle(page);assert.equal((await a.snapshot()).source,after.source); + const next=await setup(after.source);reopened=next.page; + assert.equal((await next.a.snapshot()).source,after.source); + assert.equal(await reopened.evaluate(()=>editor.surface.getText()),after.projection.body); + }finally{await reopened?.close();await page.close();} + } +}); + + +test('native Ghost receipts follow exact batch order through no-ops and a fresh view', {timeout:60000},async()=>{ + const {page,a,view}=await setup('Risk needs mitigation.\n');let other; + try{ + await page.evaluate(()=>editor.ghosts.ghost(0,4));await settle(page);const before=await a.snapshot(); + await page.evaluate(()=>editor.surface.replaceRanges([{start:4,end:4,insert:'1'},{start:4,end:4,insert:'2'},{start:0,end:1,insert:'R'},{start:3,end:4,insert:''}],{source:'test-batch'}));await settle(page); + const after=await a.snapshot();assert.equal(after.projection.body,'Ris12 needs mitigation.\n'); + const checkpoint=await page.evaluate(()=>TerraphimHistoryCheckpoint.capture(editor.surface,editor.documentApi().save_document(),editor.altPanel)); + await a.detachView(view);other=(await setup(after.source,a)).page; + await other.evaluate(({checkpoint,source})=>TerraphimHistoryCheckpoint.restore(editor.surface,checkpoint,source,editor),{checkpoint,source:after.source}); + assert.equal(await other.evaluate(()=>editor.surface.undo()),true);await settle(other);assert.equal((await a.snapshot()).source,before.source); + assert.equal(await other.evaluate(()=>editor.surface.redo()),true);await settle(other);assert.equal((await a.snapshot()).source,after.source); + }finally{await other?.close();await page.close();} +}); +// Append-only proposed regressions for the existing actual-controller fixture. +// Uses actual Rust ResourceAuthority/production controller and a delayed REAL finish, +// never an invented native response. setup/settle/state/assert/test are canonical fixture bindings. +test('decoration-only refresh preserves pending real native body text and DOM caret', {timeout:60000},async()=>{ + const body='Risk remains.\n';const {page,a}=await setup(body); + try{ + await page.evaluate(()=>editor.ghosts.ghost(0,4));await settle(page); + await page.evaluate(()=>{window.nativeFixtureDelay=250;editor.surface.focus();editor.surface.setSelectionOffsets(1,4);}); + await page.keyboard.type('isk-long'); + await page.waitForFunction(()=>{ + const draft=editor.authorityDrafts.get('body'); + return draft&&draft.input.text!==editor.surface.getText()&&editor.surface.serialise().text===draft.input.text; + }); + const observation=await page.evaluate(()=>{ + const read=()=>({text:editor.surface.serialise().text,selection:editor.surface.readSelection(),draft:structuredClone(editor.authorityDrafts.get('body')),accepted:editor.surface.getText()}); + const before=read(); + if (!before.draft || before.text === before.accepted || before.text !== before.draft.input.text) throw new Error('Pending raw decoration precondition changed before actual apply'); + const renderCount=editor.decorations.renderCount, batchDepth=editor.decorations.batchDepth; + // Real production registry application of an ordinary cosmetic layer. It + // exercises the same setDecorations/render path as a scheduled Ghost refresh. + editor.decorations.set('raw-paint-regression',[{id:'pending',start:0,end:1,className:'te-regression-decoration'}]); + return {before,after:read(),renderCountBefore:renderCount,renderCountAfter:editor.decorations.renderCount,batchDepth}; + }); + console.log('actual-decoration-observation '+JSON.stringify(observation)); + assert.equal(observation.after.text,observation.before.text,'decoration paint discarded raw native input'); + assert.deepEqual(observation.after.selection,observation.before.selection,'decoration paint moved raw native caret'); + assert.deepEqual(observation.after.draft,observation.before.draft,'cosmetic refresh changed the native proposal'); + await settle(page);assert.equal((await a.snapshot()).projection.body,'Risk-long remains.\n'); + }finally{await page.close();} +}); + +test('real ArrowLeft during pending native body acceptance survives paint and next character', {timeout:60000},async()=>{ + const body='Risk remains.\n';const {page,a}=await setup(body); + try{ + await page.evaluate(()=>editor.ghosts.ghost(0,4));await settle(page); + await page.evaluate(()=>{window.nativeFixtureDelay=250;editor.surface.focus();editor.surface.setSelectionOffsets(1,4);}); + await page.keyboard.type('isk-long'); + await page.waitForFunction(()=>{ + const draft=editor.authorityDrafts.get('body'); + return draft&&draft.input.text==='Risk-long remains.\n'&&draft.input.text!==editor.surface.getText()&&editor.surface.serialise().text===draft.input.text; + }); + await page.keyboard.press('ArrowLeft'); + const moved=await page.evaluate(()=>editor.surface.readSelection()); + assert.equal(moved.start,8);assert.equal(moved.end,8); + await settle(page); + const after=await page.evaluate(()=>({text:editor.surface.serialise().text,selection:editor.surface.readSelection()})); + assert.equal(after.text,'Risk-long remains.\n'); + assert.deepEqual(after.selection,moved,'accepted paint restored the older input-event caret'); + await page.keyboard.type('X');await settle(page); + assert.equal((await a.snapshot()).projection.body,'Risk-lonXg remains.\n'); + }finally{await page.close();} +}); + +test('guarded real native abort preserves newer actual caret and raw body draft', {timeout:60000},async()=>{ + const body='Risk remains.\n';const {page,a}=await setup(body); + try { + await page.evaluate(()=>editor.ghosts.ghost(0,4));await settle(page); + const baseline=await a.snapshot(); + await page.evaluate(()=>{ + window.nativeFixtureFailNextPaint=true; + window.nativeFixtureAbortDelay=1200; + editor.surface.focus();editor.surface.setSelectionOffsets(1,4); + }); + await page.keyboard.type('Q'); + await page.waitForFunction(()=>window.nativeFixtureAbortPending===true); + const pending=await page.evaluate(()=>({ + raw:editor.surface.serialise().text, + draft:structuredClone(editor.authorityDrafts.get('body')), + selection:editor.surface.readSelection() + })); + assert.equal(pending.raw,'RQ remains.\n'); + assert.equal(pending.draft.input.text,pending.raw); + assert.equal(pending.selection.start,2);assert.equal(pending.selection.end,2); + // This is actual browser keyboard selection movement WHILE a REAL guarded + // native abort is waiting. No selection setter, synthetic key or snapshot edit. + await page.keyboard.press('ArrowLeft'); + const moved=await page.evaluate(()=>editor.surface.readSelection()); + assert.equal(moved.start,1);assert.equal(moved.end,1); + // The public queue resolves its rejection handler; authoritySettled correctly + // refuses the still-unaccepted draft and must not be used as a success oracle. + await page.evaluate(()=>editor.authorityQueue); + await page.waitForFunction(()=>window.nativeFixtureAbortPending===false); + const after=await page.evaluate(()=>({ + raw:editor.surface.serialise().text,accepted:editor.surface.getText(), + draft:structuredClone(editor.authorityDrafts.get('body')), + selection:editor.surface.readSelection(),fault:!!editor.authorityFault, + pendingDecorations:!!editor.surface.authorityDecorationsPending + })); + assert.equal((await a.snapshot()).source,baseline.source, + 'guarded abort changed actual accepted native source/annotations'); + assert.equal(after.accepted,body); + assert.equal(after.raw,pending.raw,'rollback lost later raw text'); + assert.equal(after.draft.sequence,pending.draft.sequence); + assert.equal(after.draft.input.text,pending.raw); + assert.deepEqual(after.selection,moved,'rollback restored stale input-event caret'); + assert.equal(after.fault,false,'confirmed abort incorrectly retired native authority'); + // The one-shot fixture failure is already consumed: an actual next key accepts + // the retained raw document at the preserved caret through the real worker. + await page.keyboard.type('X');await settle(page); + assert.equal((await a.snapshot()).projection.body,'RXQ remains.\n'); + assert.equal((await state(page)).drafts.intents.length,0); + } finally { await page.close(); } +}); diff --git a/tests/history_checkpoints_native.rs b/tests/history_checkpoints_native.rs index f3ee69c..526ba68 100644 --- a/tests/history_checkpoints_native.rs +++ b/tests/history_checkpoints_native.rs @@ -175,3 +175,151 @@ fn public_strict_group_refusal_preserves_every_session_field() { let started = begin_history_replay_json("a😀b", "a😀b", "[]").unwrap(); abort_history_transaction_json(started["token"].as_str().unwrap()).unwrap(); } + +fn ghost_edit_fixture() -> String { + let mut doc = Document::new("Risk needs review."); + doc.annotations.ghosts.push(Ghost { + id: "g1".into(), + anchor: Anchor::new(0, 4, "Risk"), + }); + write(&doc) +} + +#[test] +fn issued_text_edit_receipt_restores_partial_ghost_and_tail_deletion_exactly() { + for (start, deleted, inserted) in [(1, "isk", "isk-long"), (2, "sk", "")] { + let original = ghost_edit_fixture(); + with_session(|s| { + s.open(&original); + }); + let receipt = with_session(|s| { + s.apply_edit(start, deleted.len(), inserted) + .unwrap() + .to_json()["ghostEdit"] + .clone() + }); + assert!( + receipt.is_object(), + "actual edit must issue private Ghost mapping receipt" + ); + let after = with_session(|s| s.save()); + let body_after = with_session(|s| s.body().to_owned()); + let undo = json!([{"kind":"edit","body":"Risk needs review.","start":start,"deletedText":inserted,"insertedText":deleted,"ghostEdit":{"receipt":receipt["receipt"],"undo":true}}]).to_string(); + let begun = begin_history_replay_json(&body_after, "Risk needs review.", &undo).unwrap(); + finish_history_transaction_json(begun["token"].as_str().unwrap()).unwrap(); + assert_eq!(with_session(|s| s.save()), original); + let redo = json!([{"kind":"edit","body":body_after,"start":start,"deletedText":deleted,"insertedText":inserted,"ghostEdit":receipt}]).to_string(); + let begun = begin_history_replay_json("Risk needs review.", &body_after, &redo).unwrap(); + finish_history_transaction_json(begun["token"].as_str().unwrap()).unwrap(); + assert_eq!(with_session(|s| s.save()), after); + } +} + +#[test] +fn text_ghost_receipts_refuse_forgery_stale_state_cross_session_and_late_group_failure() { + let original = ghost_edit_fixture(); + with_session(|s| { + s.open(&original); + }); + let receipt = with_session(|s| s.apply_edit(2, 2, "").unwrap().to_json()["ghostEdit"].clone()); + let changed = with_session(|s| s.clone()); + let undo_op = json!({"kind":"edit","body":"Risk needs review.","start":2,"deletedText":"","insertedText":"sk","ghostEdit":{"receipt":receipt["receipt"],"undo":true}}); + for field in ["receipt", "start", "insertedText"] { + let mut forged = undo_op.clone(); + match field { + "receipt" => forged["ghostEdit"]["receipt"] = json!("unissued-text-ghost"), + "start" => forged["start"] = json!(1), + _ => forged["insertedText"] = json!("SK"), + } + assert!(begin_history_replay_json( + "Ri needs review.", + "Risk needs review.", + &json!([forged]).to_string() + ) + .is_err()); + assert_eq!(with_session(|s| s.clone()), changed); + } + // A valid first receipt must not publish anything if a later operation fails. + let group = json!([undo_op.clone(), {"kind":"edit","body":"wrong","start":0,"deletedText":"Risk","insertedText":"wrong"}]); + assert!(begin_history_replay_json("Ri needs review.", "wrong", &group.to_string()).is_err()); + assert_eq!(with_session(|s| s.clone()), changed); + with_session(|s| { + s.revive(0, 2).unwrap(); + }); + let stale = with_session(|s| s.clone()); + assert!(begin_history_replay_json( + "Ri needs review.", + "Risk needs review.", + &json!([undo_op.clone()]).to_string() + ) + .is_err()); + assert_eq!(with_session(|s| s.clone()), stale); + // Same serialized source in a new session has no old private issued registry. + with_session(|s| { + s.open(&changed.save()); + }); + let reopened = with_session(|s| s.clone()); + assert!(begin_history_replay_json( + "Ri needs review.", + "Risk needs review.", + &json!([undo_op]).to_string() + ) + .is_err()); + assert_eq!(with_session(|s| s.clone()), reopened); + let next_receipt = with_session(|s| { + s.open(&original); + s.apply_edit(2, 2, "").unwrap().to_json()["ghostEdit"].clone() + }); + assert_ne!( + next_receipt["receipt"], receipt["receipt"], + "open never reissues an old opaque name" + ); +} + +#[test] +fn native_action_issues_the_same_actual_edit_metadata_and_abort_retires_it() { + use sha2::{Digest, Sha256}; + use terraphim_editor::begin_document_action_json; + let hash = |text: &str| format!("{:x}", Sha256::digest(text.as_bytes())); + with_session(|s| { + s.open(&ghost_edit_fixture()); + }); + let original = with_session(|s| s.clone()); + let meta = json!({"kind":"text_edit","start":2,"deleteUtf16":2,"insertedRef":0,"beforeBodyHash":hash("Risk needs review."),"afterBodyHash":hash("Ri needs review.")}); + let started = + begin_document_action_json("Risk needs review.", &meta.to_string(), &[String::new()]) + .unwrap() + .unwrap(); + let receipt = started["result"]["edit"]["ghostEdit"].clone(); + assert!(receipt.is_object()); + assert_eq!(receipt, started["result"]["ghostEdit"]); + abort_history_transaction_json(started["token"].as_str().unwrap()).unwrap(); + assert_eq!(with_session(|s| s.clone()), original); + let redo = json!([{"kind":"edit","body":"Ri needs review.","start":2,"deletedText":"sk","insertedText":"","ghostEdit":receipt}]); + assert!( + begin_history_replay_json("Risk needs review.", "Ri needs review.", &redo.to_string()) + .is_err(), + "aborted preparation never admits its receipt" + ); +} + +#[test] +fn malformed_text_ghost_metadata_and_ghost_free_edits_keep_strict_contracts() { + for reference in [ + json!({"receipt":"x"}), + json!({"receipt":"x","undo":0}), + json!({"receipt":"x","undo":true,"snapshot":{}}), + ] { + let op = json!([{"kind":"edit","body":"a","start":0,"deletedText":"","insertedText":"a","ghostEdit":reference}]); + assert!(parse_history_operations(&op.to_string()).is_err()); + } + with_session(|s| { + s.open("abc"); + assert!(s + .apply_edit(3, 0, "X") + .unwrap() + .to_json() + .get("ghostEdit") + .is_none()); + }); +} diff --git a/tests/web_ghosts.rs b/tests/web_ghosts.rs index e1a8358..d9a6bd7 100644 --- a/tests/web_ghosts.rs +++ b/tests/web_ghosts.rs @@ -480,3 +480,34 @@ fn test_deleting_ghosted_text_then_undo_restores_the_ghost() { })()"##, ); } + +/// R-5.3: text-exact inverse edits are not necessarily annotation-exact. +/// Uses the real public editing surface and Rust session, not native UI input. +#[wasm_bindgen_test] +fn test_partial_ghost_typing_burst_and_tail_delete_undo_restore_exact_source() { + setup(); + problems( + r##"(() => { + const failures = []; + for (const mode of ['burst', 'tail']) { + teGh.open('Risk needs mitigation.\n'); + const ed = teGh.ed(), s = teGh.s(); + ed.ghosts.ghost(0, 4); + const before = ed.documentApi().save_document(); + if (mode === 'burst') { + s.replaceRange(1, 4, 'i', {source:'typing'}); + for (const c of 'sk-long') s.replaceRange(s.getText().indexOf(' needs'), s.getText().indexOf(' needs'), c, {source:'typing'}); + } else s.replaceRange(3, 4, '', {source:'delete'}); + const after = ed.documentApi().save_document(); + if (!s.undo()) failures.push(mode + ': undo refused'); + if (ed.documentApi().save_document() !== before) failures.push(mode + ': undo lost Ghost extent'); + if (!s.redo()) failures.push(mode + ': redo refused'); + if (ed.documentApi().save_document() !== after) failures.push(mode + ': redo changed source'); + const reopened = ed.documentApi().save_document(); + teGh.open(reopened); + if (ed.documentApi().save_document() !== reopened) failures.push(mode + ': reopen changed source'); + } + return failures.join('; '); + })()"##, + ); +} From 5319ecd215e1892f479999dc544fd85ab4222737 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sun, 18 Oct 2026 11:41:44 +0100 Subject: [PATCH 15/16] fix: map Blocks drafts through acknowledged history --- public/js/blocks.js | 37 +++++++++++++++++-- public/js/editor.js | 10 +++-- ...-authority-controller.integration.test.cjs | 19 ++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/public/js/blocks.js b/public/js/blocks.js index b470da8..067c178 100644 --- a/public/js/blocks.js +++ b/public/js/blocks.js @@ -747,19 +747,50 @@ class BlocksView { } } + /** Map an acknowledged native replay without mirroring model/decorations twice. + * The following aggregate surface notification performs the final render. + */ + replayAcceptedHistory(applied, source) { + const changes = applied.filter(({edit}) => edit && !edit.ghost && + (edit.move || edit.deletedLength > 0 || edit.insertedText.length > 0)) + .map(({edit,text}) => ({edit,text,source})); + if (!changes.length) return; + if (this.authorityApplying) { + this.authorityChanges.push(...changes); + this.dirty = true; + return; + } + const hadFocus = this.container.contains(document.activeElement); + let overlapped = null; + for (const change of changes) { + const edit = change.edit, whole = BlocksView.replacesWhole(change); + if (this.drafts.length) this.mapDrafts(edit, whole); + if (this.editing && !this.mapEditing(edit, whole)) { + overlapped = this.editing; + this.editing = null; + this.keepDraft(overlapped, edit, whole); + } + } + if (this.drafts.length) this.renderDrafts(); + this.acceptedHistoryPresentation = {hadFocus,overlapped,source}; + } + onSurfaceChange(change) { if(this.authorityApplying){this.authorityChanges.push(change);this.dirty=true;return;} + const acceptedHistory = !change?.edit && change?.source === this.acceptedHistoryPresentation?.source + ? this.acceptedHistoryPresentation : null; + this.acceptedHistoryPresentation = null; const edit = change && change.edit; const whole = !!edit && BlocksView.replacesWhole(change); // Kept drafts follow every change, in either view. if (edit && this.drafts.length) this.mapDrafts(edit, whole); - let overlapped = null; + let overlapped = acceptedHistory?.overlapped || null; if (this.editing && edit && !this.mapEditing(edit, whole)) { overlapped = this.editing; this.editing = null; } - const hadFocus = this.container.contains(document.activeElement); - if (overlapped) this.keepDraft(overlapped, edit, whole); + const hadFocus = !!acceptedHistory?.hadFocus || this.container.contains(document.activeElement); + if (overlapped && !acceptedHistory?.overlapped) this.keepDraft(overlapped, edit, whole); else if (edit && this.drafts.length) this.renderDrafts(); if (!this.isActive()) return; if (this.applying) { diff --git a/public/js/editor.js b/public/js/editor.js index 9746acc..2534535 100644 --- a/public/js/editor.js +++ b/public/js/editor.js @@ -2122,7 +2122,7 @@ class MarkdownEditor { surface.historyIndex=index;this.paintGhostHistory(started.annotations,applied); surface.setSelectionOffsets(target.start,target.end); }); - if(accepted)this.notifyGhostHistory(direction==='undo'?'historyUndo':'historyRedo',accepted.annotations); + if(accepted)this.notifyGhostHistory(direction==='undo'?'historyUndo':'historyRedo',accepted.annotations,applied); return !!accepted; }); } @@ -2353,14 +2353,16 @@ class MarkdownEditor { else s.setSelectionOffsets(entry.start, entry.end); }); if (!result) throw new Error('Native mixed history begin returned an invalid no-op'); - this.notifyGhostHistory(source === 'undo' ? 'historyUndo' : 'historyRedo', result.annotations); + this.notifyGhostHistory(source === 'undo' ? 'historyUndo' : 'historyRedo', result.annotations,applied); } - notifyGhostHistory(inputType, annotations = null) { + notifyGhostHistory(inputType, annotations = null, applied = []) { // Native and registry already replayed every edit. Notify observers once // without an edit envelope (so ranges are not mapped twice), only after // final acknowledgement. Suppress only the duplicate model mirror. try { + const source = inputType === 'historyUndo' ? 'undo' : inputType === 'historyRedo' ? 'redo' : 'trim'; + if (applied.length) this.blocks?.replayAcceptedHistory?.(applied,source); if (annotations) { const count = annotations.setAside.spans.length + annotations.setAside.ghosts.length; const notice = count === 0 ? null : `${count} ${count === 1 ? 'annotation (alternatives or ghost) is' : 'annotations (alternatives or ghosts) are'} out of step with the text. ${count === 1 ? 'It is' : 'They are'} preserved and saved with the document, and ${count === 1 ? 'is' : 'are'} re-attached if the text returns.`; @@ -2371,7 +2373,7 @@ class MarkdownEditor { const previous = this.suppressModelSync; this.suppressModelSync = true; try { - this.surface.emitChange(null, inputType === 'historyUndo' ? 'undo' : inputType === 'historyRedo' ? 'redo' : 'trim'); + this.surface.emitChange(null, source); } finally { this.suppressModelSync = previous; } if (this.indicators) this.indicators.flush(); this.surface.dispatchInput(inputType, null); diff --git a/tests/browser/native-authority-controller.integration.test.cjs b/tests/browser/native-authority-controller.integration.test.cjs index e418592..6864683 100644 --- a/tests/browser/native-authority-controller.integration.test.cjs +++ b/tests/browser/native-authority-controller.integration.test.cjs @@ -528,3 +528,22 @@ test('guarded real native abort preserves newer actual caret and raw body draft' assert.equal((await state(page)).drafts.intents.length,0); } finally { await page.close(); } }); + +test('accepted native block Undo Redo maps kept draft anchors exactly once', {timeout:60000},async()=>{ + const {page,a}=await setup('A\n\nX\n\nC\n'); + try{ + await page.evaluate(async()=>{await editor.blocks.setView('blocks');await editor.blocks.editBlock(1);}); + await page.locator('.te-block-editor').fill('X draft');await page.keyboard.press('Escape'); + await page.waitForFunction(()=>!editor.blocks.editing&&editor.blocks.keptDrafts().length===1); + await page.evaluate(async()=>{await editor.blocks.moveBlock(1,'down');await editor.blocks.moveBlock(2,'up');}); + assert.equal(await page.evaluate(()=>editor.blocks.keptDrafts()[0].anchor),3); + await page.evaluate(()=>editor.blocks.undo()); + assert.equal((await a.snapshot()).projection.body,'A\n\nC\n\nX\n'); + assert.equal(await page.evaluate(()=>editor.blocks.keptDrafts()[0].anchor),6); + await page.evaluate(()=>editor.blocks.redo());assert.equal(await page.evaluate(()=>editor.blocks.keptDrafts()[0].anchor),3); + await page.evaluate(()=>editor.blocks.undo()); + assert.equal(await page.evaluate(()=>editor.blocks.applyDraft(editor.blocks.keptDrafts()[0].id)),'replace'); + await settle(page);assert.equal((await a.snapshot()).projection.body,'A\n\nC\n\nX draft\n'); + assert.equal((await state(page)).drafts.intents.length,0); + }finally{await page.close();} +}); From 49f2159ca2ce51e4884f29147019fe40c314491c Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Sun, 18 Oct 2026 13:02:11 +0100 Subject: [PATCH 16/16] fix: keep the word counter above open side panels --- public/css/write-on.css | 3 ++- tests/web_chrome_occlusion.rs | 40 +++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/public/css/write-on.css b/public/css/write-on.css index 81fe76a..16b8fee 100644 --- a/public/css/write-on.css +++ b/public/css/write-on.css @@ -115,7 +115,8 @@ position: fixed; top: 1px; left: 20px; - z-index: 20; + /* Like the corner groups, stay above the full-height side panels. */ + z-index: 21; display: inline-flex; align-items: baseline; gap: 0.5ch; diff --git a/tests/web_chrome_occlusion.rs b/tests/web_chrome_occlusion.rs index dc64675..6527f9f 100644 --- a/tests/web_chrome_occlusion.rs +++ b/tests/web_chrome_occlusion.rs @@ -27,3 +27,43 @@ fn save_and_open_remain_pointer_targets_above_the_alternatives_panel() { ); assert_eq!(result, ""); } + +#[wasm_bindgen_test] +fn counter_remains_a_pointer_target_with_alternatives_and_overflow_open() { + let _document = fresh_full_editor(); + let result = js_string( + r##"(() => { + const ed = window.__teEditor; + const counter = ed.chrome.counter; + const errors = []; + const hit = element => { + const rect = element.getBoundingClientRect(); + return document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2); + }; + for (const panel of [ed.altPanel, ed.overflow]) { + ed.chrome.setMode('write-on'); + panel.open(); + if (!panel.isOpen()) errors.push('panel did not open'); + const panelControls = panel === ed.altPanel + ? Object.values(ed.altPanel.tabs) : [ed.overflow.closeButton]; + for (const control of panelControls) { + const panelTarget = hit(control); + if (!panelTarget || !control.contains(panelTarget)) errors.push('panel control covered'); + } + const target = hit(counter); + if (!target || !counter.contains(target)) { + errors.push('counter covered by ' + target?.className); + panel.close(); + continue; + } + // Dispatch to the actual browser hit target, not directly to a + // covered button. This is a DOM pointer contract, not trusted UI. + target.click(); + if (counter.getAttribute('aria-pressed') !== 'false') errors.push('counter click did not leave Write_On'); + if (panel.isOpen()) errors.push('counter mode change left panel open'); + } + return errors.join('; '); + })()"##, + ); + assert_eq!(result, ""); +}