web: protect config schema wrapper
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
const MAIN_ENTRYPOINT = "main.dcdl";
|
const MAIN_ENTRYPOINT = "main.dcdl";
|
||||||
|
const WORKSPACE_SCHEMA_ASSERTION = "WorkspaceConfigSchema";
|
||||||
|
const WORKSPACE_SCHEMA_SUFFIX = `} as ${WORKSPACE_SCHEMA_ASSERTION}`;
|
||||||
|
|
||||||
let { workspaceId }: { workspaceId: string } = $props();
|
let { workspaceId }: { workspaceId: string } = $props();
|
||||||
let treeState = $state<WorkspaceConfigTreeResponse | null>(null);
|
let treeState = $state<WorkspaceConfigTreeResponse | null>(null);
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined,
|
treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined,
|
||||||
);
|
);
|
||||||
const mainSelected = $derived(selectedPath === MAIN_ENTRYPOINT);
|
const mainSelected = $derived(selectedPath === MAIN_ENTRYPOINT);
|
||||||
|
const mainSchemaWrapped = $derived(mainSelected && hasWorkspaceSchemaWrapper(source));
|
||||||
const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
||||||
const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest);
|
const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest);
|
||||||
|
|
||||||
@@ -114,6 +117,35 @@
|
|||||||
return [MAIN_ENTRYPOINT];
|
return [MAIN_ENTRYPOINT];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasWorkspaceSchemaWrapper(value: string): boolean {
|
||||||
|
const sourceWithoutTrailingWhitespace = value.trimEnd();
|
||||||
|
return value.startsWith("{") && sourceWithoutTrailingWhitespace.endsWith(WORKSPACE_SCHEMA_SUFFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wrapMainWithWorkspaceSchema() {
|
||||||
|
if (!toolchain || !mainSelected || mainSchemaWrapped || busy) return;
|
||||||
|
const candidate = source.trim();
|
||||||
|
if (!candidate.startsWith("{") || !candidate.endsWith("}")) {
|
||||||
|
status = "WorkspaceConfigSchema can only wrap a top-level object source.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
source = await toolchain.format(`${candidate} as ${WORKSPACE_SCHEMA_ASSERTION}`);
|
||||||
|
diagnostics = await toolchain.analyze(selectedPath, source);
|
||||||
|
preflightDigest = "";
|
||||||
|
candidateContract = null;
|
||||||
|
conflict = false;
|
||||||
|
status = diagnostics.length === 0
|
||||||
|
? "WorkspaceConfigSchema wrapper staged. Preview and Commit to persist it."
|
||||||
|
: `${diagnostics.length} diagnostic(s) after adding WorkspaceConfigSchema.`;
|
||||||
|
} catch (error) {
|
||||||
|
status = String(error);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function analyze() {
|
async function analyze() {
|
||||||
if (!toolchain || !treeState || !selectedPath) return;
|
if (!toolchain || !treeState || !selectedPath) return;
|
||||||
diagnostics = await toolchain.analyze(selectedPath, source);
|
diagnostics = await toolchain.analyze(selectedPath, source);
|
||||||
@@ -312,6 +344,9 @@
|
|||||||
<div class="config-source-actions">
|
<div class="config-source-actions">
|
||||||
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} />
|
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} />
|
||||||
<button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
|
<button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
|
||||||
|
{#if mainSelected && !mainSchemaWrapped}
|
||||||
|
<button type="button" onclick={wrapMainWithWorkspaceSchema} disabled={!selected || busy}>Wrap with WorkspaceConfigSchema</button>
|
||||||
|
{/if}
|
||||||
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
|
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
|
||||||
<button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button>
|
<button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button>
|
||||||
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
|
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
|
||||||
@@ -322,6 +357,7 @@
|
|||||||
<DecodalSourceEditor
|
<DecodalSourceEditor
|
||||||
value={source}
|
value={source}
|
||||||
readonly={!selectedPath || busy}
|
readonly={!selectedPath || busy}
|
||||||
|
fixedSchemaWrapper={mainSchemaWrapped}
|
||||||
onChange={(value) => source = value}
|
onChange={(value) => source = value}
|
||||||
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
|
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
EditorSelection,
|
||||||
|
EditorState,
|
||||||
|
type Extension,
|
||||||
|
Prec,
|
||||||
|
} from "@codemirror/state";
|
||||||
|
import { Decoration, EditorView, keymap } from "@codemirror/view";
|
||||||
|
|
||||||
|
const WORKSPACE_SCHEMA_SUFFIX = "} as WorkspaceConfigSchema";
|
||||||
|
const fixedWrapperMark = Decoration.mark({
|
||||||
|
class: "cm-fixed-schema-wrapper",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FixedWrapperBounds = {
|
||||||
|
bodyFrom: number;
|
||||||
|
bodyTo: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fixedWrapperBounds(
|
||||||
|
state: EditorState,
|
||||||
|
): FixedWrapperBounds | null {
|
||||||
|
const source = state.doc.toString();
|
||||||
|
if (!source.startsWith("{")) return null;
|
||||||
|
const sourceWithoutTrailingWhitespace = source.trimEnd();
|
||||||
|
if (!sourceWithoutTrailingWhitespace.endsWith(WORKSPACE_SCHEMA_SUFFIX)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const bodyTo = sourceWithoutTrailingWhitespace.length -
|
||||||
|
WORKSPACE_SCHEMA_SUFFIX.length;
|
||||||
|
if (bodyTo < 1) return null;
|
||||||
|
return { bodyFrom: 1, bodyTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedWrapperDecorations(state: EditorState) {
|
||||||
|
const bounds = fixedWrapperBounds(state);
|
||||||
|
if (!bounds) return Decoration.none;
|
||||||
|
return Decoration.set([
|
||||||
|
fixedWrapperMark.range(0, bounds.bodyFrom),
|
||||||
|
fixedWrapperMark.range(bounds.bodyTo, state.doc.length),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fixedSchemaWrapperExtension(): Extension {
|
||||||
|
return [
|
||||||
|
EditorView.decorations.of((editor) =>
|
||||||
|
fixedWrapperDecorations(editor.state)
|
||||||
|
),
|
||||||
|
EditorView.atomicRanges.of((editor) =>
|
||||||
|
fixedWrapperDecorations(editor.state)
|
||||||
|
),
|
||||||
|
EditorState.transactionFilter.of((transaction) => {
|
||||||
|
const bounds = fixedWrapperBounds(transaction.startState);
|
||||||
|
if (!bounds || !transaction.docChanged) return transaction;
|
||||||
|
let allowed = true;
|
||||||
|
transaction.changes.iterChangedRanges((from, to) => {
|
||||||
|
if (from < bounds.bodyFrom || to > bounds.bodyTo) allowed = false;
|
||||||
|
});
|
||||||
|
return allowed ? transaction : [];
|
||||||
|
}),
|
||||||
|
Prec.highest(
|
||||||
|
keymap.of([{
|
||||||
|
key: "Mod-a",
|
||||||
|
run: (editor) => {
|
||||||
|
const bounds = fixedWrapperBounds(editor.state);
|
||||||
|
if (!bounds) return false;
|
||||||
|
editor.dispatch({
|
||||||
|
selection: EditorSelection.range(bounds.bodyFrom, bounds.bodyTo),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}]),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveSelectionIntoFixedWrapper(editor: EditorView) {
|
||||||
|
const bounds = fixedWrapperBounds(editor.state);
|
||||||
|
if (!bounds) return;
|
||||||
|
const selectionInsideBody = editor.state.selection.ranges.every((range) =>
|
||||||
|
range.from >= bounds.bodyFrom && range.to <= bounds.bodyTo
|
||||||
|
);
|
||||||
|
if (!selectionInsideBody) {
|
||||||
|
editor.dispatch({
|
||||||
|
selection: EditorSelection.cursor(bounds.bodyFrom),
|
||||||
|
filter: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,17 +6,23 @@
|
|||||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||||
import { tags } from '@lezer/highlight';
|
import { tags } from '@lezer/highlight';
|
||||||
import { decodal } from 'decodal-codemirror';
|
import { decodal } from 'decodal-codemirror';
|
||||||
|
import {
|
||||||
|
fixedSchemaWrapperExtension,
|
||||||
|
moveSelectionIntoFixedWrapper,
|
||||||
|
} from '$lib/workspace/config-source/fixed-schema-wrapper.ts';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
value = '',
|
value = '',
|
||||||
readonly = false,
|
readonly = false,
|
||||||
ariaLabel = 'Decodal source',
|
ariaLabel = 'Decodal source',
|
||||||
|
fixedSchemaWrapper = false,
|
||||||
onChange = (_value: string) => {},
|
onChange = (_value: string) => {},
|
||||||
onComplete = undefined,
|
onComplete = undefined,
|
||||||
}: {
|
}: {
|
||||||
value?: string;
|
value?: string;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
|
fixedSchemaWrapper?: boolean;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
onComplete?: (source: string, utf16Offset: number, explicit: boolean) => Promise<CompletionResult | null>;
|
onComplete?: (source: string, utf16Offset: number, explicit: boolean) => Promise<CompletionResult | null>;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -24,6 +30,7 @@
|
|||||||
let host = $state<HTMLDivElement | null>(null);
|
let host = $state<HTMLDivElement | null>(null);
|
||||||
let view = $state.raw<EditorView | null>(null);
|
let view = $state.raw<EditorView | null>(null);
|
||||||
const readonlyCompartment = new Compartment();
|
const readonlyCompartment = new Compartment();
|
||||||
|
const fixedSchemaWrapperCompartment = new Compartment();
|
||||||
|
|
||||||
const syntaxTheme = HighlightStyle.define([
|
const syntaxTheme = HighlightStyle.define([
|
||||||
{ tag: tags.keyword, color: 'var(--accent)', fontWeight: '700' },
|
{ tag: tags.keyword, color: 'var(--accent)', fontWeight: '700' },
|
||||||
@@ -47,6 +54,11 @@
|
|||||||
'&.cm-focused': { outline: '1px solid var(--accent-muted)', outlineOffset: '-1px' },
|
'&.cm-focused': { outline: '1px solid var(--accent-muted)', outlineOffset: '-1px' },
|
||||||
'.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' },
|
'.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' },
|
||||||
'.cm-content': { padding: '0.75rem 0', caretColor: 'var(--text-strong)' },
|
'.cm-content': { padding: '0.75rem 0', caretColor: 'var(--text-strong)' },
|
||||||
|
'.cm-fixed-schema-wrapper': {
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
backgroundColor: 'var(--interactive-muted)',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--text-strong)', borderLeftWidth: '2px' },
|
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--text-strong)', borderLeftWidth: '2px' },
|
||||||
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||||
backgroundColor: 'var(--interactive-selected)',
|
backgroundColor: 'var(--interactive-selected)',
|
||||||
@@ -61,6 +73,7 @@
|
|||||||
if (!host || untrack(() => view)) return;
|
if (!host || untrack(() => view)) return;
|
||||||
const initialValue = untrack(() => value);
|
const initialValue = untrack(() => value);
|
||||||
const initialReadonly = untrack(() => readonly);
|
const initialReadonly = untrack(() => readonly);
|
||||||
|
const initialFixedSchemaWrapper = untrack(() => fixedSchemaWrapper);
|
||||||
const handleChange = untrack(() => onChange);
|
const handleChange = untrack(() => onChange);
|
||||||
const handleComplete = untrack(() => onComplete);
|
const handleComplete = untrack(() => onComplete);
|
||||||
const editor = new EditorView({
|
const editor = new EditorView({
|
||||||
@@ -78,6 +91,9 @@
|
|||||||
return await handleComplete(doc, context.pos, context.explicit);
|
return await handleComplete(doc, context.pos, context.explicit);
|
||||||
}] })] : []),
|
}] })] : []),
|
||||||
keymap.of([]),
|
keymap.of([]),
|
||||||
|
fixedSchemaWrapperCompartment.of(
|
||||||
|
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
|
||||||
|
),
|
||||||
readonlyCompartment.of([
|
readonlyCompartment.of([
|
||||||
EditorState.readOnly.of(initialReadonly),
|
EditorState.readOnly.of(initialReadonly),
|
||||||
EditorView.editable.of(!initialReadonly),
|
EditorView.editable.of(!initialReadonly),
|
||||||
@@ -90,6 +106,7 @@
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
view = editor;
|
view = editor;
|
||||||
|
if (initialFixedSchemaWrapper) moveSelectionIntoFixedWrapper(editor);
|
||||||
return () => {
|
return () => {
|
||||||
editor.destroy();
|
editor.destroy();
|
||||||
view = null;
|
view = null;
|
||||||
@@ -108,11 +125,27 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const editor = view;
|
||||||
|
const enabled = fixedSchemaWrapper;
|
||||||
|
if (!editor) return;
|
||||||
|
editor.dispatch({
|
||||||
|
effects: fixedSchemaWrapperCompartment.reconfigure(
|
||||||
|
enabled ? fixedSchemaWrapperExtension() : [],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (enabled) moveSelectionIntoFixedWrapper(editor);
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
const current = view.state.doc.toString();
|
const current = view.state.doc.toString();
|
||||||
if (current !== value) {
|
if (current !== value) {
|
||||||
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: current.length, insert: value },
|
||||||
|
filter: false,
|
||||||
|
});
|
||||||
|
if (fixedSchemaWrapper) moveSelectionIntoFixedWrapper(view);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -72,4 +72,32 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () =
|
|||||||
!source.includes("--border-subtle"),
|
!source.includes("--border-subtle"),
|
||||||
"CodeMirror theme must use workspace tokens that actually exist",
|
"CodeMirror theme must use workspace tokens that actually exist",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
source.includes("fixedSchemaWrapperCompartment.reconfigure") &&
|
||||||
|
source.includes("fixedSchemaWrapperExtension()") &&
|
||||||
|
source.includes("filter: false"),
|
||||||
|
"fixed schema wrapper protection should react to the main source and permit authoritative synchronization",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("main config wrapper is an explicit canonical draft conversion", async () => {
|
||||||
|
const source = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
source.includes("Wrap with WorkspaceConfigSchema") &&
|
||||||
|
source.includes("wrapMainWithWorkspaceSchema") &&
|
||||||
|
source.includes("fixedSchemaWrapper={mainSchemaWrapped}"),
|
||||||
|
"legacy main sources should offer an explicit conversion before fixed wrapper mode is enabled",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
source.includes("source.trim()") &&
|
||||||
|
source.includes("toolchain.format") &&
|
||||||
|
source.includes("wrapper staged. Preview and Commit"),
|
||||||
|
"conversion should remain a formatted local draft until the normal commit flow",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
import { EditorState } from "@codemirror/state";
|
||||||
|
import {
|
||||||
|
fixedSchemaWrapperExtension,
|
||||||
|
fixedWrapperBounds,
|
||||||
|
} from "../../src/lib/workspace/config-source/fixed-schema-wrapper.ts";
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown, message: string) {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(
|
||||||
|
`${message}: expected ${String(expected)}, got ${String(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrappedState(source = "{} as WorkspaceConfigSchema\n") {
|
||||||
|
return EditorState.create({
|
||||||
|
doc: source,
|
||||||
|
extensions: [fixedSchemaWrapperExtension()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("fixed schema wrapper allows edits only inside the asserted object", () => {
|
||||||
|
const state = wrappedState();
|
||||||
|
const bounds = fixedWrapperBounds(state);
|
||||||
|
if (!bounds) throw new Error("canonical wrapper should be recognized");
|
||||||
|
assertEquals(bounds.bodyFrom, 1, "body should start after the opening brace");
|
||||||
|
assertEquals(
|
||||||
|
bounds.bodyTo,
|
||||||
|
1,
|
||||||
|
"empty body should end before the closing brace",
|
||||||
|
);
|
||||||
|
|
||||||
|
const inserted = state.update({
|
||||||
|
changes: { from: bounds.bodyFrom, insert: " profile = {}; " },
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
inserted.newDoc.toString(),
|
||||||
|
"{ profile = {}; } as WorkspaceConfigSchema\n",
|
||||||
|
"body insertion should be accepted",
|
||||||
|
);
|
||||||
|
|
||||||
|
const prefixEdit = state.update({ changes: { from: 0, to: 1, insert: "[" } });
|
||||||
|
assertEquals(
|
||||||
|
prefixEdit.newDoc.toString(),
|
||||||
|
state.doc.toString(),
|
||||||
|
"opening wrapper edit should be rejected",
|
||||||
|
);
|
||||||
|
|
||||||
|
const suffixEdit = state.update({
|
||||||
|
changes: { from: bounds.bodyTo, to: bounds.bodyTo + 1, insert: "]" },
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
suffixEdit.newDoc.toString(),
|
||||||
|
state.doc.toString(),
|
||||||
|
"schema assertion edit should be rejected",
|
||||||
|
);
|
||||||
|
|
||||||
|
const replaceAll = state.update({
|
||||||
|
changes: { from: 0, to: state.doc.length, insert: "{}" },
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
replaceAll.newDoc.toString(),
|
||||||
|
state.doc.toString(),
|
||||||
|
"a replacement touching fixed and editable ranges should be rejected atomically",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("authoritative synchronization can bypass the fixed wrapper filter", () => {
|
||||||
|
const state = wrappedState();
|
||||||
|
const synchronized = state.update({
|
||||||
|
changes: { from: 0, to: state.doc.length, insert: "{}" },
|
||||||
|
filter: false,
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
synchronized.newDoc.toString(),
|
||||||
|
"{}",
|
||||||
|
"programmatic source replacement should bypass user transaction filters",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
fixedWrapperBounds(synchronized.state),
|
||||||
|
null,
|
||||||
|
"bare source should not expose fixed wrapper ranges",
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user