diff --git a/its/plugin/tests/src/test/java/com/sonar/javascript/it/plugin/SonarJsIntegrationTest.java b/its/plugin/tests/src/test/java/com/sonar/javascript/it/plugin/SonarJsIntegrationTest.java index 643af0163cc..2276dd6bae2 100644 --- a/its/plugin/tests/src/test/java/com/sonar/javascript/it/plugin/SonarJsIntegrationTest.java +++ b/its/plugin/tests/src/test/java/com/sonar/javascript/it/plugin/SonarJsIntegrationTest.java @@ -97,8 +97,9 @@ private void assertAnalyzeJs(Bridge bridge) throws IOException, InterruptedExcep r.fileContent = "function foo() { \n" + " var a; \n" + " var c; // NOSONAR\n" + " var b = 42; \n" + "} \n"; r.filePath = temp.resolve("file.js").toAbsolutePath().toString(); - String response = bridge.request(gson.toJson(r), "analyze-js"); - JsonObject jsonObject = gson.fromJson(response, JsonObject.class); + HttpResponse response = bridge.request(gson.toJson(r), "analyze-js"); + var parsedResponse = parseFormData(response); + JsonObject jsonObject = gson.fromJson(parsedResponse.json(), JsonObject.class); JsonArray issues = jsonObject.getAsJsonArray("issues"); assertThat(issues).hasSize(3); assertThat(issues) @@ -108,6 +109,34 @@ private void assertAnalyzeJs(Bridge bridge) throws IOException, InterruptedExcep JsonObject metrics = jsonObject.getAsJsonObject("metrics"); assertThat(metrics.entrySet()).hasSize(1); assertThat(metrics.get("nosonarLines").getAsJsonArray()).containsExactly(new JsonPrimitive(3)); + assertThat(parsedResponse.ast()).contains("plop"); + } + + private static BridgeResponse parseFormData(HttpResponse response) { + String boundary = "--" + response.headers().firstValue("Content-Type") + .orElseThrow(() -> new IllegalStateException("No Content-Type header")) + .split("boundary=")[1]; + String[] parts = response.body().split(boundary); + String json = null; + String ast = null; + for (String part : parts) { + // Split the part into headers and body + String[] splitPart = part.split("\r\n\r\n", 2); + if (splitPart.length < 2) { + // Skip if there's no body + continue; + } + + String headers = splitPart[0]; + String partBody = splitPart[1]; + + if (headers.contains("json")) { + json = partBody; + } else if (headers.contains("ast")) { + ast = partBody; + } + } + return new BridgeResponse(json, ast); } private void assertStatus(Bridge bridge) { @@ -186,15 +215,14 @@ void start(Path dest) throws IOException { process = pb.start(); } - String request(String json, String endpoint) throws IOException, InterruptedException { + HttpResponse request(String json, String endpoint) throws IOException, InterruptedException { var request = HttpRequest .newBuilder(url(endpoint)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); - var response = client.send(request, HttpResponse.BodyHandlers.ofString()); - return response.body(); + return client.send(request, HttpResponse.BodyHandlers.ofString()); } String status() throws IOException, InterruptedException { @@ -249,4 +277,6 @@ static class Rule { List configurations = Collections.emptyList(); String fileTypeTarget = "MAIN"; } + + record BridgeResponse(String json, String ast) {} } diff --git a/package-lock.json b/package-lock.json index b9ee7caa662..effa58ada06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-sonarjs": "^1.0.3", "express": "4.19.2", + "form-data": "4.0.0", "functional-red-black-tree": "1.0.1", "htmlparser2": "9.1.0", "jsx-ast-utils": "3.3.5", @@ -4054,6 +4055,11 @@ "node": ">=8" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -4667,6 +4673,17 @@ "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", "inBundle": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5132,6 +5149,14 @@ "node": ">= 0.4" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6449,6 +6474,19 @@ "is-callable": "^1.1.3" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -15508,6 +15546,11 @@ "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==" }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "available-typed-arrays": { "version": "1.0.7", "resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -15978,6 +16021,14 @@ "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -16304,6 +16355,11 @@ "object-keys": "^1.1.1" } }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -17304,6 +17360,16 @@ "is-callable": "^1.1.3" } }, + "form-data": { + "version": "4.0.0", + "resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", diff --git a/package.json b/package.json index e6f7989c687..b265cc43d59 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-sonarjs": "^1.0.3", "express": "4.19.2", + "form-data": "4.0.0", "functional-red-black-tree": "1.0.1", "htmlparser2": "9.1.0", "jsx-ast-utils": "3.3.5", @@ -136,6 +137,7 @@ "eslint-plugin-react-hooks", "eslint-plugin-sonarjs", "express", + "form-data", "functional-red-black-tree", "htmlparser2", "jsx-ast-utils", diff --git a/packages/bridge/src/errors/middleware.ts b/packages/bridge/src/errors/middleware.ts index 385a1664a35..56c29b02730 100644 --- a/packages/bridge/src/errors/middleware.ts +++ b/packages/bridge/src/errors/middleware.ts @@ -92,4 +92,5 @@ export const EMPTY_JSTS_ANALYSIS_OUTPUT: JsTsAnalysisOutput = { cognitiveComplexity: 0, }, cpdTokens: [], + ast: '', }; diff --git a/packages/bridge/src/worker.js b/packages/bridge/src/worker.js index ef755a83ad1..dc1002c7beb 100644 --- a/packages/bridge/src/worker.js +++ b/packages/bridge/src/worker.js @@ -20,6 +20,7 @@ require('module-alias/register'); +const formData = require('form-data'); const { parentPort, workerData } = require('worker_threads'); const { analyzeJSTS, @@ -47,8 +48,20 @@ exports.delegate = function (worker, type) { worker.once('message', message => { switch (message.type) { case 'success': - response.send(message.result); + if (message.format === 'multipart') { + const fd = new formData(); + fd.append('ast', message.result.ast); + delete message.result.ast; + fd.append('json', JSON.stringify(message.result)); + // this adds the boundary string that will be used to separate the parts + response.set('Content-Type', fd.getHeaders()['content-type']); + response.set('Content-Length', fd.getLengthSync()); + fd.pipe(response); + } else { + response.send(message.result); + } break; + case 'failure': next(message.error); break; @@ -92,7 +105,11 @@ if (parentPort) { await readFileLazily(data); const output = analyzeJSTS(data, 'js'); - parentThread.postMessage({ type: 'success', result: JSON.stringify(output) }); + parentThread.postMessage({ + type: 'success', + result: output, + format: 'multipart', + }); break; } @@ -107,7 +124,11 @@ if (parentPort) { await readFileLazily(data); const output = analyzeJSTS(data, 'ts'); - parentThread.postMessage({ type: 'success', result: JSON.stringify(output) }); + parentThread.postMessage({ + type: 'success', + result: output, + format: 'multipart', + }); break; } diff --git a/packages/bridge/tests/router.test.ts b/packages/bridge/tests/router.test.ts index 2d652d764ef..f40fe45060d 100644 --- a/packages/bridge/tests/router.test.ts +++ b/packages/bridge/tests/router.test.ts @@ -114,10 +114,10 @@ describe('router', () => { const filePath = path.join(fixtures, 'file.js'); const fileType = 'MAIN'; const data = { filePath, fileType, tsConfigs: [] }; - const response = (await request(server, '/analyze-js', 'POST', data)) as string; + const response = (await request(server, '/analyze-js', 'POST', data, 'formdata')) as FormData; const { issues: [issue], - } = JSON.parse(response); + } = JSON.parse(response.get('json') as string); expect(issue).toEqual( expect.objectContaining({ ruleId: 'prefer-regex-literals', @@ -128,6 +128,8 @@ describe('router', () => { message: `Use a regular expression literal instead of the 'RegExp' constructor.`, }), ); + const ast = response.get('ast'); + expect(ast).toEqual('plop'); }); it('should route /analyze-ts requests', async () => { @@ -138,10 +140,10 @@ describe('router', () => { const fileType = 'MAIN'; const tsConfig = path.join(fixtures, 'tsconfig.json'); const data = { filePath, fileType, tsConfigs: [tsConfig] }; - const response = (await request(server, '/analyze-ts', 'POST', data)) as string; + const response = (await request(server, '/analyze-ts', 'POST', data, 'formdata')) as FormData; const { issues: [issue], - } = JSON.parse(response); + } = JSON.parse(response.get('json') as string); expect(issue).toEqual( expect.objectContaining({ ruleId: 'no-duplicate-in-composite', @@ -152,6 +154,8 @@ describe('router', () => { message: `Remove this duplicated type or replace with another one.`, }), ); + const ast = response.get('ast'); + expect(ast).toEqual('plop'); }); it('should route /analyze-with-program requests', async () => { @@ -165,10 +169,16 @@ describe('router', () => { (await request(server, '/create-program', 'POST', { tsConfig })) as string, ); const data = { filePath, fileType, programId }; - const response = (await request(server, '/analyze-with-program', 'POST', data)) as string; + const response = (await request( + server, + '/analyze-with-program', + 'POST', + data, + 'formdata', + )) as FormData; const { issues: [issue], - } = JSON.parse(response); + } = JSON.parse(response.get('json') as string); expect(issue).toEqual( expect.objectContaining({ ruleId: 'no-duplicate-in-composite', diff --git a/packages/bridge/tests/server.test.ts b/packages/bridge/tests/server.test.ts index 8a56cb6ddbc..e3113dbbaf9 100644 --- a/packages/bridge/tests/server.test.ts +++ b/packages/bridge/tests/server.test.ts @@ -21,7 +21,7 @@ import { start } from '../src/server'; import path from 'path'; import { setContext } from '@sonar/shared'; import { AddressInfo } from 'net'; -import { request } from './tools'; +import { BridgeResponseType, request } from './tools'; import http from 'http'; describe('server', () => { @@ -75,10 +75,10 @@ describe('server', () => { }); expect(await requestInitLinter(server, fileType, ruleId)).toBe('OK!'); - + const response = await requestAnalyzeJs(server, fileType, 'formdata'); const { issues: [issue], - } = JSON.parse(await requestAnalyzeJs(server, fileType)); + } = JSON.parse(response.get('json')); expect(issue).toEqual( expect.objectContaining({ ruleId, @@ -99,11 +99,11 @@ describe('server', () => { const fileType = 'MAIN'; await requestInitLinter(server, fileType, ruleId); - const response = await requestAnalyzeJs(server, fileType); + const response = await requestAnalyzeJs(server, fileType, 'formdata'); const { issues: [issue], - } = JSON.parse(response); + } = JSON.parse(response.get('json')); expect(issue).toEqual( expect.objectContaining({ ruleId, @@ -162,11 +162,15 @@ describe('server', () => { }); }); -async function requestAnalyzeJs(server: http.Server, fileType: string): Promise { +async function requestAnalyzeJs( + server: http.Server, + fileType: string, + format: BridgeResponseType = 'text', +): Promise { const filePath = path.join(__dirname, 'fixtures', 'routing.js'); const analysisInput = { filePath, fileType }; - return await request(server, '/analyze-js', 'POST', analysisInput); + return await request(server, '/analyze-js', 'POST', analysisInput, format); } function requestInitLinter(server: http.Server, fileType: string, ruleId: string) { diff --git a/packages/bridge/tests/tools/request.ts b/packages/bridge/tests/tools/request.ts index ed59d0bfc3d..11b7e63b5ec 100644 --- a/packages/bridge/tests/tools/request.ts +++ b/packages/bridge/tests/tools/request.ts @@ -20,15 +20,32 @@ import { AddressInfo } from 'net'; import http from 'http'; +export type BridgeResponseType = 'text' | 'formdata'; + /** * Sends an HTTP request to a server's endpoint running on localhost. */ -export async function request(server: http.Server, path: string, method: string, body: any = {}) { - return await fetch(`http://127.0.0.1:${(server.address() as AddressInfo).port}${path}`, { +export async function request( + server: http.Server, + path: string, + method: string, + body: any = {}, + format: BridgeResponseType = 'text', +) { + const res = await fetch(`http://127.0.0.1:${(server.address() as AddressInfo).port}${path}`, { headers: { 'Content-Type': 'application/json', }, method, body: method !== 'GET' ? JSON.stringify(body) : undefined, - }).then(response => response.text()); + }); + + switch (format) { + case 'text': + return res.text(); + case 'formdata': + return res.formData(); + default: + throw new Error(`Unsupported format: ${format}`); + } } diff --git a/packages/jsts/src/analysis/analysis.ts b/packages/jsts/src/analysis/analysis.ts index f6a04702b28..5676e24c21b 100644 --- a/packages/jsts/src/analysis/analysis.ts +++ b/packages/jsts/src/analysis/analysis.ts @@ -63,4 +63,5 @@ export interface JsTsAnalysisOutput extends AnalysisOutput { metrics?: Metrics; cpdTokens?: CpdToken[]; ucfgPaths?: string[]; + ast: string; } diff --git a/packages/jsts/src/analysis/analyzer.ts b/packages/jsts/src/analysis/analyzer.ts index 5de8567c712..264f7e5183f 100644 --- a/packages/jsts/src/analysis/analyzer.ts +++ b/packages/jsts/src/analysis/analyzer.ts @@ -84,7 +84,7 @@ function analyzeFile( highlightedSymbols, cognitiveComplexity, ); - return { issues, ucfgPaths, ...extendedMetrics }; + return { issues, ucfgPaths, ...extendedMetrics, ast: 'plop' }; } catch (e) { /** Turns exceptions from TypeScript compiler into "parsing" errors */ if (e.stack.indexOf('typescript.js:') > -1) { diff --git a/packages/jsts/tests/analysis/analyzer.test.ts b/packages/jsts/tests/analysis/analyzer.test.ts index cf9c828cd7f..3cf86cab37e 100644 --- a/packages/jsts/tests/analysis/analyzer.test.ts +++ b/packages/jsts/tests/analysis/analyzer.test.ts @@ -930,4 +930,19 @@ describe('analyzeJSTS', () => { expect(vueIssues).toHaveLength(1); expect(vueIssues[0].message).toEqual('call'); }); + + it('should return the AST along with the issues', async () => { + const rules = [ + { key: 'prefer-default-last', configurations: [], fileTypeTarget: ['MAIN'] }, + ] as RuleConfig[]; + initializeLinter(rules); + initializeLinter([], [], [], 'empty'); + + const filePath = path.join(__dirname, 'fixtures', 'code.js'); + const language = 'js'; + + const { ast } = analyzeJSTS(await jsTsInput({ filePath }), language) as JsTsAnalysisOutput; + + expect(ast).toEqual('plop'); + }); }); diff --git a/sonar-plugin/bridge/src/main/java/org/sonar/plugins/javascript/bridge/BridgeServer.java b/sonar-plugin/bridge/src/main/java/org/sonar/plugins/javascript/bridge/BridgeServer.java index b9a94353d4f..6e5be40c224 100644 --- a/sonar-plugin/bridge/src/main/java/org/sonar/plugins/javascript/bridge/BridgeServer.java +++ b/sonar-plugin/bridge/src/main/java/org/sonar/plugins/javascript/bridge/BridgeServer.java @@ -73,14 +73,26 @@ record JsAnalysisRequest(String filePath, String fileType, String language, @Nul record CssAnalysisRequest(String filePath, @Nullable String fileContent, List rules) { } + record BridgeResponse(String json, @Nullable String ast) { + public BridgeResponse(String json) { + this(json, null); + } + } record AnalysisResponse(@Nullable ParsingError parsingError, List issues, List highlights, - List highlightedSymbols, Metrics metrics, List cpdTokens, List ucfgPaths) { + List highlightedSymbols, Metrics metrics, List cpdTokens, List ucfgPaths, String ast) { + + public AnalysisResponse(AnalysisResponse response, String ast) { + this(response.parsingError, response.issues, response.highlights, response.highlightedSymbols, + response.metrics, response.cpdTokens, response.ucfgPaths, ast); + } + public AnalysisResponse() { - this(null, List.of(), List.of(), List.of(), new Metrics(), List.of(), List.of()); + this(null, List.of(), List.of(), List.of(), new Metrics(), List.of(), List.of(), null); } public AnalysisResponse(@Nullable ParsingError parsingError, @Nullable List issues, @Nullable List highlights, - @Nullable List highlightedSymbols, @Nullable Metrics metrics, @Nullable List cpdTokens, List ucfgPaths) { + @Nullable List highlightedSymbols, @Nullable Metrics metrics, + @Nullable List cpdTokens, List ucfgPaths, @Nullable String ast) { this.parsingError = parsingError; this.issues = issues != null ? issues : List.of(); this.highlights = highlights != null ? highlights : List.of(); @@ -88,6 +100,7 @@ public AnalysisResponse(@Nullable ParsingError parsingError, @Nullable List response) { + var contentTypeHeader = response.headers().firstValue("Content-type").orElse(""); + return contentTypeHeader.contains("multipart/form-data"); + } + private static IllegalStateException handleInterruptedException( InterruptedException e, String msg @@ -411,9 +421,12 @@ private static IllegalStateException handleInterruptedException( return new IllegalStateException(msg, e); } - private static AnalysisResponse response(String result, String filePath) { + private static AnalysisResponse response(BridgeResponse result, String filePath) { try { - return GSON.fromJson(result, AnalysisResponse.class); + return new AnalysisResponse( + GSON.fromJson(result.json(), AnalysisResponse.class), + result.ast() + ); } catch (JsonSyntaxException e) { String msg = "Failed to parse response for file " + filePath + ": \n-----\n" + result + "\n-----\n"; @@ -441,7 +454,7 @@ public boolean isAlive() { @Override public boolean newTsConfig() { try { - var response = request("", "new-tsconfig"); + var response = request("", "new-tsconfig").json(); return "OK!".equals(response); } catch (IOException e) { LOG.error("Failed to post new-tsconfig", e); @@ -453,7 +466,7 @@ TsConfigResponse tsConfigFiles(String tsconfigAbsolutePath) { String result = null; try { TsConfigRequest tsConfigRequest = new TsConfigRequest(tsconfigAbsolutePath); - result = request(GSON.toJson(tsConfigRequest), "tsconfig-files"); + result = request(GSON.toJson(tsConfigRequest), "tsconfig-files").json(); return GSON.fromJson(result, TsConfigResponse.class); } catch (IOException e) { LOG.error("Failed to request files for tsconfig: " + tsconfigAbsolutePath, e); @@ -482,20 +495,20 @@ public TsConfigFile loadTsConfig(String filename) { @Override public TsProgram createProgram(TsProgramRequest tsProgramRequest) throws IOException { var response = request(GSON.toJson(tsProgramRequest), "create-program"); - return GSON.fromJson(response, TsProgram.class); + return GSON.fromJson(response.json(), TsProgram.class); } @Override public boolean deleteProgram(TsProgram tsProgram) throws IOException { var programToDelete = new TsProgram(tsProgram.programId(), null, null); - var response = request(GSON.toJson(programToDelete), "delete-program"); + var response = request(GSON.toJson(programToDelete), "delete-program").json(); return "OK!".equals(response); } @Override public TsConfigFile createTsConfigFile(String content) throws IOException { var response = request(content, "create-tsconfig-file"); - return GSON.fromJson(response, TsConfigFile.class); + return GSON.fromJson(response.json(), TsConfigFile.class); } private static List emptyListIfNull(@Nullable List list) { diff --git a/sonar-plugin/bridge/src/main/java/org/sonar/plugins/javascript/bridge/FormDataUtils.java b/sonar-plugin/bridge/src/main/java/org/sonar/plugins/javascript/bridge/FormDataUtils.java new file mode 100644 index 00000000000..643261caace --- /dev/null +++ b/sonar-plugin/bridge/src/main/java/org/sonar/plugins/javascript/bridge/FormDataUtils.java @@ -0,0 +1,58 @@ +/* + * SonarQube JavaScript Plugin + * Copyright (C) 2011-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.plugins.javascript.bridge; + +import java.net.http.HttpResponse; + +public class FormDataUtils { + private FormDataUtils() { + throw new IllegalStateException("Utility class"); + } + + public static BridgeServer.BridgeResponse parseFormData(HttpResponse response) { + String boundary = "--" + response.headers().firstValue("Content-Type") + .orElseThrow(() -> new IllegalStateException("No Content-Type header")) + .split("boundary=")[1]; + String[] parts = response.body().split(boundary); + String json = null; + String ast = null; + for (String part : parts) { + // Split the part into headers and body + String[] splitPart = part.split("\r\n\r\n", 2); + if (splitPart.length < 2) { + // Skip if there's no body + continue; + } + + String headers = splitPart[0]; + String partBody = splitPart[1]; + + if (headers.contains("json")) { + json = partBody; + } else if (headers.contains("ast")) { + ast = partBody; + } + } + if (json == null || ast == null) { + throw new IllegalStateException("Data missing from response"); + } + return new BridgeServer.BridgeResponse(json, ast); + } +} diff --git a/sonar-plugin/bridge/src/test/java/org/sonar/plugins/javascript/bridge/BridgeServerImplTest.java b/sonar-plugin/bridge/src/test/java/org/sonar/plugins/javascript/bridge/BridgeServerImplTest.java index 945a8b98561..0d452c11a2e 100644 --- a/sonar-plugin/bridge/src/test/java/org/sonar/plugins/javascript/bridge/BridgeServerImplTest.java +++ b/sonar-plugin/bridge/src/test/java/org/sonar/plugins/javascript/bridge/BridgeServerImplTest.java @@ -183,7 +183,8 @@ void should_get_answer_from_server() throws Exception { .setContents("alert('Fly, you fools!')") .build(); JsAnalysisRequest request = createRequest(inputFile); - assertThat(bridgeServer.analyzeJavaScript(request).issues()).hasSize(1); + var response = bridgeServer.analyzeJavaScript(request); + assertThat(response.issues()).hasSize(1); } @Test @@ -721,6 +722,19 @@ void test_ucfg_bundle_version() throws Exception { .contains("Security Frontend version is available: [some_bundle_version]"); } + @Test + void should_return_an_ast() throws Exception { + bridgeServer = createBridgeServer(START_SERVER_SCRIPT); + bridgeServer.startServer(context, emptyList()); + + DefaultInputFile inputFile = TestInputFileBuilder + .create("foo", "foo.js") + .setContents("alert('Fly, you fools!')") + .build(); + JsAnalysisRequest request = createRequest(inputFile); + var response = bridgeServer.analyzeJavaScript(request); + assertThat(response.ast()).contains("plop"); + } @Test void should_not_deploy_runtime_if_sonar_nodejs_executable_is_set() { var existingDoesntMatterScript = "logging.js"; @@ -734,7 +748,6 @@ void should_not_deploy_runtime_if_sonar_nodejs_executable_is_set() { "'" + NODE_EXECUTABLE_PROPERTY + "' is set. Skipping embedded Node.js runtime deployment." ); } - private BridgeServerImpl createBridgeServer(String startServerScript) { return new BridgeServerImpl( builder(), diff --git a/sonar-plugin/bridge/src/test/java/org/sonar/plugins/javascript/bridge/FormDataUtilsTest.java b/sonar-plugin/bridge/src/test/java/org/sonar/plugins/javascript/bridge/FormDataUtilsTest.java new file mode 100644 index 00000000000..af6e1461b39 --- /dev/null +++ b/sonar-plugin/bridge/src/test/java/org/sonar/plugins/javascript/bridge/FormDataUtilsTest.java @@ -0,0 +1,102 @@ +/* + * SonarQube JavaScript Plugin + * Copyright (C) 2011-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.plugins.javascript.bridge; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.plugins.javascript.bridge.FormDataUtils.parseFormData; + +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class FormDataUtilsTest { + + @Test + void should_parse_form_data_into_bridge_response() { + HttpResponse mockResponse = mock(HttpResponse.class); + var values = new HashMap>(); + values.put("Content-Type", List.of("multipart/form-data; boundary=---------------------------9051914041544843365972754266")); + when(mockResponse.headers()).thenReturn(HttpHeaders.of(values, (_a, _b) -> true)); + when(mockResponse.body()).thenReturn("-----------------------------9051914041544843365972754266" + + "\r\n" + + "Content-Disposition: form-data; name=\"json\"" + + "\r\n" + + "\r\n" + + "{\"hello\":\"worlds\"}" + + "\r\n" + + "-----------------------------9051914041544843365972754266" + + "\r\n" + + "Content-Disposition: form-data; name=\"ast\"" + + "\r\n" + + "\r\n" + + "plop" + + "\r\n" + + "-----------------------------9051914041544843365972754266--" + + "\r\n"); + BridgeServer.BridgeResponse response = parseFormData(mockResponse); + assertThat(response.ast()).contains("plop"); + assertThat(response.json()).contains("{\"hello\":\"worlds\"}"); + } + + @Test + void should_throw_an_error_if_json_is_missing() { + HttpResponse mockResponse = mock(HttpResponse.class); + var values = new HashMap>(); + values.put("Content-Type", List.of("multipart/form-data; boundary=---------------------------9051914041544843365972754266")); + when(mockResponse.headers()).thenReturn(HttpHeaders.of(values, (_a, _b) -> true)); + when(mockResponse.body()).thenReturn("-----------------------------9051914041544843365972754266" + + "\r\n" + + "Content-Disposition: form-data; name=\"ast\"" + + "\r\n" + + "\r\n" + + "plop" + + "\r\n" + + "-----------------------------9051914041544843365972754266--" + + "\r\n"); + assertThatThrownBy(() -> parseFormData(mockResponse)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Data missing from response"); + } + + @Test + void should_throw_an_error_if_ast_is_missing() { + HttpResponse mockResponse = mock(HttpResponse.class); + var values = new HashMap>(); + values.put("Content-Type", List.of("multipart/form-data; boundary=---------------------------9051914041544843365972754266")); + when(mockResponse.headers()).thenReturn(HttpHeaders.of(values, (_a, _b) -> true)); + when(mockResponse.body()).thenReturn("-----------------------------9051914041544843365972754266" + + "\r\n" + + "Content-Disposition: form-data; name=\"json\"" + + "\r\n" + + "\r\n" + + "{\"hello\":\"worlds\"}" + + "\r\n" + + "-----------------------------9051914041544843365972754266--" + + "\r\n"); + assertThatThrownBy(() -> parseFormData(mockResponse)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Data missing from response"); + } +} diff --git a/sonar-plugin/bridge/src/test/resources/mock-bridge/startServer.js b/sonar-plugin/bridge/src/test/resources/mock-bridge/startServer.js index b7d06544488..f95f2d01ee6 100644 --- a/sonar-plugin/bridge/src/test/resources/mock-bridge/startServer.js +++ b/sonar-plugin/bridge/src/test/resources/mock-bridge/startServer.js @@ -1,6 +1,7 @@ #!/usr/bin/env node const http = require('http'); +const formData = require('form-data'); const port = process.argv[2]; const host = process.argv[3]; @@ -37,11 +38,7 @@ const requestHandler = (request, response) => { response.end('OK!'); } else if (request.url === '/create-tsconfig-file') { response.end('{"filename":"/path/to/tsconfig.json"}'); - } else { - // /analyze-with-program - // /analyze-js - // /analyze-ts - // /analyze-css + } else if (['/analyze-css', '/analyze-yaml', '/analyze-html'].includes(request.url)) { // objects are created to have test coverage response.end(`{ issues: [{line:0, column:0, endLine:0, endColumn:0, quickFixes: [ @@ -50,6 +47,59 @@ const requestHandler = (request, response) => { loc: {}}]}]}], highlights: [{location: {startLine: 0, startColumn: 0, endLine: 0, endColumn: 0}}], metrics: {}, highlightedSymbols: [{}], cpdTokens: [{}] }`); + } else { + // /analyze-with-program + // /analyze-js + // /analyze-ts + // objects are created to have test coverage + const res = { + issues: [ + { + line: 0, + column: 0, + endLine: 0, + endColumn: 0, + quickFixes: [ + { + edits: [ + { + loc: {}, + }, + ], + }, + ], + }, + ], + highlights: [{ location: { startLine: 0, startColumn: 0, endLine: 0, endColumn: 0 } }], + metrics: {}, + highlightedSymbols: [{}], + cpdTokens: [{}], + }; + const boundary = '---------9051914041544843365972754266'; + const contentTypeHeader = `multipart/form-data; boundary=${boundary}`; + let body = ''; + body += `--${boundary}`; + body += `\r\n`; + body += `Content-Disposition: form-data; name="json"`; + body += `\r\n`; + body += `\r\n`; + body += `${JSON.stringify(res)}`; + body += `\r\n`; + body += `--${boundary}`; + body += `\r\n`; + body += `Content-Disposition: form-data; name="ast"`; + body += `\r\n`; + body += `\r\n`; + body += `plop`; + body += `\r\n`; + body += `--${boundary}--`; + body += `\r\n`; + // this adds the boundary string that will be + response.writeHead(200, { + 'Content-Type': contentTypeHeader, + 'Content-Length': Buffer.byteLength(body, 'utf-8'), + }); + response.end(body); } }); }; diff --git a/sonar-plugin/sonar-javascript-plugin/src/test/java/org/sonar/plugins/javascript/analysis/AnalysisProcessorTest.java b/sonar-plugin/sonar-javascript-plugin/src/test/java/org/sonar/plugins/javascript/analysis/AnalysisProcessorTest.java index d3a70e252d6..eaebaea83f5 100644 --- a/sonar-plugin/sonar-javascript-plugin/src/test/java/org/sonar/plugins/javascript/analysis/AnalysisProcessorTest.java +++ b/sonar-plugin/sonar-javascript-plugin/src/test/java/org/sonar/plugins/javascript/analysis/AnalysisProcessorTest.java @@ -42,7 +42,7 @@ void should_not_fail_when_invalid_range() { .build(); var location = new Location(1, 2, 1, 1); // invalid range startCol > endCol var highlight = new Highlight(location, ""); - var response = new AnalysisResponse(null, List.of(), List.of(highlight), List.of(), new Metrics(), List.of(), List.of()); + var response = new AnalysisResponse(null, List.of(), List.of(highlight), List.of(), new Metrics(), List.of(), List.of(), null); processor.processResponse(context, mock(JsTsChecks.class), file, response); assertThat(logTester.logs()) .contains("Failed to save highlight in " + file.uri() + " at 1:2-1:1"); @@ -60,14 +60,14 @@ void should_not_fail_when_invalid_symbol() { .build(); var declaration = new Location(1, 2, 1, 1); // invalid range startCol > endCol var symbol = new HighlightedSymbol(declaration, List.of()); - var response = new AnalysisResponse(null, List.of(), List.of(), List.of(symbol), new Metrics(), List.of(), List.of()); + var response = new AnalysisResponse(null, List.of(), List.of(), List.of(symbol), new Metrics(), List.of(), List.of(), null); processor.processResponse(context, mock(JsTsChecks.class), file, response); assertThat(logTester.logs()) .contains("Failed to create symbol declaration in " + file.uri() + " at 1:2-1:1"); context = SensorContextTester.create(baseDir); symbol = new HighlightedSymbol(new Location(1, 1, 1, 2), List.of(new Location(2, 2, 2, 1))); - response = new AnalysisResponse(null, List.of(), List.of(), List.of(symbol), new Metrics(), List.of(), List.of()); + response = new AnalysisResponse(null, List.of(), List.of(), List.of(symbol), new Metrics(), List.of(), List.of(), null); processor.processResponse(context, mock(JsTsChecks.class), file, response); assertThat(logTester.logs()) .contains("Failed to create symbol reference in " + file.uri() + " at 2:2-2:1"); @@ -85,7 +85,7 @@ void should_not_fail_when_invalid_cpd() { .build(); var location = new Location(1, 2, 1, 1); // invalid range startCol > endCol var cpd = new CpdToken(location, "img"); - var response = new AnalysisResponse(null, List.of(), List.of(), List.of(), new Metrics(), List.of(cpd), List.of()); + var response = new AnalysisResponse(null, List.of(), List.of(), List.of(), new Metrics(), List.of(cpd), List.of(), null); processor.processResponse(context, mock(JsTsChecks.class), file, response); assertThat(context.cpdTokens(file.key())).isNull(); assertThat(logTester.logs()) diff --git a/sonar-plugin/sonar-javascript-plugin/src/test/java/org/sonar/plugins/javascript/analysis/JsTsSensorTest.java b/sonar-plugin/sonar-javascript-plugin/src/test/java/org/sonar/plugins/javascript/analysis/JsTsSensorTest.java index fbea47081a3..5e3bd97f37b 100644 --- a/sonar-plugin/sonar-javascript-plugin/src/test/java/org/sonar/plugins/javascript/analysis/JsTsSensorTest.java +++ b/sonar-plugin/sonar-javascript-plugin/src/test/java/org/sonar/plugins/javascript/analysis/JsTsSensorTest.java @@ -390,7 +390,7 @@ void should_send_content_when_not_utf8() throws Exception { @Test void should_log_when_failing_typescript() throws Exception { var err = new ParsingError("Debug Failure. False expression.", null, ParsingErrorCode.FAILING_TYPESCRIPT); - var parseError = new AnalysisResponse(err, null, null, null, null, null, null); + var parseError = new AnalysisResponse(err, null, null, null, null, null, null, null); when(bridgeServerMock.analyzeTypeScript(any())).thenReturn(parseError); var file1 = createInputFile(context, "dir/file1.ts"); var file2 = createInputFile(context, "dir/file2.ts"); diff --git a/sonar-plugin/sonar-javascript-plugin/src/test/java/org/sonar/plugins/javascript/analysis/QuickFixSupportTest.java b/sonar-plugin/sonar-javascript-plugin/src/test/java/org/sonar/plugins/javascript/analysis/QuickFixSupportTest.java index ce38914ecd0..8b53396072a 100644 --- a/sonar-plugin/sonar-javascript-plugin/src/test/java/org/sonar/plugins/javascript/analysis/QuickFixSupportTest.java +++ b/sonar-plugin/sonar-javascript-plugin/src/test/java/org/sonar/plugins/javascript/analysis/QuickFixSupportTest.java @@ -103,7 +103,7 @@ DefaultSensorContext createContext(Version version) { void test() { var context = createContext(Version.create(6, 3)); - var response = new AnalysisResponse(null, List.of(issueWithQuickFix()), List.of(), List.of(), new Metrics(), List.of(), List.of()); + var response = new AnalysisResponse(null, List.of(issueWithQuickFix()), List.of(), List.of(), new Metrics(), List.of(), List.of(), null); var issueCaptor = ArgumentCaptor.forClass(DefaultSonarLintIssue.class); doNothing().when(sensorStorage).store(issueCaptor.capture()); @@ -135,7 +135,7 @@ static Issue issueWithQuickFix() { @Test void test_old_version() { var context = createContext(Version.create(6, 2)); - var response = new AnalysisResponse(null, List.of(issueWithQuickFix()), List.of(), List.of(), new Metrics(), List.of(), List.of()); + var response = new AnalysisResponse(null, List.of(issueWithQuickFix()), List.of(), List.of(), new Metrics(), List.of(), List.of(), null); var issueCaptor = ArgumentCaptor.forClass(DefaultSonarLintIssue.class); doNothing().when(sensorStorage).store(issueCaptor.capture()); @@ -148,7 +148,7 @@ void test_old_version() { void test_null() { var context = createContext(Version.create(6, 3)); var issue = new Issue(1, 1, 1, 1,"", "no-extra-semi", List.of(), 1.0, List.of()); - var response = new AnalysisResponse(null, List.of(issue), List.of(), List.of(), new Metrics(), List.of(), List.of()); + var response = new AnalysisResponse(null, List.of(issue), List.of(), List.of(), new Metrics(), List.of(), List.of(), null); var issueCaptor = ArgumentCaptor.forClass(DefaultSonarLintIssue.class); doNothing().when(sensorStorage).store(issueCaptor.capture());