Skip to content

Commit a08a28c

Browse files
ckbedwellVikaCep
andcommitted
feat: add custom validation to monaco (#1259)
* feat: add custom validation to monaco * fix: improve regex and minor changes * fix: tests * fix: lint --------- Co-authored-by: Virginia Cepeda <[email protected]>
1 parent be9c318 commit a08a28c

File tree

6 files changed

+124
-16
lines changed

6 files changed

+124
-16
lines changed

src/components/CodeEditor/CodeEditor.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CodeEditorProps, ConstrainedEditorProps } from './CodeEditor.types';
99
import k6Types from './k6.types';
1010

1111
import { initializeConstrainedInstance, updateConstrainedEditorRanges } from './CodeEditor.utils';
12+
import { wireCustomValidation } from './monacoValidation';
1213

1314
const addK6Types = (monaco: typeof monacoType) => {
1415
Object.entries(k6Types).map(([name, type]) => {
@@ -116,6 +117,9 @@ export const CodeEditor = forwardRef(function CodeEditor(
116117
handleValidation(monaco, editor);
117118
});
118119

120+
// Wire custom red-squiggle markers for forbidden syntax
121+
const disposeCustomValidation = wireCustomValidation(monaco, editor);
122+
119123
if (constrainedRanges) {
120124
const instance = initializeConstrainedInstance(monaco, editor);
121125
const model = editor.getModel();
@@ -126,6 +130,13 @@ export const CodeEditor = forwardRef(function CodeEditor(
126130
updateConstrainedEditorRanges(instance, model, value, constrainedRanges, onDidChangeContentInEditableRange);
127131
setConstrainedInstance(instance);
128132
}
133+
134+
// Cleanup subscriptions on dispose
135+
editor.onDidDispose(() => {
136+
if (typeof disposeCustomValidation === 'function') {
137+
disposeCustomValidation();
138+
}
139+
});
129140
};
130141

131142
useEffect(() => {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api';
2+
import { findRuleViolations } from 'schemas/forms/script/rules';
3+
4+
const OWNER = 'sm-custom-validation';
5+
6+
export function applyCustomScriptMarkers(monaco: typeof monacoType, model: monacoType.editor.ITextModel) {
7+
const script = model.getValue();
8+
const violations = findRuleViolations(script);
9+
10+
const markers: monacoType.editor.IMarkerData[] = violations.map(({ startIndex, endIndex, message }) => {
11+
const start = model.getPositionAt(startIndex);
12+
const end = model.getPositionAt(endIndex);
13+
return {
14+
severity: monaco.MarkerSeverity.Error,
15+
message,
16+
startLineNumber: start.lineNumber,
17+
startColumn: start.column,
18+
endLineNumber: end.lineNumber,
19+
endColumn: end.column,
20+
source: 'Synthetic Monitoring',
21+
};
22+
});
23+
24+
monaco.editor.setModelMarkers(model, OWNER, markers);
25+
}
26+
27+
export function wireCustomValidation(monaco: typeof monacoType, editor: monacoType.editor.IStandaloneCodeEditor) {
28+
const model = editor.getModel();
29+
if (!model) {
30+
return;
31+
}
32+
33+
// Initial run
34+
applyCustomScriptMarkers(monaco, model);
35+
36+
// Update on content changes
37+
const disposeOnChangeModelContent = editor.onDidChangeModelContent(() => {
38+
const currentModel = editor.getModel();
39+
if (currentModel) {
40+
applyCustomScriptMarkers(monaco, currentModel);
41+
}
42+
});
43+
44+
// Update when model changes
45+
const disposeOnChangeModel = editor.onDidChangeModel(() => {
46+
const currentModel = editor.getModel();
47+
if (currentModel) {
48+
applyCustomScriptMarkers(monaco, currentModel);
49+
}
50+
});
51+
52+
return () => {
53+
disposeOnChangeModelContent.dispose();
54+
disposeOnChangeModel.dispose();
55+
};
56+
}

src/page/NewCheck/__tests__/BrowserChecks/Scripted/1-script.ui.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { screen } from '@testing-library/react';
2+
import { K6_PRAGMA_MESSAGE } from 'schemas/forms/script/rules';
23

34
import { CheckType } from 'types';
45
import { renderNewForm, submitForm } from 'page/__testHelpers__/checkForm';
@@ -147,7 +148,7 @@ ${exportCorrectOptions}`;
147148
await user.type(scriptTextAreaPreSubmit, scriptWithPragma);
148149

149150
await submitForm(user);
150-
const err = await screen.findByText('Script contains a k6 version pragma which is not allowed. Please remove the "use k6" directive.');
151+
const err = await screen.findByText(K6_PRAGMA_MESSAGE);
151152
expect(err).toBeInTheDocument();
152153
});
153154

src/page/NewCheck/__tests__/ScriptedChecks/Scripted/1-script.ui.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { screen, waitFor } from '@testing-library/react';
2+
import { K6_PRAGMA_MESSAGE } from 'schemas/forms/script/rules';
23

34
import { CheckType } from 'types';
45
import { renderNewForm, submitForm } from 'page/__testHelpers__/checkForm';
@@ -51,7 +52,7 @@ export default function() {
5152
await fillMandatoryFields({ user, fieldsToOmit: [`probes`], checkType });
5253

5354
await submitForm(user);
54-
const err = await screen.findByText('Script contains a k6 version pragma which is not allowed. Please remove the "use k6" directive.');
55+
const err = await screen.findByText(K6_PRAGMA_MESSAGE);
5556
expect(err).toBeInTheDocument();
5657
});
5758

src/schemas/forms/script/rules.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Shared script validation rules for both Zod validation and Monaco markers
2+
3+
export const K6_PRAGMA_MESSAGE =
4+
'Version directives cannot be used within scripts. Please remove any "use k6" statements.';
5+
6+
export const K6_EXTENSION_MESSAGE =
7+
'Script imports k6 extensions which are not allowed. Please remove imports from k6/x/ paths.';
8+
9+
// Match patterns like: "use k6 >= v1.0.0", "use k6 > 0.52", `use k6 >= v1.0.0`, etc.
10+
export const K6_PRAGMA_REGEX = /["'`]use\s+k6\s*[><=!]+\s*v?\d+/gi;
11+
12+
// Match import statements that include k6/x/ paths
13+
export const K6_EXTENSION_IMPORT_REGEX = /import\s+.*from\s*[\'"`][^'"`]*k6\/x\/[^'"`]*[\'"`]/gi;
14+
15+
export function hasK6Pragma(script: string): boolean {
16+
K6_PRAGMA_REGEX.lastIndex = 0;
17+
return K6_PRAGMA_REGEX.test(script);
18+
}
19+
20+
export function hasK6ExtensionImports(script: string): boolean {
21+
K6_EXTENSION_IMPORT_REGEX.lastIndex = 0;
22+
return K6_EXTENSION_IMPORT_REGEX.test(script);
23+
}
24+
25+
export type ScriptRuleMatch = {
26+
startIndex: number;
27+
endIndex: number;
28+
message: string;
29+
};
30+
31+
export function findRuleViolations(script: string): ScriptRuleMatch[] {
32+
const matches: ScriptRuleMatch[] = [];
33+
34+
const checkRegex = (pattern: RegExp, message: string) => {
35+
pattern.lastIndex = 0;
36+
let m: RegExpExecArray | null;
37+
while ((m = pattern.exec(script))) {
38+
matches.push({ startIndex: m.index, endIndex: m.index + m[0].length, message });
39+
// Prevent infinite loops on zero-length matches
40+
if (pattern.lastIndex === m.index) {
41+
pattern.lastIndex++;
42+
}
43+
}
44+
};
45+
46+
checkRegex(K6_PRAGMA_REGEX, K6_PRAGMA_MESSAGE);
47+
checkRegex(K6_EXTENSION_IMPORT_REGEX, K6_EXTENSION_MESSAGE);
48+
49+
return matches;
50+
}

src/schemas/forms/script/validation.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,22 @@
11
import { RefinementCtx, ZodIssueCode } from 'zod';
22

33
import { extractImportStatement, extractOptionsExport, getProperty, parseScript } from './parser';
4+
import { hasK6ExtensionImports, hasK6Pragma, K6_EXTENSION_MESSAGE, K6_PRAGMA_MESSAGE } from './rules';
45

56
const MAX_SCRIPT_IN_KB = 128;
67

7-
function hasK6Pragma(script: string): boolean {
8-
// Match patterns like: "use k6 >= v1.0.0", "use k6 > 0.52", etc.
9-
const pragmaPattern = /["']use\s+k6\s*[><=!]+\s*v?\d+/i;
10-
return pragmaPattern.test(script);
11-
}
12-
13-
function hasK6ExtensionImports(script: string): boolean {
14-
// Match import statements that include k6/x/ paths
15-
const extensionPattern = /import\s+.*from\s*['"`][^'"`]*k6\/x\/[^'"`]*['"`]/i;
16-
return extensionPattern.test(script);
17-
}
18-
198
function validateScriptPragmasAndExtensions(script: string, context: RefinementCtx): void {
209
if (hasK6Pragma(script)) {
2110
context.addIssue({
2211
code: ZodIssueCode.custom,
23-
message: 'Script contains a k6 version pragma which is not allowed. Please remove the "use k6" directive.',
12+
message: K6_PRAGMA_MESSAGE,
2413
});
2514
}
2615

2716
if (hasK6ExtensionImports(script)) {
2817
context.addIssue({
2918
code: ZodIssueCode.custom,
30-
message: 'Script imports k6 extensions which are not allowed. Please remove imports from k6/x/ paths.',
19+
message: K6_EXTENSION_MESSAGE,
3120
});
3221
}
3322
}

0 commit comments

Comments
 (0)