@testomatio/reporter 2.7.4-beta.allure-1 → 2.7.5

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/src/bin/cli.js CHANGED
@@ -6,7 +6,6 @@ import { glob } from 'glob';
6
6
  import createDebugMessages from 'debug';
7
7
  import TestomatClient from '../client.js';
8
8
  import XmlReader from '../xmlReader.js';
9
- import AllureReader from '../allureReader.js';
10
9
  import { APP_PREFIX, STATUS } from '../constants.js';
11
10
  import { cleanLatestRunId, getPackageVersion, applyFilter } from '../utils/utils.js';
12
11
  import { config } from '../config.js';
@@ -265,40 +264,6 @@ program
265
264
  if (timeoutTimer) clearTimeout(timeoutTimer);
266
265
  });
267
266
 
268
- program
269
- .command('allure')
270
- .description('Parse Allure result files and upload to Testomat.io')
271
- .argument('<pattern>', 'Allure result directory pattern')
272
- .option('-d, --dir <dir>', 'Project directory')
273
- .option('--timelimit <time>', 'default time limit in seconds to kill a stuck process')
274
- .option('--with-package', 'Keep full package path in file names (default: strip package prefix)')
275
- .action(async (pattern, opts) => {
276
- const runReader = new AllureReader({ withPackage: opts.withPackage });
277
-
278
- let timeoutTimer;
279
- if (opts.timelimit) {
280
- timeoutTimer = setTimeout(
281
- () => {
282
- console.log(
283
- `⚠️ Reached timeout of ${opts.timelimit}s. Exiting... (Exit code is 0 to not fail the pipeline)`,
284
- );
285
- process.exit(0);
286
- },
287
- parseInt(opts.timelimit, 10) * 1000,
288
- );
289
- }
290
-
291
- try {
292
- await runReader.parse(pattern);
293
- await runReader.createRun();
294
- await runReader.uploadData();
295
- } catch (err) {
296
- console.log(APP_PREFIX, 'Error uploading Allure results:', err);
297
- }
298
-
299
- if (timeoutTimer) clearTimeout(timeoutTimer);
300
- });
301
-
302
267
  program
303
268
  .command('upload-artifacts')
304
269
  .description('Upload artifacts to Testomat.io')
package/src/client.js CHANGED
@@ -360,10 +360,11 @@ class Client {
360
360
  *
361
361
  * Updates the status of the current test run and finishes the run.
362
362
  * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run.
363
+ * @param {Partial<import('../types/types.js').RunData>} [params] - Additional run params (e.g. duration).
363
364
  * Must be one of "passed", "failed", or "finished"
364
365
  * @returns {Promise<any>} - A Promise that resolves when finishes the run.
365
366
  */
366
- async updateRunStatus(status) {
367
+ async updateRunStatus(status, params = {}) {
367
368
  this.pipes ||= await pipesFactory(this.paramsForPipesFactory || {}, this.pipeStore);
368
369
  this.runId ||= readLatestRunId();
369
370
 
@@ -371,7 +372,7 @@ class Client {
371
372
  // all pipes disabled, skipping
372
373
  if (!this.pipes?.filter(p => p.isEnabled).length) return Promise.resolve();
373
374
 
374
- const runParams = { status };
375
+ const runParams = { ...params, status };
375
376
 
376
377
  this.queue = this.queue
377
378
  .then(() => Promise.all(this.pipes.map(p => p.finishRun(runParams))))
@@ -4,7 +4,6 @@ import JavaAdapter from './java.js';
4
4
  import PythonAdapter from './python.js';
5
5
  import RubyAdapter from './ruby.js';
6
6
  import CSharpAdapter from './csharp.js';
7
- import KotlinAdapter from './kotlin.js';
8
7
 
9
8
  function AdapterFactory(lang, opts) {
10
9
  if (lang === 'java') {
@@ -22,9 +21,6 @@ function AdapterFactory(lang, opts) {
22
21
  if (lang === 'c#' || lang === 'csharp') {
23
22
  return new CSharpAdapter(opts);
24
23
  }
25
- if (lang === 'kotlin') {
26
- return new KotlinAdapter(opts);
27
- }
28
24
 
29
25
  return new Adapter(opts);
30
26
  }
@@ -78,6 +78,20 @@ export class BitbucketPipe {
78
78
 
79
79
  async finishRun(runParams) {
80
80
  if (!this.isEnabled) return;
81
+ if (!this.ENV.BITBUCKET_PR_ID) {
82
+ log.warn(
83
+ pc.yellow('Bitbucket'),
84
+ 'Skipping PR comment: BITBUCKET_PR_ID is not set. Run this pipe in a Bitbucket pull-requests pipeline.',
85
+ );
86
+ return;
87
+ }
88
+ if (!this.ENV.BITBUCKET_WORKSPACE || !this.ENV.BITBUCKET_REPO_SLUG) {
89
+ log.warn(
90
+ pc.yellow('Bitbucket'),
91
+ 'Skipping PR comment: BITBUCKET_WORKSPACE or BITBUCKET_REPO_SLUG is missing.',
92
+ );
93
+ return;
94
+ }
81
95
 
82
96
  if (runParams.tests) runParams.tests.forEach(t => this.addTest(t));
83
97
 
@@ -196,12 +210,20 @@ export class BitbucketPipe {
196
210
 
197
211
  log.info(pc.yellow('Bitbucket'), `Report created: ${pc.magenta(commentURL)}`);
198
212
  } catch (err) {
213
+ const isForbiddenError = `${err}`.includes('Forbidden') || `${err}`.includes('403');
214
+ const scopeHint =
215
+ isForbiddenError
216
+ ? '\nHint: use a token that can write PR comments '
217
+ + '(recommended: Repository Access Token with Pull requests: Write '
218
+ + 'and Repository: Read) and run inside a pull-requests pipeline '
219
+ + 'where BITBUCKET_PR_ID is available.'
220
+ : '';
199
221
  console.error(
200
222
  APP_PREFIX,
201
223
  pc.yellow('Bitbucket'),
202
224
  `Couldn't create Bitbucket report\n${err}.
203
225
  Request URL: ${commentsRequestURL}
204
- Request data: ${body}`,
226
+ Request data: ${body}${scopeHint}`,
205
227
  );
206
228
  }
207
229
  }
package/src/pipe/html.js CHANGED
@@ -230,8 +230,10 @@ class HtmlPipe {
230
230
  ];
231
231
 
232
232
  test.artifactsUploaded = allPossibleArtifacts.some(artifact => {
233
- const link = artifact?.link || artifact?.path;
234
- return link && (link.startsWith('http://') || link.startsWith('https://')) && !link.startsWith('file://');
233
+ let link = artifact?.link || artifact?.path;
234
+ if (!link) return false;
235
+ if (typeof link !== 'string') link = String(link);
236
+ return link.startsWith('http://') || link.startsWith('https://');
235
237
  });
236
238
 
237
239
  normalizeRetries(test);
@@ -1083,6 +1085,18 @@ function normalizeArtifacts(test) {
1083
1085
  return allArtifacts
1084
1086
  .map(artifact => {
1085
1087
  if (typeof artifact === 'string') {
1088
+ if (/^https?:\/\//i.test(artifact)) {
1089
+ const base = path.basename(new URL(artifact).pathname) || artifact;
1090
+
1091
+ return {
1092
+ name: base,
1093
+ title: base,
1094
+ path: artifact,
1095
+ fsPath: null,
1096
+ relativePath: artifact,
1097
+ };
1098
+ }
1099
+
1086
1100
  const abs = path.isAbsolute(artifact) ? artifact : path.resolve(process.cwd(), artifact);
1087
1101
  const href = artifact.startsWith('file://') ? artifact : fileUrl(abs, { resolve: true });
1088
1102
  const base = path.basename(abs);
@@ -1099,9 +1113,14 @@ function normalizeArtifacts(test) {
1099
1113
  if (artifact?.path) {
1100
1114
  const raw = String(artifact.path);
1101
1115
  const isFileUrl = raw.startsWith('file://');
1102
- const abs = isFileUrl ? null : path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
1103
- const href = isFileUrl ? raw : fileUrl(abs, { resolve: true });
1104
- const base = abs ? path.basename(abs) : artifact.name || artifact.title || 'attachment';
1116
+ const isHttpUrl = /^https?:\/\//i.test(raw);
1117
+ const abs = isFileUrl || isHttpUrl ? null : path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
1118
+ const href = isFileUrl || isHttpUrl ? raw : fileUrl(abs, { resolve: true });
1119
+ const base = abs
1120
+ ? path.basename(abs)
1121
+ : isHttpUrl
1122
+ ? path.basename(new URL(raw).pathname) || artifact.name || artifact.title || 'attachment'
1123
+ : artifact.name || artifact.title || 'attachment';
1105
1124
 
1106
1125
  return {
1107
1126
  ...artifact,
@@ -741,6 +741,18 @@
741
741
  min-width: 0;
742
742
  }
743
743
 
744
+ .step-header {
745
+ display: flex;
746
+ align-items: center;
747
+ gap: 12px;
748
+ width: 100%;
749
+ }
750
+
751
+ .step-main {
752
+ flex: 1;
753
+ min-width: 0;
754
+ }
755
+
744
756
  .step-text {
745
757
  font-size: 14px;
746
758
  color: var(--gray-700);
@@ -755,6 +767,35 @@
755
767
  margin-top: 4px;
756
768
  }
757
769
 
770
+ .step-toggle {
771
+ width: 24px;
772
+ height: 24px;
773
+ border: none;
774
+ border-radius: 6px;
775
+ background: var(--gray-100);
776
+ color: var(--gray-600);
777
+ display: inline-flex;
778
+ align-items: center;
779
+ justify-content: center;
780
+ cursor: pointer;
781
+ flex-shrink: 0;
782
+ transition: var(--transition);
783
+ margin-top: 2px;
784
+ }
785
+
786
+ .step-toggle:hover {
787
+ background: var(--gray-200);
788
+ color: var(--gray-700);
789
+ }
790
+
791
+ .step-toggle.collapsed i {
792
+ transform: rotate(-90deg);
793
+ }
794
+
795
+ .step-toggle i {
796
+ transition: transform 0.2s ease;
797
+ }
798
+
758
799
  .step-status {
759
800
  width: 8px;
760
801
  height: 8px;
@@ -790,6 +831,10 @@
790
831
  padding-left: 0;
791
832
  }
792
833
 
834
+ .step-children.collapsed {
835
+ display: none;
836
+ }
837
+
793
838
  .step-children::before {
794
839
  content: '';
795
840
  position: absolute;
@@ -867,6 +912,30 @@
867
912
  border-left-color: var(--info-color);
868
913
  }
869
914
 
915
+ .message-block.passed {
916
+ border-left-color: var(--success-color);
917
+ }
918
+
919
+ .message-block.skipped,
920
+ .message-block.todo {
921
+ border-left-color: var(--warning-color);
922
+ }
923
+
924
+ .message-block.failed {
925
+ border-left-color: var(--danger-color);
926
+ }
927
+
928
+ .step-attachments {
929
+ display: grid;
930
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
931
+ gap: 12px;
932
+ margin-top: 12px;
933
+ }
934
+
935
+ .step-attachments .attachment-item {
936
+ min-height: 120px;
937
+ }
938
+
870
939
  .metadata-grid {
871
940
  display: grid;
872
941
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
@@ -2377,6 +2446,11 @@
2377
2446
  const isTodo = statusClass === 'todo';
2378
2447
  const isSkippedOrTodo = statusClass === 'skipped' || statusClass === 'todo';
2379
2448
  const displayTime = isSkippedOrTodo ? '-' : formatDuration(test.run_time || 0);
2449
+ const hasMessage = hasMeaningfulMessage(test.message);
2450
+ const initialTab = getInitialTestTab({ isTodo, hasMessage, hasSteps: test.stepsArray?.length || test.steps });
2451
+ const initialMessageClass = initialTab === 'message' ? ' active' : '';
2452
+ const initialStepsClass = initialTab === 'steps' ? ' active' : '';
2453
+ const initialInfoClass = initialTab === 'info' ? ' active' : '';
2380
2454
 
2381
2455
  testItem.innerHTML = `
2382
2456
  <div class='test-header' onclick='toggleTest(this)'>
@@ -2411,14 +2485,14 @@
2411
2485
  <div class='test-content'>
2412
2486
  <div class='test-tabs'>
2413
2487
  ${isTodo ? `
2414
- <button class='test-tab active' onclick='showTestTab(this, \"info\")'>
2488
+ <button class='test-tab${initialInfoClass}' onclick='showTestTab(this, \"info\")'>
2415
2489
  <i class='fas fa-info-circle'></i> Info
2416
2490
  </button>
2417
2491
  ` : `
2418
- <button class='test-tab active' onclick='showTestTab(this, \"steps\")'>
2492
+ <button class='test-tab${initialStepsClass}' onclick='showTestTab(this, \"steps\")'>
2419
2493
  <i class='fas fa-list-ol'></i> Steps
2420
2494
  </button>
2421
- <button class='test-tab' onclick='showTestTab(this, \"message\")'>
2495
+ <button class='test-tab${initialMessageClass}' onclick='showTestTab(this, \"message\")'>
2422
2496
  <i class='fas fa-comment-dots'></i> Message
2423
2497
  </button>
2424
2498
  ${hasStack ? `<button class='test-tab' onclick='showTestTab(this, \"stack\")'>
@@ -2439,7 +2513,7 @@
2439
2513
  `}
2440
2514
  </div>
2441
2515
  ${isTodo ? `
2442
- <div class='test-tab-content active' data-tab='info'>
2516
+ <div class='test-tab-content${initialInfoClass}' data-tab='info'>
2443
2517
  <div class='todo-info'>
2444
2518
  <div class='todo-title'>
2445
2519
  <i class='fas fa-list-ul'></i>
@@ -2456,7 +2530,7 @@
2456
2530
  </div>
2457
2531
  </div>
2458
2532
  ` : `
2459
- <div class='test-tab-content active' data-tab='steps'>
2533
+ <div class='test-tab-content${initialStepsClass}' data-tab='steps'>
2460
2534
  <div class='steps-container' id='steps-${index}'>
2461
2535
  ${(() => {
2462
2536
  const hasStepsArray = test.stepsArray && Array.isArray(test.stepsArray) && test.stepsArray.length > 0;
@@ -2476,7 +2550,7 @@
2476
2550
  })()}
2477
2551
  </div>
2478
2552
  </div>
2479
- <div class='test-tab-content' data-tab='message'>
2553
+ <div class='test-tab-content${initialMessageClass}' data-tab='message'>
2480
2554
  <div class='message-block ${statusClass}'>
2481
2555
  <strong>${statusClass.toUpperCase()}:</strong><br>
2482
2556
  ${test.message || 'No message available'}
@@ -2542,6 +2616,16 @@
2542
2616
  }
2543
2617
  }
2544
2618
 
2619
+ function hasMeaningfulMessage(message) {
2620
+ return typeof message === 'string' && message.trim().length > 0 && message !== 'No message available';
2621
+ }
2622
+
2623
+ function getInitialTestTab({ isTodo, hasMessage }) {
2624
+ if (isTodo) return 'info';
2625
+ if (hasMessage) return 'message';
2626
+ return 'steps';
2627
+ }
2628
+
2545
2629
  function parseStepsToHtml(stepsText) {
2546
2630
  if (!stepsText || stepsText === 'No steps recorded') {
2547
2631
  return `<div class="step-item">
@@ -2629,25 +2713,33 @@
2629
2713
  const status = step.status || 'passed';
2630
2714
  const duration = step.duration || 0;
2631
2715
  const category = step.category || '';
2716
+ const hasArtifacts = Array.isArray(step.artifacts) && step.artifacts.length > 0;
2632
2717
 
2633
2718
  html += `
2634
2719
  <div class="step-item step-level-${depth}" data-step-id="${stepId}">
2635
- <div class="step-number">${stepNumber}</div>
2636
2720
  <div class="step-content">
2637
- <p class="step-text">${step.title || 'Untitled step'}</p>
2638
- ${category && category !== 'user' ? `<div class="step-category">${category}</div>` : ''}
2639
- <div class="step-time">
2640
- Step ${stepNumber}
2641
- ${duration > 0 ? `<span class="step-duration"><i class="fas fa-clock"></i> ${formatDuration(duration)}</span>` : ''}
2721
+ <div class="step-header">
2722
+ <div class="step-number">${stepNumber}</div>
2723
+ <div class="step-main">
2724
+ <p class="step-text">${step.title || 'Untitled step'}</p>
2725
+ ${category && category !== 'user' ? `<div class="step-category">${category}</div>` : ''}
2726
+ ${duration > 0 ? `<div class="step-time"><span class="step-duration"><i class="fas fa-clock"></i> ${formatDuration(duration)}</span></div>` : ''}
2727
+ ${hasArtifacts ? `<div class="step-attachments">${createAttachmentItems(step.artifacts)}</div>` : ''}
2728
+ </div>
2729
+ ${hasChildren ? `
2730
+ <button class="step-toggle" type="button" aria-expanded="true" onclick="toggleStepChildren(this)">
2731
+ <i class="fas fa-chevron-down"></i>
2732
+ </button>
2733
+ ` : ''}
2734
+ <div class="step-status ${status}"></div>
2642
2735
  </div>
2643
2736
  </div>
2644
- <div class="step-status ${status}"></div>
2645
2737
  </div>
2646
2738
  `;
2647
2739
 
2648
2740
  if (hasChildren) {
2649
2741
  html += `
2650
- <div class="step-children">
2742
+ <div class="step-children" data-parent-step-id="${stepId}">
2651
2743
  ${renderStepsTree(step.steps, depth + 1)}
2652
2744
  </div>
2653
2745
  `;
@@ -2673,19 +2765,38 @@
2673
2765
  function createStepHtml(text, number, status = null, category = null) {
2674
2766
  const statusClass = status || 'passed';
2675
2767
  const categoryText = category && category !== 'user' ? `<div class="step-category">${category}</div>` : '';
2676
- const durationIcon = status ? `<div class="step-duration"><i class="fas fa-clock"></i> auto</div>` : '';
2768
+ const durationIcon = status ? `<div class="step-time"><span class="step-duration"><i class="fas fa-clock"></i> auto</span></div>` : '';
2677
2769
 
2678
2770
  return `<div class="step-item">
2679
- <div class="step-number">${number}</div>
2680
2771
  <div class="step-content">
2681
- <p class="step-text">${text}</p>
2682
- ${categoryText}
2683
- <div class="step-time">Step ${number}${durationIcon}</div>
2772
+ <div class="step-header">
2773
+ <div class="step-number">${number}</div>
2774
+ <div class="step-main">
2775
+ <p class="step-text">${text}</p>
2776
+ ${categoryText}
2777
+ ${durationIcon}
2778
+ </div>
2779
+ <div class="step-status ${statusClass}"></div>
2780
+ </div>
2684
2781
  </div>
2685
- <div class="step-status ${statusClass}"></div>
2686
2782
  </div>`;
2687
2783
  }
2688
2784
 
2785
+ function toggleStepChildren(button) {
2786
+ const stepItem = button.closest('.step-item');
2787
+ if (!stepItem) return;
2788
+
2789
+ const stepId = stepItem.getAttribute('data-step-id');
2790
+ if (!stepId) return;
2791
+
2792
+ const children = stepItem.parentElement.querySelector(`.step-children[data-parent-step-id="${stepId}"]`);
2793
+ if (!children) return;
2794
+
2795
+ const collapsed = children.classList.toggle('collapsed');
2796
+ button.classList.toggle('collapsed', collapsed);
2797
+ button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
2798
+ }
2799
+
2689
2800
  function createRetryAttempts(retries) {
2690
2801
  const attempts = Array.isArray(retries?.attempts) ? retries.attempts : [];
2691
2802
  if (!attempts.length) return '';
@@ -289,11 +289,6 @@ const fetchSourceCode = (contents, opts = {}) => {
289
289
  if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`@DisplayName("${title}`));
290
290
  if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
291
291
  if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
292
- } else if (opts.lang === 'kotlin') {
293
- lineIndex = lines.findIndex(l => l.includes(`fun test${title}`));
294
- if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`@DisplayName("${title}`));
295
- if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`fun ${title}`));
296
- if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
297
292
  } else if (opts.lang === 'csharp') {
298
293
  // Find the method declaration line
299
294
  let methodLineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
package/src/xmlReader.js CHANGED
@@ -769,17 +769,20 @@ function reduceTestCases(prev, item) {
769
769
 
770
770
  function processTestSuite(testsuite) {
771
771
  if (!testsuite) return [];
772
- if (testsuite.testsuite) return processTestSuite(testsuite.testsuite);
772
+ if (testsuite.testsuite && !testsuite.testcase) return processTestSuite(testsuite.testsuite);
773
773
  if (testsuite['test-suite'] && !testsuite['test-case']) return processTestSuite(testsuite['test-suite']);
774
774
 
775
775
  let suites = testsuite;
776
- if (!Array.isArray(testsuite)) {
777
- suites = [testsuite];
778
- }
776
+ if (!Array.isArray(testsuite)) suites = [testsuite];
779
777
 
780
- const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
778
+ const subSuites = suites.filter(
779
+ s => (s['test-suite'] || s.testsuite) && !(s['test-case'] || s.testcase),
780
+ );
781
781
 
782
- return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
782
+ return [
783
+ ...suites.reduce(reduceTestCases, []),
784
+ ...subSuites.map(s => processTestSuite(s['test-suite'] || s.testsuite)),
785
+ ].flat();
783
786
  }
784
787
 
785
788
  function fetchProperties(item) {
@@ -1,65 +0,0 @@
1
- export default AllureReader;
2
- declare class AllureReader {
3
- constructor(opts?: {});
4
- requestParams: {
5
- apiKey: any;
6
- url: any;
7
- title: string;
8
- env: string;
9
- group_title: string;
10
- isBatchEnabled: boolean;
11
- };
12
- runId: any;
13
- opts: {};
14
- withPackage: any;
15
- store: {};
16
- pipesPromise: Promise<any[]>;
17
- _tests: any[];
18
- stats: {};
19
- suites: {};
20
- uploader: S3Uploader;
21
- version: any;
22
- set tests(value: any[]);
23
- get tests(): any[];
24
- createRun(): Promise<any[]>;
25
- pipes: any;
26
- parse(resultsPattern: any): {};
27
- parseContainerFiles(containerFiles: any): void;
28
- processAllureResult(result: any, resultsDir: any): {
29
- rid: any;
30
- title: any;
31
- status: any;
32
- suite_title: any;
33
- file: string;
34
- run_time: number;
35
- steps: any;
36
- message: any;
37
- stack: any;
38
- meta: {};
39
- links: {
40
- label: string;
41
- }[];
42
- artifacts: any[];
43
- create: boolean;
44
- overwrite: boolean;
45
- };
46
- mapStatus(status: any): any;
47
- extractSuiteTitle(result: any): any;
48
- stripNamespace(suiteName: any): any;
49
- extractFile(result: any): string;
50
- getFileExtension(result: any): any;
51
- extractMeta(result: any): {};
52
- extractLinks(result: any): {
53
- label: string;
54
- }[];
55
- convertSteps(steps: any, depth?: number): any;
56
- calculateRunTime(item: any): number;
57
- convertParameters(parameters: any): {};
58
- combineRetryAttempts(attempts: any): any;
59
- calculateStats(): {};
60
- fetchSourceCode(): void;
61
- getLanguage(): any;
62
- uploadArtifacts(): Promise<void>;
63
- uploadData(): Promise<any[]>;
64
- }
65
- import { S3Uploader } from './uploader.js';