diff --git a/src/components/CodeEditor/CodeEditor.tsx b/src/components/CodeEditor/CodeEditor.tsx index 3ef7cdb3a..ae9decd75 100644 --- a/src/components/CodeEditor/CodeEditor.tsx +++ b/src/components/CodeEditor/CodeEditor.tsx @@ -9,6 +9,7 @@ import { CodeEditorProps, ConstrainedEditorProps } from './CodeEditor.types'; import k6Types from './k6.types'; import { initializeConstrainedInstance, updateConstrainedEditorRanges } from './CodeEditor.utils'; +import { wireCustomValidation } from './monacoValidation'; const addK6Types = (monaco: typeof monacoType) => { Object.entries(k6Types).map(([name, type]) => { @@ -116,6 +117,9 @@ export const CodeEditor = forwardRef(function CodeEditor( handleValidation(monaco, editor); }); + // Wire custom red-squiggle markers for forbidden syntax + const disposeCustomValidation = wireCustomValidation(monaco, editor); + if (constrainedRanges) { const instance = initializeConstrainedInstance(monaco, editor); const model = editor.getModel(); @@ -126,6 +130,13 @@ export const CodeEditor = forwardRef(function CodeEditor( updateConstrainedEditorRanges(instance, model, value, constrainedRanges, onDidChangeContentInEditableRange); setConstrainedInstance(instance); } + + // Cleanup subscriptions on dispose + editor.onDidDispose(() => { + if (typeof disposeCustomValidation === 'function') { + disposeCustomValidation(); + } + }); }; useEffect(() => { diff --git a/src/components/CodeEditor/monacoValidation.test.ts b/src/components/CodeEditor/monacoValidation.test.ts new file mode 100644 index 000000000..013af47dd --- /dev/null +++ b/src/components/CodeEditor/monacoValidation.test.ts @@ -0,0 +1,368 @@ +import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api'; + +import { applyCustomScriptMarkers } from './monacoValidation'; + +// Mock Monaco types and create test helpers +const createMockModel = (script: string): monacoType.editor.ITextModel => ({ + getValue: () => script, + getLineContent: (lineNumber: number) => script.split('\n')[lineNumber - 1] || '', + getLineCount: () => script.split('\n').length, + getPositionAt: (offset: number) => { + const lines = script.split('\n'); + let currentOffset = 0; + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length + 1; // +1 for newline + if (currentOffset + lineLength > offset) { + return { lineNumber: i + 1, column: offset - currentOffset + 1 }; + } + currentOffset += lineLength; + } + return { lineNumber: lines.length, column: lines[lines.length - 1].length + 1 }; + }, +} as any); + +const mockMonaco = { + MarkerSeverity: { Error: 8 }, + editor: { + setModelMarkers: jest.fn(), + }, +} as any; + +describe('Monaco K6 Validation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('K6 Version Directives (Pragmas)', () => { + describe('Should detect and flag', () => { + test.each([ + // Basic version directives + '"use k6 > 0.54";', + "'use k6 >= v1.0.0';", + '`use k6 < 2.0`;', + '"use k6 <= v1.5.3";', + "'use k6 == 1.2.0';", + '"use k6 != v2.0.0-beta";', + + // Version directives with extensions + '"use k6 with k6/x/faker > 0.4.0";', + "'use k6 with k6/x/sql >= 1.0.1';", + '`use k6 with k6/x/kubernetes < 2.0.0`;', + '"use k6 with k6/x/prometheus-remote-write != v1.0.0";', + + // Different spacing + '"use k6>0.54";', + '"use k6 >= v1.0.0";', + '"use k6 with k6/x/faker>0.4.0";', + + // Version variations + '"use k6 > 1";', + '"use k6 >= 1.0";', + '"use k6 < 1.2.3";', + '"use k6 <= v1.2.3-alpha";', + '"use k6 == 1.0.0+build.123";', + ])('standalone directive: %s', (directive) => { + const script = `${directive}\nimport http from 'k6/http';`; + const model = createMockModel(script); + + applyCustomScriptMarkers(mockMonaco, model); + + expect(mockMonaco.editor.setModelMarkers).toHaveBeenCalledWith( + model, + 'k6-validation', + expect.arrayContaining([ + expect.objectContaining({ + severity: 8, // Error + message: 'Version directives cannot be used within scripts. Please remove any "use k6" statements.', + code: 'k6-pragma-forbidden', + startLineNumber: 1, + }) + ]) + ); + }); + + test('multiple directives in same script', () => { + const script = `"use k6 > 0.54"; +"use k6 with k6/x/faker >= 1.0.0"; +import http from 'k6/http';`; + + const model = createMockModel(script); + applyCustomScriptMarkers(mockMonaco, model); + + const [, , markers] = mockMonaco.editor.setModelMarkers.mock.calls[0]; + expect(markers).toHaveLength(2); + expect(markers[0]).toMatchObject({ + message: 'Version directives cannot be used within scripts. Please remove any "use k6" statements.', + startLineNumber: 1, + }); + expect(markers[1]).toMatchObject({ + message: 'Version directives cannot be used within scripts. Please remove any "use k6" statements.', + startLineNumber: 2, + }); + }); + }); + + describe('Should NOT detect (context-aware)', () => { + test.each([ + // Inside function calls + 'console.log("use k6 >= 1");', + 'alert("use k6 with k6/x/faker > 0.4.0");', + 'someFunction("use k6 < 2.0");', + + // In variable assignments + 'const myVar = "use k6 >= 1";', + 'let directive = "use k6 with k6/x/sql >= 1.0.1";', + 'var version = "use k6 > 0.54";', + + // In object properties + 'const config = { directive: "use k6 >= 1" };', + 'const obj = { "use k6 > 0.54": true };', + + // In array literals + 'const directives = ["use k6 >= 1", "use k6 > 0.54"];', + + // In return statements + 'return "use k6 >= 1";', + + // In if conditions + 'if (script === "use k6 >= 1") {}', + + // In template literals (non-standalone) + 'console.log(`Version: ${"use k6 >= 1"}`);', + + // Comments (should be ignored completely) + '// "use k6 >= 1"', + '/* "use k6 with k6/x/faker > 0.4.0" */', + + // Invalid syntax (not real directives) + '"use k6";', // No operator + '"use k7 >= 1";', // Wrong tool + '"use k6 >= ";', // No version + '"k6 >= 1";', // Missing "use" + ])('should ignore: %s', (code) => { + const script = `${code}\nimport http from 'k6/http';`; + const model = createMockModel(script); + + applyCustomScriptMarkers(mockMonaco, model); + + const [, , markers] = mockMonaco.editor.setModelMarkers.mock.calls[0]; + const pragmaMarkers = markers.filter((m: any) => m.code === 'k6-pragma-forbidden'); + expect(pragmaMarkers).toHaveLength(0); + }); + }); + }); + + describe('K6 Extension Imports', () => { + describe('Should detect and flag', () => { + test.each([ + // Named imports + 'import { Faker } from "k6/x/faker";', + 'import { sql } from "k6/x/sql";', + 'import { check, group } from "k6/x/utils";', + + // Default imports + 'import faker from "k6/x/faker";', + 'import sql from "k6/x/sql";', + 'import kubernetes from "k6/x/kubernetes";', + + // Namespace imports + 'import * as faker from "k6/x/faker";', + 'import * as prometheus from "k6/x/prometheus-remote-write";', + + // Mixed imports + 'import sql, { query } from "k6/x/sql";', + 'import faker, * as utils from "k6/x/faker";', + + // Different quote styles + "import faker from 'k6/x/faker';", + // Note: Template literals are not valid in import statements in JavaScript + + // Nested paths + 'import driver from "k6/x/sql/driver/postgres";', + 'import ramsql from "k6/x/sql/driver/ramsql";', + 'import auth from "k6/x/oauth/v2";', + ])('extension import: %s', (importStatement) => { + const script = `${importStatement}\nimport http from 'k6/http';`; + const model = createMockModel(script); + + applyCustomScriptMarkers(mockMonaco, model); + + expect(mockMonaco.editor.setModelMarkers).toHaveBeenCalledWith( + model, + 'k6-validation', + expect.arrayContaining([ + expect.objectContaining({ + severity: 8, // Error + message: 'Script imports k6 extensions which are not allowed. Please remove imports from k6/x/ paths.', + code: 'k6-extension-forbidden', + startLineNumber: 1, + }) + ]) + ); + }); + + test('multiple extension imports', () => { + const script = `import faker from "k6/x/faker"; +import sql from "k6/x/sql"; +import http from 'k6/http';`; + + const model = createMockModel(script); + applyCustomScriptMarkers(mockMonaco, model); + + const [, , markers] = mockMonaco.editor.setModelMarkers.mock.calls[0]; + const extensionMarkers = markers.filter((m: any) => m.code === 'k6-extension-forbidden'); + expect(extensionMarkers).toHaveLength(2); + }); + }); + + describe('Should NOT detect (standard k6 modules)', () => { + test.each([ + // Standard k6 modules + 'import http from "k6/http";', + 'import { check, group } from "k6";', + 'import { Rate, Counter } from "k6/metrics";', + 'import { browser } from "k6/browser";', + 'import { crypto } from "k6/crypto";', + 'import { encoding } from "k6/encoding";', + 'import ws from "k6/ws";', + + // External modules + 'import lodash from "lodash";', + 'import axios from "axios";', + 'import moment from "moment";', + + // Relative imports + 'import utils from "./utils";', + 'import config from "../config";', + 'import helper from "../../helpers/test";', + + // URL imports + 'import something from "https://example.com/lib.js";', + + // Comments about k6/x (should be ignored) + '// import faker from "k6/x/faker";', + '/* import sql from "k6/x/sql"; */', + + // Strings containing k6/x (should be ignored) + 'console.log("import faker from k6/x/faker");', + 'const note = "We used to use k6/x/sql";', + ])('should allow: %s', (importStatement) => { + const script = `${importStatement}\nexport default function() {}`; + const model = createMockModel(script); + + applyCustomScriptMarkers(mockMonaco, model); + + const [, , markers] = mockMonaco.editor.setModelMarkers.mock.calls[0]; + const extensionMarkers = markers.filter((m: any) => m.code === 'k6-extension-forbidden'); + expect(extensionMarkers).toHaveLength(0); + }); + }); + }); + + describe('Combined scenarios', () => { + test('script with both pragma and extension violations', () => { + const script = `"use k6 > 0.54"; +import faker from "k6/x/faker"; +import http from 'k6/http'; + +export default function() { + console.log("This should not trigger: use k6 >= 1"); + const variable = "use k6 with k6/x/sql >= 1.0.1"; +}`; + + const model = createMockModel(script); + applyCustomScriptMarkers(mockMonaco, model); + + const [, , markers] = mockMonaco.editor.setModelMarkers.mock.calls[0]; + + expect(markers).toHaveLength(2); + + const pragmaMarkers = markers.filter((m: any) => m.code === 'k6-pragma-forbidden'); + const extensionMarkers = markers.filter((m: any) => m.code === 'k6-extension-forbidden'); + + expect(pragmaMarkers).toHaveLength(1); + expect(extensionMarkers).toHaveLength(1); + + expect(pragmaMarkers[0]).toMatchObject({ + startLineNumber: 1, + message: 'Version directives cannot be used within scripts. Please remove any "use k6" statements.', + }); + + expect(extensionMarkers[0]).toMatchObject({ + startLineNumber: 2, + message: 'Script imports k6 extensions which are not allowed. Please remove imports from k6/x/ paths.', + }); + }); + + test('valid k6 script without violations', () => { + const script = `import http from 'k6/http'; +import { check, group } from 'k6'; +import { Rate } from 'k6/metrics'; + +export default function() { + const response = http.get('https://example.com'); + check(response, { + 'status is 200': (r) => r.status === 200, + }); +} + +export function handleSummary(data) { + return { + 'summary.json': JSON.stringify(data), + }; +}`; + + const model = createMockModel(script); + applyCustomScriptMarkers(mockMonaco, model); + + const [, , markers] = mockMonaco.editor.setModelMarkers.mock.calls[0]; + expect(markers).toHaveLength(0); + }); + }); + + describe('Edge cases', () => { + test('empty script', () => { + const model = createMockModel(''); + applyCustomScriptMarkers(mockMonaco, model); + + const [, , markers] = mockMonaco.editor.setModelMarkers.mock.calls[0]; + expect(markers).toHaveLength(0); + }); + + test('script with syntax errors (should not crash)', () => { + const script = 'import { unclosed from "k6/x/faker"'; // Syntax error + const model = createMockModel(script); + + // Should not throw + expect(() => { + applyCustomScriptMarkers(mockMonaco, model); + }).not.toThrow(); + + // Should return empty markers for invalid syntax + const [, , markers] = mockMonaco.editor.setModelMarkers.mock.calls[0]; + expect(markers).toHaveLength(0); + }); + + test('template literals as standalone expressions', () => { + const script = '`use k6 >= v1.0.0`;\nimport http from "k6/http";'; + const model = createMockModel(script); + + applyCustomScriptMarkers(mockMonaco, model); + + const [, , markers] = mockMonaco.editor.setModelMarkers.mock.calls[0]; + const pragmaMarkers = markers.filter((m: any) => m.code === 'k6-pragma-forbidden'); + expect(pragmaMarkers).toHaveLength(1); + }); + + test('nested template literals (should be ignored)', () => { + const script = 'console.log(`Version: ${`use k6 >= 1`}`);'; + const model = createMockModel(script); + + applyCustomScriptMarkers(mockMonaco, model); + + const [, , markers] = mockMonaco.editor.setModelMarkers.mock.calls[0]; + const pragmaMarkers = markers.filter((m: any) => m.code === 'k6-pragma-forbidden'); + expect(pragmaMarkers).toHaveLength(0); + }); + }); +}); diff --git a/src/components/CodeEditor/monacoValidation.ts b/src/components/CodeEditor/monacoValidation.ts new file mode 100644 index 000000000..b13d18cdf --- /dev/null +++ b/src/components/CodeEditor/monacoValidation.ts @@ -0,0 +1,219 @@ +import { Node, parse } from 'acorn'; +import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api'; +import { K6_EXTENSION_MESSAGE, K6_PRAGMA_MESSAGE,validateK6Restrictions } from 'schemas/forms/script/rules'; + +// ============================================================================= +// TYPES & CONSTANTS +// ============================================================================= + +type Monaco = typeof monacoType; +type Editor = monacoType.editor.IStandaloneCodeEditor; +type Model = monacoType.editor.ITextModel; +type Marker = monacoType.editor.IMarkerData; + +interface ValidationIssue { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + message: string; + code: string; +} + +const VALIDATION_CONFIG = { + OWNER: 'k6-validation', + PRAGMA_ERROR: K6_PRAGMA_MESSAGE, + EXTENSION_ERROR: K6_EXTENSION_MESSAGE, + PRAGMA_CODE: 'k6-pragma-forbidden', + EXTENSION_CODE: 'k6-extension-forbidden', +} as const; + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +export function parseScript(script: string): Node | null { + try { + return parse(script, { + ecmaVersion: 2023, + sourceType: 'module', + locations: true, // Enable locations for Monaco validation + }); + } catch (e) { + return null; + } +} + +function getMonacoPosition(model: Model, loc: { line: number; column: number }) { + return { + lineNumber: loc.line, + column: loc.column + 1, // Monaco is 1-based, Acorn is 0-based + }; +} + +// ============================================================================= +// VALIDATION LOGIC +// ============================================================================= + +function createValidationIssue( + model: Model, + node: Node, + message: string, + code: string +): ValidationIssue | null { + if (!node.loc) { + return null; // Skip nodes without location info + } + + const start = getMonacoPosition(model, node.loc.start); + const end = getMonacoPosition(model, node.loc.end); + + return { + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: end.lineNumber, + endColumn: end.column, + message, + code, + }; +} + +function findK6ValidationIssues(model: Model): ValidationIssue[] { + const script = model.getValue(); + const validation = validateK6Restrictions(script, parseScript); + + // Convert shared validation issues to Monaco validation issues with location info + return validation.issues.map(sharedIssue => { + const issue = createValidationIssue( + model, + sharedIssue.node, + sharedIssue.message, + sharedIssue.code + ); + return issue; + }).filter((issue): issue is ValidationIssue => issue !== null); +} + +function createMarkers(issues: ValidationIssue[], monaco: Monaco): Marker[] { + return issues.map((issue) => ({ + severity: monaco.MarkerSeverity.Error, + message: issue.message, + startLineNumber: issue.startLineNumber, + startColumn: issue.startColumn, + endLineNumber: issue.endLineNumber, + endColumn: issue.endColumn, + source: 'Synthetic Monitoring', + code: issue.code, + })); +} + +// ============================================================================= +// QUICK FIX ACTIONS +// ============================================================================= + +function createRemoveLineAction( + monaco: Monaco, + model: Model, + marker: Marker, + title: string +): monacoType.languages.CodeAction { + return { + title, + diagnostics: [marker], + kind: 'quickfix', + edit: { + edits: [ + { + resource: model.uri, + textEdit: { + range: new monaco.Range( + marker.startLineNumber, + 1, + marker.startLineNumber, + model.getLineContent(marker.startLineNumber).length + 1 + ), + text: '', + }, + versionId: model.getVersionId(), + }, + ], + }, + isPreferred: true, + }; +} + +function getK6ValidationMarkers(context: monacoType.languages.CodeActionContext, monaco: Monaco): Marker[] { + return (Array.isArray(context.markers) ? context.markers : []).filter( + (marker) => + (marker.message === VALIDATION_CONFIG.PRAGMA_ERROR || marker.message === VALIDATION_CONFIG.EXTENSION_ERROR) && + marker.severity === monaco.MarkerSeverity.Error + ); +} + +function createQuickFixActions(monaco: Monaco, model: Model, markers: Marker[]): monacoType.languages.CodeAction[] { + return markers.map((marker) => { + if (marker.message === VALIDATION_CONFIG.PRAGMA_ERROR) { + return createRemoveLineAction(monaco, model, marker, 'Remove k6 version directive'); + } + if (marker.message === VALIDATION_CONFIG.EXTENSION_ERROR) { + return createRemoveLineAction(monaco, model, marker, 'Remove k6 extension import'); + } + return createRemoveLineAction(monaco, model, marker, 'Remove forbidden k6 statement'); + }); +} + +function registerCodeActionProvider(monaco: Monaco) { + return monaco.languages.registerCodeActionProvider('javascript', { + provideCodeActions(model, range, context, token) { + const k6Markers = getK6ValidationMarkers(context, monaco); + const actions = createQuickFixActions(monaco, model, k6Markers); + + return { actions, dispose: () => {} }; + }, + }); +} + +// ============================================================================= +// PUBLIC API +// ============================================================================= + +export function applyCustomScriptMarkers(monaco: Monaco, model: Model): void { + const issues = findK6ValidationIssues(model); + const markers = createMarkers(issues, monaco); + monaco.editor.setModelMarkers(model, VALIDATION_CONFIG.OWNER, markers); +} + +export function wireCustomValidation(monaco: Monaco, editor: Editor) { + const model = editor.getModel(); + if (!model) { + return; + } + + // Register quick fix provider + const codeActionDisposable = registerCodeActionProvider(monaco); + + // Apply initial validation + applyCustomScriptMarkers(monaco, model); + + // Update validation on changes + const disposeOnChangeModelContent = editor.onDidChangeModelContent(() => { + const currentModel = editor.getModel(); + if (currentModel) { + applyCustomScriptMarkers(monaco, currentModel); + } + }); + + const disposeOnChangeModel = editor.onDidChangeModel(() => { + const currentModel = editor.getModel(); + if (currentModel) { + applyCustomScriptMarkers(monaco, currentModel); + } + }); + + // Cleanup function + return () => { + disposeOnChangeModelContent.dispose(); + disposeOnChangeModel.dispose(); + codeActionDisposable.dispose(); + }; +} diff --git a/src/page/NewCheck/__tests__/BrowserChecks/Scripted/1-script.ui.test.tsx b/src/page/NewCheck/__tests__/BrowserChecks/Scripted/1-script.ui.test.tsx index cba6bfa60..157fa7a3a 100644 --- a/src/page/NewCheck/__tests__/BrowserChecks/Scripted/1-script.ui.test.tsx +++ b/src/page/NewCheck/__tests__/BrowserChecks/Scripted/1-script.ui.test.tsx @@ -1,4 +1,5 @@ import { screen } from '@testing-library/react'; +import { K6_PRAGMA_MESSAGE } from 'schemas/forms/script/rules'; import { CheckType } from 'types'; import { renderNewForm, submitForm } from 'page/__testHelpers__/checkForm'; @@ -135,5 +136,35 @@ describe(`BrowserCheck - 1 (Script) UI`, () => { const err = await screen.findByText("Script can't define iterations > 1 for this check"); expect(err).toBeInTheDocument(); }); + + it(`will display an error when it contains a k6 version pragma`, async () => { + const { user } = await renderNewForm(checkType); + const scriptTextAreaPreSubmit = screen.getByTestId(`code-editor`); + await user.clear(scriptTextAreaPreSubmit); + + const scriptWithPragma = `"use k6 >= v1.0.0" +${browserImport} +${exportCorrectOptions}`; + await user.type(scriptTextAreaPreSubmit, scriptWithPragma); + + await submitForm(user); + const err = await screen.findByText(K6_PRAGMA_MESSAGE); + expect(err).toBeInTheDocument(); + }); + + it(`will display an error when it imports k6 extensions`, async () => { + const { user } = await renderNewForm(checkType); + const scriptTextAreaPreSubmit = screen.getByTestId(`code-editor`); + await user.clear(scriptTextAreaPreSubmit); + + const scriptWithExtension = `import { Kubernetes } from "k6/x/kubernetes"; +${browserImport} +${exportCorrectOptions}`; + await user.type(scriptTextAreaPreSubmit, scriptWithExtension); + + await submitForm(user); + const err = await screen.findByText('Script imports k6 extensions which are not allowed. Please remove imports from k6/x/ paths.'); + expect(err).toBeInTheDocument(); + }); }); }); diff --git a/src/page/NewCheck/__tests__/ScriptedChecks/Scripted/1-script.ui.test.tsx b/src/page/NewCheck/__tests__/ScriptedChecks/Scripted/1-script.ui.test.tsx index 28ca819e6..f05bacf25 100644 --- a/src/page/NewCheck/__tests__/ScriptedChecks/Scripted/1-script.ui.test.tsx +++ b/src/page/NewCheck/__tests__/ScriptedChecks/Scripted/1-script.ui.test.tsx @@ -1,4 +1,5 @@ import { screen, waitFor } from '@testing-library/react'; +import { K6_PRAGMA_MESSAGE } from 'schemas/forms/script/rules'; import { CheckType } from 'types'; import { renderNewForm, submitForm } from 'page/__testHelpers__/checkForm'; @@ -36,4 +37,58 @@ describe(`ScriptedCheck - 1 (Script) UI`, () => { const scriptTextAreaPostSubmit = screen.getByTestId(`code-editor`); await waitFor(() => expect(scriptTextAreaPostSubmit).toHaveFocus()); }); + + it(`will display an error when script contains a k6 version pragma`, async () => { + const { user } = await renderNewForm(checkType); + const scriptTextAreaPreSubmit = screen.getByTestId(`code-editor`); + await user.clear(scriptTextAreaPreSubmit); + + const scriptWithPragma = `'use k6 > 0.52' +import http from 'k6/http'; +export default function() { + http.get('https://example.com'); +}`; + await user.type(scriptTextAreaPreSubmit, scriptWithPragma); + await fillMandatoryFields({ user, fieldsToOmit: [`probes`], checkType }); + + await submitForm(user); + const err = await screen.findByText(K6_PRAGMA_MESSAGE); + expect(err).toBeInTheDocument(); + }); + + it(`will display an error when script imports k6 extensions`, async () => { + const { user } = await renderNewForm(checkType); + const scriptTextAreaPreSubmit = screen.getByTestId(`code-editor`); + await user.clear(scriptTextAreaPreSubmit); + + const scriptWithExtension = `import { Faker } from "k6/x/faker"; +import http from 'k6/http'; +export default function() { + http.get('https://example.com'); +}`; + await user.type(scriptTextAreaPreSubmit, scriptWithExtension); + await fillMandatoryFields({ user, fieldsToOmit: [`probes`], checkType }); + + await submitForm(user); + const err = await screen.findByText('Script imports k6 extensions which are not allowed. Please remove imports from k6/x/ paths.'); + expect(err).toBeInTheDocument(); + }); + + it(`will display an error when script contains browser import (not allowed for scripted checks)`, async () => { + const { user } = await renderNewForm(checkType); + const scriptTextAreaPreSubmit = screen.getByTestId(`code-editor`); + await user.clear(scriptTextAreaPreSubmit); + + const scriptWithBrowser = `import { browser } from 'k6/browser'; +import http from 'k6/http'; +export default function() { + http.get('https://example.com'); +}`; + await user.type(scriptTextAreaPreSubmit, scriptWithBrowser); + await fillMandatoryFields({ user, fieldsToOmit: [`probes`], checkType }); + + await submitForm(user); + const err = await screen.findByText("Script must not import { browser } from 'k6/browser'"); + expect(err).toBeInTheDocument(); + }); }); diff --git a/src/schemas/forms/script/rules.ts b/src/schemas/forms/script/rules.ts new file mode 100644 index 000000000..f9c37eccc --- /dev/null +++ b/src/schemas/forms/script/rules.ts @@ -0,0 +1,86 @@ +import type { Node } from 'acorn'; +import { simple as walk, SimpleVisitors } from 'acorn-walk'; + +export const K6_PRAGMA_MESSAGE = + 'Version directives cannot be used within scripts. Please remove any "use k6" statements.'; + +export const K6_EXTENSION_MESSAGE = + 'Script imports k6 extensions which are not allowed. Please remove imports from k6/x/ paths.'; + +const K6_VERSION_DIRECTIVE_PATTERN = /^use\s+k6(\s+with\s+k6\/x\/[\w/-]+)?\s*[><=!]+\s*v?\d+(?:\.\d*)*(?:[-+][\w.]*)*$/i; + +export function isK6VersionDirective(value: string): boolean { + return K6_VERSION_DIRECTIVE_PATTERN.test(value.trim()); +} +export interface K6ValidationIssue { + type: 'pragma' | 'extension'; + node: Node; + message: string; + code: string; +} + +export interface K6ValidationResult { + hasPragmas: boolean; + hasExtensions: boolean; + issues: K6ValidationIssue[]; +} + +// Single AST walk that serves both Monaco (needs locations) and Zod (needs booleans) +export function validateK6Restrictions(script: string, parseScript: (script: string) => Node | null): K6ValidationResult { + const ast = parseScript(script); + if (!ast) { + return { hasPragmas: false, hasExtensions: false, issues: [] }; + } + + const issues: K6ValidationIssue[] = []; + + const visitors: SimpleVisitors<{}> = { + ExpressionStatement(node) { + if (node.expression.type === 'Literal' && typeof node.expression.value === 'string') { + if (isK6VersionDirective(node.expression.value)) { + issues.push({ + type: 'pragma', + node: node.expression, + message: K6_PRAGMA_MESSAGE, + code: 'k6-pragma-forbidden', + }); + } + } + + if (node.expression.type === 'TemplateLiteral' && node.expression.quasis) { + node.expression.quasis.forEach((quasi) => { + if (quasi.value?.raw && isK6VersionDirective(quasi.value.raw)) { + issues.push({ + type: 'pragma', + node: quasi, + message: K6_PRAGMA_MESSAGE, + code: 'k6-pragma-forbidden', + }); + } + }); + } + }, + + ImportDeclaration(node) { + if (node.source?.type === 'Literal' && typeof node.source.value === 'string') { + const importPath = node.source.value; + if (importPath.startsWith('k6/x/')) { + issues.push({ + type: 'extension', + node: node, + message: K6_EXTENSION_MESSAGE, + code: 'k6-extension-forbidden', + }); + } + } + }, + }; + + walk(ast, visitors, undefined, {}); + + return { + hasPragmas: issues.some(issue => issue.type === 'pragma'), + hasExtensions: issues.some(issue => issue.type === 'extension'), + issues, + }; +} diff --git a/src/schemas/forms/script/validation.ts b/src/schemas/forms/script/validation.ts index 722791f81..c8a045919 100644 --- a/src/schemas/forms/script/validation.ts +++ b/src/schemas/forms/script/validation.ts @@ -1,9 +1,28 @@ import { RefinementCtx, ZodIssueCode } from 'zod'; import { extractImportStatement, extractOptionsExport, getProperty, parseScript } from './parser'; +import { K6_EXTENSION_MESSAGE, K6_PRAGMA_MESSAGE, validateK6Restrictions } from './rules'; const MAX_SCRIPT_IN_KB = 128; +function validateScriptPragmasAndExtensions(script: string, context: RefinementCtx): void { + const validation = validateK6Restrictions(script, parseScript); + + if (validation.hasPragmas) { + context.addIssue({ + code: ZodIssueCode.custom, + message: K6_PRAGMA_MESSAGE, + }); + } + + if (validation.hasExtensions) { + context.addIssue({ + code: ZodIssueCode.custom, + message: K6_EXTENSION_MESSAGE, + }); + } +} + export const maxSizeValidation = (val: string, context: RefinementCtx) => { const textBlob = new Blob([val], { type: 'text/plain' }); const sizeInBytes = textBlob.size; @@ -18,6 +37,8 @@ export const maxSizeValidation = (val: string, context: RefinementCtx) => { }; export function validateBrowserScript(script: string, context: RefinementCtx) { + validateScriptPragmasAndExtensions(script, context); + const program = parseScript(script); if (program === null) { @@ -81,6 +102,8 @@ export function validateBrowserScript(script: string, context: RefinementCtx) { } export function validateNonBrowserScript(script: string, context: RefinementCtx) { + validateScriptPragmasAndExtensions(script, context); + const program = parseScript(script); if (program === null) {