• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MarkUsProject / Markus / 19453055458

18 Nov 2025 03:29AM UTC coverage: 91.493% (-0.2%) from 91.661%
19453055458

Pull #7734

github

web-flow
Merge b0fb4e9f1 into a2d26257e
Pull Request #7734: Add timeout display to the test run table UI

900 of 1801 branches covered (49.97%)

Branch coverage included in aggregate %.

6 of 7 new or added lines in 2 files covered. (85.71%)

4 existing lines in 1 file now uncovered.

43378 of 46594 relevant lines covered (93.1%)

120.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

56.65
/app/javascript/Components/test_run_table.jsx
1
import React from "react";
2
import {createRoot} from "react-dom/client";
3
import ReactTable from "react-table";
4
import mime from "mime/lite";
5
import {dateSort, selectFilter} from "./Helpers/table_helpers";
6
import {FileViewer} from "./Result/file_viewer";
7
import consumer from "../channels/consumer";
8
import {renderFlashMessages} from "../common/flash";
9

10
export class TestRunTable extends React.Component {
11
  constructor(props) {
12
    super(props);
2✔
13
    this.state = {
1✔
14
      data: [],
15
      loading: true,
16
      expanded: {},
17
    };
18
    this.testRuns = React.createRef();
1✔
19
  }
20

21
  componentDidMount() {
22
    this.fetchData();
1✔
23
    this.create_test_runs_channel_subscription();
1✔
24
  }
25

26
  componentDidUpdate(prevProps) {
27
    if (
1!
28
      prevProps.result_id !== this.props.result_id ||
3✔
29
      prevProps.instructor_run !== this.props.instructor_run ||
30
      prevProps.instructor_view !== this.props.instructor_view
31
    ) {
32
      this.setState({loading: true}, this.fetchData);
×
33
    }
34
  }
35

36
  fetchData = () => {
1✔
37
    let fetchDetails = {
1✔
38
      headers: {
39
        Accept: "application/json",
40
      },
41
    };
42
    let url;
43
    if (this.props.instructor_run) {
1!
44
      if (this.props.instructor_view) {
1!
45
        url = Routes.get_test_runs_instructors_course_result_path(
1✔
46
          this.props.course_id,
47
          this.props.result_id
48
        );
49
      } else {
50
        url = Routes.get_test_runs_instructors_released_course_result_path(
×
51
          this.props.course_id,
52
          this.props.result_id
53
        );
54
      }
55
    } else {
56
      url = Routes.get_test_runs_students_course_assignment_automated_tests_path(
×
57
        this.props.course_id,
58
        this.props.assignment_id
59
      );
60
    }
61
    fetch(url, fetchDetails)
1✔
62
      .then(response => {
63
        if (response.ok) {
1!
64
          return response.json();
1✔
65
        }
66
      })
67
      .then(data => {
68
        this.setState({
1✔
69
          data: data,
70
          loading: false,
71
          expanded: data.length > 0 ? {0: true} : {},
1!
72
        });
73
      });
74
  };
75

76
  onExpandedChange = newExpanded => this.setState({expanded: newExpanded});
1✔
77

78
  create_test_runs_channel_subscription = () => {
1✔
79
    consumer.subscriptions.create(
1✔
80
      {
81
        channel: "TestRunsChannel",
82
        course_id: this.props.course_id,
83
        assignment_id: this.props.assignment_id,
84
        grouping_id: this.props.grouping_id,
85
        submission_id: this.props.submission_id,
86
      },
87
      {
88
        connected: () => {},
89
        disconnected: () => {},
90
        received: data => {
91
          // Called when there's incoming data on the websocket for this channel
92
          if (data["status"] !== null) {
×
93
            let message_data = generateMessage(data);
×
94
            renderFlashMessages(message_data);
×
95
          }
96
          if (data["status"] === "completed") {
×
97
            // Note: this gets called after AutotestRunJob completes (when a new
98
            // TestRun is created), and after an AutotestResultsJob completed
99
            // (when test results are available).
100
            this.fetchData();
×
101
          }
102
        },
103
      }
104
    );
105
  };
106

107
  render() {
108
    let height;
109
    if (this.props.instructor_view) {
2!
110
      // 3.5em is the vertical space for the action bar (and run tests button)
111
      height = "calc(599px - 3.5em)";
2✔
112
    } else {
113
      height = "599px";
×
114
    }
115

116
    return (
2✔
117
      <div>
118
        <ReactTable
119
          ref={this.testRuns}
120
          data={this.state.data}
121
          key={this.state.data.length ? this.state.data[0]["test_runs.id"] : "empty-table"}
2✔
122
          columns={[
123
            {
124
              id: "created_at",
125
              accessor: row => row["test_runs.created_at"],
1✔
126
              sortMethod: dateSort,
127
              minWidth: 300,
128
            },
129
            {
130
              id: "user_name",
131
              accessor: row => row["users.user_name"],
1✔
132
              Cell: ({value}) => I18n.t("activerecord.attributes.test_run.user") + " " + value,
1✔
133
              show: !this.props.instructor_run || this.props.instructor_view,
4✔
134
              width: 120,
135
            },
136
            {
137
              id: "status",
138
              accessor: row => {
139
                const results = row["test_results"] || [];
1!
140
                const has_timeout = results.some(
1✔
141
                  result => result["test_group_results.error_type"] === "timeout"
1✔
142
                );
143
                if (has_timeout) {
1!
144
                  return I18n.t("automated_tests.test_runs_statuses.timeout");
1✔
145
                }
NEW
146
                return I18n.t(`automated_tests.test_runs_statuses.${row["test_runs.status"]}`);
×
147
              },
148
              width: 120,
149
            },
150
          ]}
151
          SubComponent={row =>
152
            row.original["test_runs.problems"] ? (
1!
153
              <pre>{row.original["test_runs.problems"]}</pre>
154
            ) : (
155
              <TestGroupResultTable
156
                key={row.original.id_}
157
                data={row.original["test_results"]}
158
                course_id={this.props.course_id}
159
              />
160
            )
161
          }
162
          noDataText={I18n.t("automated_tests.no_results")}
163
          getTheadProps={() => {
164
            return {
2✔
165
              style: {display: "none"},
166
            };
167
          }}
168
          defaultSorted={[{id: "created_at", desc: true}]}
169
          expanded={this.state.expanded}
170
          onExpandedChange={this.onExpandedChange}
171
          loading={this.state.loading}
172
          style={{maxHeight: height}}
173
        />
174
      </div>
175
    );
176
  }
177
}
178

179
class TestGroupResultTable extends React.Component {
180
  constructor(props) {
181
    super(props);
1✔
182
    this.state = {
1✔
183
      show_output: this.showOutput(props.data),
184
      expanded: this.computeExpanded(props.data),
185
      filtered: [],
186
      filteredData: props.data,
187
    };
188
  }
189

190
  componentDidUpdate(prevProps) {
191
    if (prevProps.data !== this.props.data) {
×
192
      this.setState({filteredData: this.props.data, filtered: []});
×
193
    }
194
  }
195

196
  computeExpanded = data => {
1✔
197
    let expanded = {};
1✔
198
    let i = 0;
1✔
199
    let groups = new Set();
1✔
200
    data.forEach(row => {
1✔
201
      if (!groups.has(row["test_groups.id"])) {
1!
202
        expanded[i] = {};
1✔
203
        i++;
1✔
204
        groups.add(row["test_groups.id"]);
1✔
205
      }
206
    });
207
    return expanded;
1✔
208
  };
209

210
  onExpandedChange = newExpanded => {
1✔
211
    this.setState({expanded: newExpanded});
×
212
  };
213

214
  showOutput = data => {
1✔
215
    if (data) {
1!
216
      return data.some(row => "test_results.output" in row);
1✔
217
    } else {
218
      return false;
×
219
    }
220
  };
221

222
  columns = () => [
1✔
223
    {
224
      id: "test_group_id",
225
      Header: "",
226
      accessor: row => row["test_groups.id"],
1✔
227
      maxWidth: 30,
228
    },
229
    {
230
      id: "test_group_name",
231
      Header: "",
232
      accessor: row => row["test_groups.name"],
1✔
233
      maxWidth: 30,
234
      show: false,
235
    },
236
    {
237
      id: "name",
238
      Header: I18n.t("activerecord.attributes.test_result.name"),
239
      accessor: row => row["test_results.name"],
1✔
240
      aggregate: (values, rows) => {
241
        if (rows.length === 0) {
1!
242
          return "";
×
243
        } else {
244
          return rows[0]["test_group_name"];
1✔
245
        }
246
      },
247
      minWidth: 200,
248
    },
249
    {
250
      id: "test_status",
251
      Header: I18n.t("activerecord.attributes.test_result.status"),
252
      accessor: "test_results_status",
253
      width: 80,
254
      aggregate: _ => "",
1✔
255
      filterable: true,
256
      Filter: selectFilter,
257
      filterOptions: ["pass", "partial", "fail", "error", "error_all"].map(status => ({
5✔
258
        value: status,
259
        text: status,
260
      })),
261
      // Disable the default filter method because this is a controlled component
262
      filterMethod: () => true,
×
263
    },
264
    {
265
      id: "marks_earned",
266
      Header: I18n.t("activerecord.attributes.test_result.marks_earned"),
267
      accessor: row => row["test_results.marks_earned"],
1✔
268
      Cell: row => {
269
        const marksEarned = row.original["test_results.marks_earned"];
1✔
270
        const marksTotal = row.original["test_results.marks_total"];
1✔
271
        if (marksEarned !== null && marksTotal !== null) {
1!
272
          return `${marksEarned} / ${marksTotal}`;
1✔
273
        } else {
274
          return "";
×
275
        }
276
      },
277
      width: 80,
278
      className: "number",
279
      aggregate: (vals, rows) =>
280
        rows.reduce(
1✔
281
          (acc, row) => [
1✔
282
            acc[0] + (row._original["test_results.marks_earned"] || 0),
2✔
283
            acc[1] + (row._original["test_results.marks_total"] || 0),
2✔
284
          ],
285
          [0, 0]
286
        ),
287
      Aggregated: row => {
288
        const timeout_reached = row.value[0] === 0 && row.value[1] === 0;
1✔
289
        const ret_val = timeout_reached
1!
290
          ? I18n.t("activerecord.attributes.test_group_result.no_test_results")
291
          : `${row.value[0]} / ${row.value[1]}`;
292
        return ret_val;
1✔
293
      },
294
    },
295
  ];
296

297
  filterByStatus = filtered => {
1✔
298
    let status;
299
    for (const filter of filtered) {
×
300
      if (filter.id === "test_status") {
×
301
        status = filter.value;
×
302
      }
303
    }
304

305
    let filteredData;
306
    if (!!status && status !== "all") {
×
307
      filteredData = this.props.data.filter(row => row.test_results_status === status);
×
308
    } else {
309
      filteredData = this.props.data;
×
310
    }
311

312
    this.setState({
×
313
      filtered,
314
      filteredData,
315
      expanded: this.computeExpanded(filteredData),
316
    });
317
  };
318

319
  render() {
320
    const seen = new Set();
1✔
321
    const extraInfo = this.props.data
1✔
322
      .reduce((acc, test_data) => {
323
        const id = test_data["test_groups.id"];
1✔
324
        const name = (test_data["test_groups.name"] || "").trim();
1!
325
        const info = (test_data["test_group_results.extra_info"] || "").trim();
1✔
326

327
        if (!seen.has(id) && info) {
1!
328
          seen.add(id);
×
329
          acc.push(`[${name}]`, info);
×
330
        }
331

332
        return acc;
1✔
333
      }, [])
334
      .join("\n");
335
    let extraInfoDisplay;
336
    if (extraInfo) {
1!
337
      extraInfoDisplay = (
×
338
        <div>
339
          <h4>{I18n.t("activerecord.attributes.test_group_result.extra_info")}</h4>
340
          <pre>{extraInfo}</pre>
341
        </div>
342
      );
343
    } else {
344
      extraInfoDisplay = "";
1✔
345
    }
346
    const feedbackFiles = [];
1✔
347
    this.props.data.forEach(data => {
1✔
348
      data.feedback_files.forEach(feedbackFile => {
1✔
349
        if (!feedbackFiles.some(f => f.id === feedbackFile.id)) {
×
350
          feedbackFiles.push(feedbackFile);
×
351
        }
352
      });
353
    });
354
    let feedbackFileDisplay;
355
    if (feedbackFiles.length) {
1!
356
      feedbackFileDisplay = (
×
357
        <TestGroupFeedbackFileTable data={feedbackFiles} course_id={this.props.course_id} />
358
      );
359
    } else {
360
      feedbackFileDisplay = "";
1✔
361
    }
362

363
    return (
1✔
364
      <div>
365
        <ReactTable
366
          className={this.state.loading ? "auto-overflow" : "auto-overflow display-block"}
1!
367
          data={this.state.filteredData}
368
          columns={this.columns()}
369
          pivotBy={["test_group_id"]}
370
          getTdProps={(state, rowInfo) => {
371
            if (rowInfo) {
105✔
372
              let className = `-wrap test-result-${rowInfo.row["test_status"]}`;
10✔
373
              if (
10✔
374
                !rowInfo.aggregated &&
15!
375
                (!this.state.show_output || !rowInfo.original["test_results.output"])
376
              ) {
377
                className += " hide-rt-expander";
5✔
378
              }
379
              return {className: className};
10✔
380
            } else {
381
              return {};
95✔
382
            }
383
          }}
384
          PivotValueComponent={() => ""}
1✔
385
          expanded={this.state.expanded}
386
          filtered={this.state.filtered}
387
          onFilteredChange={this.filterByStatus}
388
          onExpandedChange={this.onExpandedChange}
389
          collapseOnDataChange={false}
390
          collapseOnSortingChange={false}
391
          SubComponent={row => (
392
            <pre className={`test-results-output test-result-${row.row["test_status"]}`}>
×
393
              {row.original["test_results.output"]}
394
            </pre>
395
          )}
396
          style={{maxHeight: "initial"}}
397
        />
398
        {extraInfoDisplay}
399
        {feedbackFileDisplay}
400
      </div>
401
    );
402
  }
403
}
404

405
class TestGroupFeedbackFileTable extends React.Component {
406
  render() {
407
    const columns = [
×
408
      {
409
        Header: I18n.t("activerecord.attributes.submission.feedback_files"),
410
        accessor: "filename",
411
      },
412
    ];
413

414
    return (
×
415
      <ReactTable
416
        className={"auto-overflow test-result-feedback-files"}
417
        data={this.props.data}
418
        columns={columns}
419
        SubComponent={row => (
420
          <FileViewer
×
421
            selectedFile={row.original.filename}
422
            selectedFileURL={Routes.course_feedback_file_path(
423
              this.props.course_id,
424
              row.original.id
425
            )}
426
            mime_type={mime.getType(row["filename"])}
427
            selectedFileType={row.original.type}
428
            rmd_convert_enabled={this.props.rmd_convert_enabled}
429
          />
430
        )}
431
      />
432
    );
433
  }
434
}
435

436
export function makeTestRunTable(elem, props) {
437
  const root = createRoot(elem);
×
438
  root.render(<TestRunTable {...props} />);
×
439
}
440

441
function generateMessage(status_data) {
442
  let message_data = {};
×
443
  switch (status_data["status"]) {
×
444
    case "failed":
445
      if (!status_data["exception"] || !status_data["exception"]["message"]) {
×
446
        message_data["error"] = I18n.t("job.status.failed.no_message");
×
447
      } else {
448
        message_data["error"] = I18n.t("job.status.failed.message", {
×
449
          error: status_data["exception"]["message"],
450
        });
451
      }
452
      break;
×
453
    case "completed":
454
      if (status_data["job_class"] === "AutotestRunJob") {
×
455
        message_data["success"] = I18n.t("automated_tests.autotest_run_job.status.completed");
×
456
      } else {
457
        message_data["success"] = I18n.t("automated_tests.autotest_results_job.status.completed");
×
458
      }
459
      break;
×
460
    case "queued":
461
      message_data["notice"] = I18n.t("job.status.queued");
×
462
      break;
×
463
    case "service_unavailable":
464
      message_data["notice"] = status_data["exception"]["message"];
×
465
      break;
×
466
    default:
467
      message_data["notice"] = I18n.t("automated_tests.autotest_run_job.status.in_progress");
×
468
  }
469
  if (status_data["warning_message"]) {
×
470
    message_data["warning"] = status_data["warning_message"];
×
471
  }
472
  return message_data;
×
473
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc