@jsenv/test 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/test",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,13 +38,13 @@
38
38
  "dependencies": {
39
39
  "@c88/v8-coverage": "0.1.1",
40
40
  "@jsenv/abort": "4.3.0",
41
- "@jsenv/ast": "6.1.0",
42
- "@jsenv/filesystem": "4.7.2",
43
- "@jsenv/github-check-run": "0.1.16",
44
- "@jsenv/humanize": "1.1.3",
45
- "@jsenv/plugin-supervisor": "1.4.12",
41
+ "@jsenv/ast": "6.1.2",
42
+ "@jsenv/filesystem": "4.7.3",
43
+ "@jsenv/github-check-run": "0.1.18",
44
+ "@jsenv/humanize": "1.2.0",
45
+ "@jsenv/plugin-supervisor": "1.4.14",
46
46
  "@jsenv/url-meta": "8.4.2",
47
- "@jsenv/urls": "2.2.7",
47
+ "@jsenv/urls": "2.2.8",
48
48
  "@jsenv/utils": "2.1.1",
49
49
  "istanbul-lib-coverage": "3.2.2",
50
50
  "istanbul-lib-instrument": "6.0.3",
@@ -211,9 +211,7 @@ const getErrorName = (value) => {
211
211
 
212
212
  const getPropertiesFromEvalOrigin = (origin) => {
213
213
  // Most eval() calls are in this format
214
- const topLevelEvalMatch = /^eval at ([^(]+) \((.+):(\d+):(\d+)\)$/.exec(
215
- origin,
216
- );
214
+ const topLevelEvalMatch = /^eval at [^(]+ \(.+:\d+:\d+\)$/.exec(origin);
217
215
  if (topLevelEvalMatch) {
218
216
  const source = topLevelEvalMatch[2];
219
217
  const line = Number(topLevelEvalMatch[3]);
@@ -225,7 +223,7 @@ const getPropertiesFromEvalOrigin = (origin) => {
225
223
  };
226
224
  }
227
225
  // Parse nested eval() calls using recursion
228
- const nestedEvalMatch = /^eval at ([^(]+) \((.+)\)$/.exec(origin);
226
+ const nestedEvalMatch = /^eval at [^(]+ \(.+\)$/.exec(origin);
229
227
  if (nestedEvalMatch) {
230
228
  return getPropertiesFromEvalOrigin(nestedEvalMatch[2]);
231
229
  }
@@ -33,6 +33,7 @@ import { assertAndNormalizeWebServer } from "./web_server_param.js";
33
33
  import { githubAnnotationFromError } from "./github_annotation_from_error.js";
34
34
  import { run } from "./run.js";
35
35
  import { reporterList, renderOutroContent } from "./reporters/reporter_list.js";
36
+ import { createIsInsideFragment } from "./is_inside_fragment.js";
36
37
 
37
38
  /**
38
39
  * Execute a list of files and log how it goes.
@@ -114,6 +115,11 @@ export const executeTestPlan = async ({
114
115
  handleSIGTERM = true,
115
116
  updateProcessExitCode = true,
116
117
  parallel = parallelDefault,
118
+ // https://github.com/avajs/ava/blob/main/docs/recipes/splitting-tests-ci.md
119
+ // https://playwright.dev/docs/test-sharding
120
+ fragment,
121
+ fragmentByRuntime,
122
+ fragmentBy,
117
123
  defaultMsAllocatedPerExecution = 30_000,
118
124
  failFast = false,
119
125
  // keepRunning: false to ensure runtime is stopped once executed
@@ -207,6 +213,9 @@ export const executeTestPlan = async ({
207
213
  rootDirectoryUrl: String(rootDirectoryUrl),
208
214
  patterns: Object.keys(testPlan),
209
215
  groups: {},
216
+ fragment,
217
+ fragmentStart: undefined,
218
+ fragmentEnd: undefined,
210
219
  counters: {
211
220
  planified: 0,
212
221
  remaining: 0,
@@ -214,6 +223,7 @@ export const executeTestPlan = async ({
214
223
  executing: 0,
215
224
  executed: 0,
216
225
 
226
+ skipped: 0,
217
227
  aborted: 0,
218
228
  cancelled: 0,
219
229
  timedout: 0,
@@ -243,6 +253,36 @@ export const executeTestPlan = async ({
243
253
  };
244
254
  const beforeEachCallbackSet = new Set();
245
255
  const beforeEachInOrderCallbackSet = new Set();
256
+ const triggerBeforeEach = (execution, testPlanResult, testPlanHelpers) => {
257
+ for (const beforeEachCallback of beforeEachCallbackSet) {
258
+ const returnValue = beforeEachCallback(
259
+ execution,
260
+ testPlanResult,
261
+ testPlanHelpers,
262
+ );
263
+ if (typeof returnValue === "function") {
264
+ const callback = (...args) => {
265
+ afterEachCallbackSet.delete(callback);
266
+ return returnValue(...args);
267
+ };
268
+ afterEachCallbackSet.add(callback);
269
+ }
270
+ }
271
+ for (const beforeEachInOrderCallback of beforeEachInOrderCallbackSet) {
272
+ const returnValue = beforeEachInOrderCallback(
273
+ execution,
274
+ testPlanResult,
275
+ testPlanHelpers,
276
+ );
277
+ if (typeof returnValue === "function") {
278
+ const callback = (...args) => {
279
+ afterEachInOrderCallbackSet.delete(callback);
280
+ return returnValue(...args);
281
+ };
282
+ afterEachInOrderCallbackSet.add(callback);
283
+ }
284
+ }
285
+ };
246
286
  const afterEachCallbackSet = new Set();
247
287
  const afterEachInOrderCallbackSet = new Set();
248
288
  const afterAllCallbackSet = new Set();
@@ -375,6 +415,16 @@ export const executeTestPlan = async ({
375
415
  );
376
416
  }
377
417
  }
418
+ // fragment/fragmentByRuntime
419
+ {
420
+ if (fragment) {
421
+ if (!/[0-9]+\/[0-9]+/.test(fragment)) {
422
+ throw new TypeError(
423
+ `fragment must look like number/number, received ${fragment}`,
424
+ );
425
+ }
426
+ }
427
+ }
378
428
  // testPlan
379
429
  {
380
430
  if (typeof testPlan !== "object") {
@@ -579,9 +629,21 @@ To fix this warning:
579
629
  }
580
630
  }
581
631
 
582
- const executionPlanifiedSet = new Set();
632
+ const executionPlanifiedArray = [];
633
+ const getPreviousExecution = (execution) => {
634
+ const index = execution.index;
635
+ return executionPlanifiedArray[index - 1] || null;
636
+ };
637
+ const getNextExecution = (execution) => {
638
+ const index = execution.index;
639
+ return executionPlanifiedArray[index + 1] || null;
640
+ };
641
+ const testPlanHelpers = {
642
+ getPreviousExecution,
643
+ getNextExecution,
644
+ };
583
645
 
584
- // collect files to execute + fill executionPlanifiedSet
646
+ // collect files to execute + fill executionPlanifiedArray
585
647
  {
586
648
  const fileResultArray = await collectFiles({
587
649
  signal,
@@ -589,6 +651,11 @@ To fix this warning:
589
651
  associations: { testPlan },
590
652
  predicate: ({ testPlan }) => testPlan,
591
653
  });
654
+
655
+ // idéalement dans les logs on aurait un truc comme ceci:
656
+ // --- 100 execution found ---
657
+ // Executing only from 33 to 66 according to fragment: "2/3"
658
+
592
659
  let index = 0;
593
660
  let lastExecution;
594
661
  const fileExecutionCountMap = new Map();
@@ -611,10 +678,6 @@ To fix this warning:
611
678
  ),
612
679
  );
613
680
  }
614
- if (stepConfig.runtime?.disabled) {
615
- continue;
616
- }
617
-
618
681
  const {
619
682
  runtime,
620
683
  runtimeParams,
@@ -671,6 +734,8 @@ To fix this warning:
671
734
  runtimeName,
672
735
  runtimeVersion,
673
736
  params,
737
+ skipped: false,
738
+ skipReason: "",
674
739
 
675
740
  // will be set by run()
676
741
  status: "planified",
@@ -681,7 +746,7 @@ To fix this warning:
681
746
  }
682
747
 
683
748
  lastExecution = execution;
684
- executionPlanifiedSet.add(execution);
749
+ executionPlanifiedArray.push(execution);
685
750
  const existingResults = results[relativeUrl];
686
751
  if (existingResults) {
687
752
  existingResults[groupName] = execution.result;
@@ -701,9 +766,48 @@ To fix this warning:
701
766
  runtimeVersion,
702
767
  };
703
768
  }
769
+
770
+ if (runtime?.disabled) {
771
+ execution.skipped = true;
772
+ execution.skipReason =
773
+ runtime.disabledReason || "reason not specified";
774
+ } else if (fragmentByRuntime && runtime.name !== fragmentByRuntime) {
775
+ execution.skipped = true;
776
+ execution.skipReason = "runtime disabled";
777
+ } else if (fragmentBy) {
778
+ const fragmentByResult = fragmentBy(execution);
779
+ if (fragmentByResult) {
780
+ execution.skipped = true;
781
+ execution.skipReason =
782
+ typeof fragmentByResult === "string"
783
+ ? fragmentByResult
784
+ : `reason not specified`;
785
+ }
786
+ }
704
787
  index++;
705
788
  }
706
789
  }
790
+
791
+ if (fragment) {
792
+ const total = executionPlanifiedArray.length;
793
+ const isInsideFragment = createIsInsideFragment(fragment, total);
794
+ for (const execution of executionPlanifiedArray) {
795
+ if (isInsideFragment(execution.index)) {
796
+ const executionNumber = execution.index + 1;
797
+ if (testPlanResult.fragmentStart === undefined) {
798
+ testPlanResult.fragmentStart = executionNumber;
799
+ }
800
+ if (
801
+ testPlanResult.fragmentEnd === undefined ||
802
+ executionNumber > testPlanResult.fragmentEnd
803
+ ) {
804
+ testPlanResult.fragmentEnd = executionNumber;
805
+ }
806
+ } else {
807
+ execution.skipped = true;
808
+ }
809
+ }
810
+ }
707
811
  fileResultArray.length = 0;
708
812
  fileExecutionCountMap.clear();
709
813
  if (lastExecution) {
@@ -714,11 +818,11 @@ To fix this warning:
714
818
  counters.planified =
715
819
  counters.remaining =
716
820
  counters.waiting =
717
- executionPlanifiedSet.size;
821
+ executionPlanifiedArray.length;
718
822
  countersInOrder.planified =
719
823
  countersInOrder.remaining =
720
824
  countersInOrder.waiting =
721
- executionPlanifiedSet.size;
825
+ executionPlanifiedArray.length;
722
826
  if (githubCheck) {
723
827
  const githubCheckRun = await startGithubCheckRun({
724
828
  logLevel: githubCheck.logLevel,
@@ -728,7 +832,7 @@ To fix this warning:
728
832
  commitSha: githubCheck.commitSha,
729
833
  checkName: githubCheck.name,
730
834
  checkTitle: githubCheck.title,
731
- checkSummary: `${executionPlanifiedSet.size} files will be executed`,
835
+ checkSummary: `${executionPlanifiedArray.length} files will be executed`,
732
836
  });
733
837
  const annotations = [];
734
838
  reporters.push({
@@ -808,80 +912,66 @@ To fix this warning:
808
912
 
809
913
  const callWhenPreviousExecutionAreDone = createCallOrderer();
810
914
 
811
- const executionRemainingSet = new Set(executionPlanifiedSet);
915
+ const executionRemainingSet = new Set(executionPlanifiedArray);
812
916
  const executionExecutingSet = new Set();
813
917
  const usedTagSet = new Set();
814
918
  const start = async (execution) => {
815
- if (execution.params.uses) {
816
- for (const tagThatWillBeUsed of execution.params.uses) {
817
- usedTagSet.add(tagThatWillBeUsed);
818
- }
819
- }
820
919
  execution.fileExecutionCount = Object.keys(
821
920
  testPlanResult.results[execution.fileRelativeUrl],
822
921
  ).length;
823
922
  mutateCountersBeforeExecutionStarts(counters, execution);
824
923
  mutateCountersBeforeExecutionStarts(countersInOrder, execution);
825
-
826
- execution.status = "executing";
827
924
  executionRemainingSet.delete(execution);
828
925
  executionExecutingSet.add(execution);
829
- for (const beforeEachCallback of beforeEachCallbackSet) {
830
- const returnValue = beforeEachCallback(execution, testPlanResult);
831
- if (typeof returnValue === "function") {
832
- const callback = (...args) => {
833
- afterEachCallbackSet.delete(callback);
834
- return returnValue(...args);
835
- };
836
- afterEachCallbackSet.add(callback);
926
+ triggerBeforeEach(execution, testPlanResult, testPlanHelpers);
927
+ if (execution.skipped) {
928
+ execution.result.status = "skipped";
929
+ execution.result.value = execution.skipReason;
930
+ } else {
931
+ if (execution.params.uses) {
932
+ for (const tagThatWillBeUsed of execution.params.uses) {
933
+ usedTagSet.add(tagThatWillBeUsed);
934
+ }
837
935
  }
838
- }
839
- for (const beforeEachInOrderCallback of beforeEachInOrderCallbackSet) {
840
- const returnValue = beforeEachInOrderCallback(
841
- execution,
842
- testPlanResult,
843
- );
844
- if (typeof returnValue === "function") {
845
- const callback = (...args) => {
846
- afterEachInOrderCallbackSet.delete(callback);
847
- return returnValue(...args);
848
- };
849
- afterEachInOrderCallbackSet.add(callback);
936
+ execution.status = "executing";
937
+ const executionResult = await run({
938
+ ...execution.params,
939
+ signal: operation.signal,
940
+ logger,
941
+ keepRunning,
942
+ mirrorConsole: false, // might be executed in parallel: log would be a mess to read
943
+ coverageEnabled: Boolean(coverage),
944
+ coverageTempDirectoryUrl: coverage?.tempDirectoryUrl,
945
+ });
946
+ Object.assign(execution.result, executionResult);
947
+ execution.status = "executed";
948
+ if (execution.params.uses) {
949
+ for (const tagNoLongerInUse of execution.params.uses) {
950
+ usedTagSet.delete(tagNoLongerInUse);
951
+ }
850
952
  }
851
- }
852
- const executionResult = await run({
853
- ...execution.params,
854
- signal: operation.signal,
855
- logger,
856
- keepRunning,
857
- mirrorConsole: false, // might be executed in parallel: log would be a mess to read
858
- coverageEnabled: Boolean(coverage),
859
- coverageTempDirectoryUrl: coverage?.tempDirectoryUrl,
860
- });
861
- Object.assign(execution.result, executionResult);
862
- execution.status = "executed";
863
- executionExecutingSet.delete(execution);
864
- mutateCountersAfterExecutionEnds(counters, execution);
865
- if (execution.result.status !== "completed") {
866
- testPlanResult.failed = true;
867
- if (updateProcessExitCode) {
868
- process.exitCode = 1;
953
+ if (execution.result.status !== "completed") {
954
+ testPlanResult.failed = true;
955
+ if (updateProcessExitCode) {
956
+ process.exitCode = 1;
957
+ }
869
958
  }
870
959
  }
960
+ mutateCountersAfterExecutionEnds(counters, execution);
961
+ executionExecutingSet.delete(execution);
871
962
  for (const afterEachCallback of afterEachCallbackSet) {
872
- afterEachCallback(execution, testPlanResult);
963
+ afterEachCallback(execution, testPlanResult, testPlanHelpers);
873
964
  }
874
965
  callWhenPreviousExecutionAreDone(execution.index, () => {
875
966
  mutateCountersAfterExecutionEnds(countersInOrder, execution);
876
967
  for (const afterEachInOrderCallback of afterEachInOrderCallbackSet) {
877
- afterEachInOrderCallback(execution, testPlanResult);
968
+ afterEachInOrderCallback(
969
+ execution,
970
+ testPlanResult,
971
+ testPlanHelpers,
972
+ );
878
973
  }
879
974
  });
880
- if (execution.params.uses) {
881
- for (const tagNoLongerInUse of execution.params.uses) {
882
- usedTagSet.delete(tagNoLongerInUse);
883
- }
884
- }
885
975
  if (testPlanResult.failed && failFast && counters.remaining) {
886
976
  logger.info(`"failFast" enabled -> cancel remaining executions`);
887
977
  failFastAbortController.abort();
@@ -1096,7 +1186,9 @@ const mutateCountersAfterExecutionEnds = (counters, execution) => {
1096
1186
  counters.executing--;
1097
1187
  counters.executed++;
1098
1188
  counters.remaining--;
1099
- if (execution.result.status === "aborted") {
1189
+ if (execution.result.status === "skipped") {
1190
+ counters.skipped++;
1191
+ } else if (execution.result.status === "aborted") {
1100
1192
  counters.aborted++;
1101
1193
  } else if (execution.result.status === "timedout") {
1102
1194
  counters.timedout++;
@@ -2,6 +2,8 @@ import { readFileSync } from "node:fs";
2
2
  import { generateContentFrame, ANSI } from "@jsenv/humanize";
3
3
  import { urlToFileSystemPath } from "@jsenv/urls";
4
4
 
5
+ import { replaceUrls } from "./replace_urls.js";
6
+
5
7
  export const formatErrorForTerminal = (
6
8
  error,
7
9
  { rootDirectoryUrl, mainFileRelativeUrl, mockFluctuatingValues, tryColors },
@@ -133,54 +135,3 @@ export const formatErrorForTerminal = (
133
135
 
134
136
  return text;
135
137
  };
136
-
137
- // `Error: yo
138
- // at Object.execute (http://127.0.0.1:57300/build/src/__test__/file-throw.js:9:13)
139
- // at doExec (http://127.0.0.1:3000/src/__test__/file-throw.js:452:38)
140
- // at postOrderExec (http://127.0.0.1:3000/src/__test__/file-throw.js:448:16)
141
- // at http://127.0.0.1:3000/src/__test__/file-throw.js:399:18`.replace(/(?:https?|ftp|file):\/\/(.*+)$/gm, (...args) => {
142
- // debugger
143
- // })
144
- const replaceUrls = (source, replace) => {
145
- return source.replace(/(?:https?|ftp|file):\/\/\S+/gm, (match) => {
146
- let replacement = "";
147
- const lastChar = match[match.length - 1];
148
-
149
- // hotfix because our url regex sucks a bit
150
- const endsWithSeparationChar = lastChar === ")" || lastChar === ":";
151
- if (endsWithSeparationChar) {
152
- match = match.slice(0, -1);
153
- }
154
-
155
- const lineAndColumnPattern = /:([0-9]+):([0-9]+)$/;
156
- const lineAndColumMatch = match.match(lineAndColumnPattern);
157
- if (lineAndColumMatch) {
158
- const lineAndColumnString = lineAndColumMatch[0];
159
- const lineString = lineAndColumMatch[1];
160
- const columnString = lineAndColumMatch[2];
161
- replacement = replace({
162
- url: match.slice(0, -lineAndColumnString.length),
163
- line: lineString ? parseInt(lineString) : null,
164
- column: columnString ? parseInt(columnString) : null,
165
- });
166
- } else {
167
- const linePattern = /:([0-9]+)$/;
168
- const lineMatch = match.match(linePattern);
169
- if (lineMatch) {
170
- const lineString = lineMatch[0];
171
- replacement = replace({
172
- url: match.slice(0, -lineString.length),
173
- line: lineString ? parseInt(lineString) : null,
174
- });
175
- } else {
176
- replacement = replace({
177
- url: match,
178
- });
179
- }
180
- }
181
- if (endsWithSeparationChar) {
182
- return `${replacement}${lastChar}`;
183
- }
184
- return replacement;
185
- });
186
- };
@@ -85,7 +85,7 @@ const stringifyUrlSite = ({ url, line, column }) => {
85
85
  };
86
86
 
87
87
  const replaceUrls = (source, replace) => {
88
- return source.replace(/(?:https?|ftp|file):\/\/\S+/gm, (match) => {
88
+ return source.replace(/(?:https?|ftp|file):\/\/\S+/g, (match) => {
89
89
  let replacement = "";
90
90
  const lastChar = match[match.length - 1];
91
91
 
@@ -0,0 +1,32 @@
1
+ export const createIsInsideFragment = (fragment, total) => {
2
+ let [dividend, divisor] = fragment.split("/");
3
+ dividend = parseInt(dividend);
4
+ divisor = parseInt(divisor);
5
+ const groupSize = Math.ceil(total / divisor);
6
+ let from;
7
+ let to;
8
+ if (groupSize === 0) {
9
+ if (dividend === 1) {
10
+ from = 0;
11
+ to = 0;
12
+ } else {
13
+ from = Infinity;
14
+ to = -1;
15
+ }
16
+ } else if (dividend === 1) {
17
+ from = 0;
18
+ to = groupSize;
19
+ } else {
20
+ from = (dividend - 1) * groupSize;
21
+ if (dividend === divisor) {
22
+ to = total;
23
+ } else {
24
+ to = from + groupSize;
25
+ }
26
+ }
27
+ return (index) => {
28
+ if (index < from) return false;
29
+ if (index >= to) return false;
30
+ return true;
31
+ };
32
+ };
@@ -46,22 +46,26 @@ export const reportAsJunitXml = async (
46
46
  });
47
47
  testSuite.appendChild(testCase);
48
48
 
49
- if (executionResult.status === "aborted") {
49
+ if (executionResult.status === "skipped") {
50
+ testCase.attributes.skipped = 1;
51
+ const skipped = testCase.createNode("skipped", {
52
+ message: "Execution was skipped",
53
+ });
54
+ testSuite.appendChild(skipped);
55
+ } else if (executionResult.status === "aborted") {
50
56
  testCase.attributes.skipped = 1;
51
57
  const skipped = testCase.createNode("skipped", {
52
58
  message: "Execution was aborted",
53
59
  });
54
60
  testSuite.appendChild(skipped);
55
- }
56
- if (executionResult.status === "timedout") {
61
+ } else if (executionResult.status === "timedout") {
57
62
  testCase.attributes.failures = 1;
58
63
  const failure = testCase.createNode("failure", {
59
64
  message: `Execution timeout after ${executionResult.params.allocatedMs}ms"`,
60
65
  type: "timeout",
61
66
  });
62
67
  testSuite.appendChild(failure);
63
- }
64
- if (executionResult.status === "failed") {
68
+ } else if (executionResult.status === "failed") {
65
69
  const [error] = executionResult.errors;
66
70
  if (
67
71
  error &&
@@ -115,7 +119,7 @@ export const reportAsJunitXml = async (
115
119
  propertiesNode.appendChild(propertyNode);
116
120
  }
117
121
 
118
- const { consoleCalls } = executionResult;
122
+ const { consoleCalls = [] } = executionResult;
119
123
  let stdout = "";
120
124
  let stderr = "";
121
125
  for (const consoleCall of consoleCalls) {
@@ -0,0 +1,50 @@
1
+ // `Error: yo
2
+ // at Object.execute (http://127.0.0.1:57300/build/src/__test__/file-throw.js:9:13)
3
+ // at doExec (http://127.0.0.1:3000/src/__test__/file-throw.js:452:38)
4
+ // at postOrderExec (http://127.0.0.1:3000/src/__test__/file-throw.js:448:16)
5
+ // at http://127.0.0.1:3000/src/__test__/file-throw.js:399:18`.replace(/(?:https?|ftp|file):\/\/(.*+)$/gm, (...args) => {
6
+ // debugger
7
+ // })
8
+ export const replaceUrls = (source, replace) => {
9
+ return source.replace(/(?:https?|ftp|file):\/\/\S+/g, (match) => {
10
+ let replacement = "";
11
+ const lastChar = match[match.length - 1];
12
+
13
+ // hotfix because our url regex sucks a bit
14
+ const endsWithSeparationChar = lastChar === ")" || lastChar === ":";
15
+ if (endsWithSeparationChar) {
16
+ match = match.slice(0, -1);
17
+ }
18
+
19
+ const lineAndColumnPattern = /:([0-9]+):([0-9]+)$/;
20
+ const lineAndColumMatch = match.match(lineAndColumnPattern);
21
+ if (lineAndColumMatch) {
22
+ const lineAndColumnString = lineAndColumMatch[0];
23
+ const lineString = lineAndColumMatch[1];
24
+ const columnString = lineAndColumMatch[2];
25
+ replacement = replace({
26
+ url: match.slice(0, -lineAndColumnString.length),
27
+ line: lineString ? parseInt(lineString) : null,
28
+ column: columnString ? parseInt(columnString) : null,
29
+ });
30
+ } else {
31
+ const linePattern = /:([0-9]+)$/;
32
+ const lineMatch = match.match(linePattern);
33
+ if (lineMatch) {
34
+ const lineString = lineMatch[0];
35
+ replacement = replace({
36
+ url: match.slice(0, -lineString.length),
37
+ line: lineString ? parseInt(lineString) : null,
38
+ });
39
+ } else {
40
+ replacement = replace({
41
+ url: match,
42
+ });
43
+ }
44
+ }
45
+ if (endsWithSeparationChar) {
46
+ return `${replacement}${lastChar}`;
47
+ }
48
+ return replacement;
49
+ });
50
+ };