From 07cadbb6b6f130320ad2c8f34c0022d1eaaf0d92 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Fri, 26 Jul 2024 14:03:43 -0500 Subject: [PATCH] Add status update endpoint to receive updates from Testflinger (#184) * Add status update endpoint to receive updates from Testflinger * Add frontend changes to display event log * Add resource_url to the test_execution table * Added horizontal scrolling to event log * Prevent status updates from overwriting test results * Add last event to execution name and fix padding issue * Changed ended to ended_prematurely --- ...e5bc72_create_test_status_update_tables.py | 62 +++++ .../controllers/test_executions/__init__.py | 3 +- .../controllers/test_executions/logic.py | 11 + .../controllers/test_executions/models.py | 11 + .../test_executions/status_update.py | 84 +++++++ .../testflinger_event_parser.py | 30 +++ backend/test_observer/data_access/models.py | 22 ++ .../test_observer/data_access/models_enums.py | 1 + .../test_executions/test_status_update.py | 213 ++++++++++++++++++ frontend/lib/models/test_event.dart | 16 ++ frontend/lib/models/test_execution.dart | 9 +- frontend/lib/providers/test_events.dart | 15 ++ frontend/lib/repositories/api_repository.dart | 11 + .../test_event_log_expandable.dart | 72 ++++++ .../test_execution_expandable.dart | 62 ++++- frontend/pubspec.lock | 32 +-- 16 files changed, 627 insertions(+), 27 deletions(-) create mode 100644 backend/migrations/versions/2024_06_11_2002-2745d4e5bc72_create_test_status_update_tables.py create mode 100644 backend/test_observer/controllers/test_executions/status_update.py create mode 100644 backend/test_observer/controllers/test_executions/testflinger_event_parser.py create mode 100644 backend/tests/controllers/test_executions/test_status_update.py create mode 100644 frontend/lib/models/test_event.dart create mode 100644 frontend/lib/providers/test_events.dart create mode 100644 frontend/lib/ui/artefact_page/test_event_log_expandable.dart diff --git a/backend/migrations/versions/2024_06_11_2002-2745d4e5bc72_create_test_status_update_tables.py b/backend/migrations/versions/2024_06_11_2002-2745d4e5bc72_create_test_status_update_tables.py new file mode 100644 index 00000000..5c112e11 --- /dev/null +++ b/backend/migrations/versions/2024_06_11_2002-2745d4e5bc72_create_test_status_update_tables.py @@ -0,0 +1,62 @@ +"""Create test status update tables + +Revision ID: 2745d4e5bc72 +Revises: 33c0383ea9ca +Create Date: 2024-06-11 20:02:00.064753+00:00 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "2745d4e5bc72" +down_revision = "33c0383ea9ca" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "test_event", + sa.Column("event_name", sa.String(), nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.Column("detail", sa.String(), nullable=False), + sa.Column("test_execution_id", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["test_execution_id"], + ["test_execution.id"], + name=op.f("test_event_test_execution_id_fkey"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("test_event_pkey")), + ) + # ### end Alembic commands ### + op.execute( + "ALTER TABLE test_execution ADD COLUMN " + "resource_url VARCHAR NOT NULL DEFAULT ''" + ) + + with op.get_context().autocommit_block(): + op.execute("ALTER TYPE testexecutionstatus ADD VALUE 'ENDED_PREMATURELY'") + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("test_event") + # ### end Alembic commands ### + op.execute("ALTER TYPE testexecutionstatus RENAME TO testexecutionstatus_old") + op.execute( + "CREATE TYPE testexecutionstatus AS " + "ENUM('NOT_STARTED', 'IN_PROGRESS', 'PASSED', 'FAILED', 'NOT_TESTED')" + ) + op.execute( + "ALTER TABLE test_execution ALTER COLUMN status TYPE testexecutionstatus USING " + "status::text::testexecutionstatus" + ) + op.execute("DROP TYPE testexecutionstatus_old") + op.drop_column("test_execution", "resource_url") diff --git a/backend/test_observer/controllers/test_executions/__init__.py b/backend/test_observer/controllers/test_executions/__init__.py index 33027593..778aa106 100644 --- a/backend/test_observer/controllers/test_executions/__init__.py +++ b/backend/test_observer/controllers/test_executions/__init__.py @@ -16,7 +16,7 @@ from fastapi import APIRouter -from . import end_test, get_test_results, patch, reruns, start_test +from . import end_test, get_test_results, patch, reruns, start_test, status_update router = APIRouter(tags=["test-executions"]) router.include_router(start_test.router) @@ -24,3 +24,4 @@ router.include_router(end_test.router) router.include_router(patch.router) router.include_router(reruns.router) +router.include_router(status_update.router) diff --git a/backend/test_observer/controllers/test_executions/logic.py b/backend/test_observer/controllers/test_executions/logic.py index 83dcc817..8495d1d9 100644 --- a/backend/test_observer/controllers/test_executions/logic.py +++ b/backend/test_observer/controllers/test_executions/logic.py @@ -24,6 +24,7 @@ ArtefactBuild, TestExecution, TestResult, + TestEvent, ) from test_observer.data_access.models_enums import TestExecutionStatus @@ -55,6 +56,16 @@ def delete_previous_results( db.commit() +def delete_previous_test_events( + db: Session, + test_execution: TestExecution, +): + db.execute( + delete(TestEvent).where(TestEvent.test_execution_id == test_execution.id) + ) + db.commit() + + def get_previous_artefact_builds_query( session: Session, artefact: Artefact, diff --git a/backend/test_observer/controllers/test_executions/models.py b/backend/test_observer/controllers/test_executions/models.py index 36df1821..ce649d1f 100644 --- a/backend/test_observer/controllers/test_executions/models.py +++ b/backend/test_observer/controllers/test_executions/models.py @@ -21,6 +21,7 @@ from enum import Enum from typing import Annotated +from datetime import datetime from pydantic import ( AliasPath, BaseModel, @@ -172,3 +173,13 @@ class PendingRerun(BaseModel): class DeleteReruns(BaseModel): test_execution_ids: set[int] + + +class TestEvent(BaseModel): + event_name: str + timestamp: datetime + detail: str + + +class StatusUpdateRequest(BaseModel): + events: list[TestEvent] diff --git a/backend/test_observer/controllers/test_executions/status_update.py b/backend/test_observer/controllers/test_executions/status_update.py new file mode 100644 index 00000000..25324594 --- /dev/null +++ b/backend/test_observer/controllers/test_executions/status_update.py @@ -0,0 +1,84 @@ +# Copyright 2024 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, joinedload + +from test_observer.data_access.models import ( + TestEvent, + TestExecution, +) +from test_observer.data_access.models_enums import TestExecutionStatus +from test_observer.data_access.setup import get_db + +from .logic import delete_previous_test_events +from .models import StatusUpdateRequest +from .testflinger_event_parser import TestflingerEventParser + +router = APIRouter() + + +@router.put("/{id}/status_update") +def put_status_update( + id: int, request: StatusUpdateRequest, db: Session = Depends(get_db) +): + test_execution = db.get( + TestExecution, + id, + options=[joinedload(TestExecution.test_events)], + ) + if test_execution is None: + raise HTTPException(status_code=404, detail="TestExecution not found") + + delete_previous_test_events(db, test_execution) + + for event in request.events: + test_event = TestEvent( + event_name=event.event_name, + timestamp=event.timestamp, + detail=event.detail, + ) + db.add(test_event) + test_execution.test_events.append(test_event) + event_parser = TestflingerEventParser() + event_parser.process_events(test_execution.test_events) + if event_parser.resource_url is not None: + test_execution.resource_url = event_parser.resource_url + if ( + event_parser.is_ended_prematurely + and test_execution.status is not TestExecutionStatus.FAILED + and test_execution.status is not TestExecutionStatus.PASSED + ): + test_execution.status = TestExecutionStatus.ENDED_PREMATURELY + db.commit() + + +@router.get("/{id}/status_update") +def get_status_update(id: int, db: Session = Depends(get_db)): + test_execution = db.get( + TestExecution, + id, + options=[joinedload(TestExecution.test_events)], + ) + + if test_execution is None: + raise HTTPException(status_code=404, detail="TestExecution not found") + + test_events = [] + for test_event in test_execution.test_events: + test_events.append(test_event) + + return test_events diff --git a/backend/test_observer/controllers/test_executions/testflinger_event_parser.py b/backend/test_observer/controllers/test_executions/testflinger_event_parser.py new file mode 100644 index 00000000..9f34a530 --- /dev/null +++ b/backend/test_observer/controllers/test_executions/testflinger_event_parser.py @@ -0,0 +1,30 @@ +# Copyright 2024 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +from test_observer.data_access.models import TestEvent + + +class TestflingerEventParser: + def __init__(self): + self.is_ended_prematurely = False + self.resource_url = None + + def process_events(self, events: list[TestEvent]): + final_event = events[-1] + if final_event.event_name == "job_end" and final_event.detail != "normal_exit": + self.is_ended_prematurely = True + if events[0].event_name == "job_start": + self.resource_url = events[0].detail diff --git a/backend/test_observer/data_access/models.py b/backend/test_observer/data_access/models.py index 62b150e3..a702ede2 100644 --- a/backend/test_observer/data_access/models.py +++ b/backend/test_observer/data_access/models.py @@ -336,6 +336,12 @@ class TestExecution(Base): test_results: Mapped[list["TestResult"]] = relationship( back_populates="test_execution", cascade="all, delete" ) + test_events: Mapped[list["TestEvent"]] = relationship( + back_populates="test_execution", + cascade="all, delete", + order_by="TestEvent.timestamp", + ) + resource_url: Mapped[str] = mapped_column(default="") rerun_request: Mapped[TestExecutionRerunRequest | None] = relationship( back_populates="test_execution", cascade="all, delete" ) @@ -425,3 +431,19 @@ def __repr__(self) -> str: "test_execution_id", "test_case_id", ) + + +class TestEvent(Base): + """ + A table to represent test events that have ocurred during a job + """ + + __tablename__ = "test_event" + + event_name: Mapped[str] + timestamp: Mapped[datetime] + detail: Mapped[str] + test_execution_id: Mapped[int] = mapped_column( + ForeignKey("test_execution.id", ondelete="CASCADE") + ) + test_execution: Mapped["TestExecution"] = relationship(back_populates="test_events") diff --git a/backend/test_observer/data_access/models_enums.py b/backend/test_observer/data_access/models_enums.py index d9fc3fb7..75458c8c 100644 --- a/backend/test_observer/data_access/models_enums.py +++ b/backend/test_observer/data_access/models_enums.py @@ -35,6 +35,7 @@ class TestExecutionStatus(str, Enum): PASSED = "PASSED" FAILED = "FAILED" NOT_TESTED = "NOT_TESTED" + ENDED_PREMATURELY = "ENDED_PREMATURELY" class TestExecutionReviewDecision(str, Enum): diff --git a/backend/tests/controllers/test_executions/test_status_update.py b/backend/tests/controllers/test_executions/test_status_update.py new file mode 100644 index 00000000..fb878f01 --- /dev/null +++ b/backend/tests/controllers/test_executions/test_status_update.py @@ -0,0 +1,213 @@ +# Copyright 2024 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import datetime + +from fastapi.testclient import TestClient + +from tests.data_generator import DataGenerator + + +def test_status_updates_stored(test_client: TestClient, generator: DataGenerator): + artefact = generator.gen_artefact("beta") + artefact_build = generator.gen_artefact_build(artefact) + environment = generator.gen_environment() + test_execution = generator.gen_test_execution( + artefact_build, environment, ci_link="http://localhost" + ) + + response = test_client.put( + f"/v1/test-executions/{test_execution.id}/status_update", + json={ + "agent_id": "test_agent", + "job_queue": "test_job_queue", + "events": [ + { + "event_name": "started_setup", + "timestamp": "2015-03-21T11:08:14.859831", + "detail": "my_detail_one", + }, + { + "event_name": "ended_setup", + "timestamp": "2015-03-21T11:08:15.859831", + "detail": "my_detail_two", + }, + { + "event_name": "job_end", + "timestamp": "2015-03-21T11:08:15.859831", + "detail": "my_detail_three", + }, + ], + }, + ) + assert response.status_code == 200 + assert test_execution.test_events[0].event_name == "started_setup" + assert test_execution.test_events[0].timestamp == datetime.datetime.fromisoformat( + "2015-03-21T11:08:14.859831" + ) + assert test_execution.test_events[0].detail == "my_detail_one" + assert test_execution.test_events[1].event_name == "ended_setup" + assert test_execution.test_events[1].timestamp == datetime.datetime.fromisoformat( + "2015-03-21T11:08:15.859831" + ) + assert test_execution.test_events[1].detail == "my_detail_two" + assert test_execution.status == "ENDED_PREMATURELY" + + +def test_status_updates_is_idempotent( + test_client: TestClient, generator: DataGenerator +): + artefact = generator.gen_artefact("beta") + artefact_build = generator.gen_artefact_build(artefact) + environment = generator.gen_environment() + test_execution = generator.gen_test_execution( + artefact_build, environment, ci_link="http://localhost" + ) + + for _ in range(3): + test_client.put( + f"/v1/test-executions/{test_execution.id}/status_update", + json={ + "agent_id": "test_agent", + "job_queue": "test_job_queue", + "events": [ + { + "event_name": "started_setup", + "timestamp": "2015-03-21T11:08:14.859831", + "detail": "my_detail_one", + }, + { + "event_name": "ended_setup", + "timestamp": "2015-03-21T11:08:15.859831", + "detail": "my_detail_two", + }, + ], + }, + ) + assert len(test_execution.test_events) == 2 + + +def test_get_status_update(test_client: TestClient, generator: DataGenerator): + artefact = generator.gen_artefact("beta") + artefact_build = generator.gen_artefact_build(artefact) + environment = generator.gen_environment() + test_execution = generator.gen_test_execution( + artefact_build, environment, ci_link="http://localhost" + ) + + test_client.put( + f"/v1/test-executions/{test_execution.id}/status_update", + json={ + "agent_id": "test_agent", + "job_queue": "test_job_queue", + "events": [ + { + "event_name": "started_setup", + "timestamp": "2015-03-21T11:08:14.859831", + "detail": "my_detail_one", + }, + { + "event_name": "ended_setup", + "timestamp": "2015-03-21T11:08:15.859831", + "detail": "my_detail_two", + }, + { + "event_name": "job_end", + "timestamp": "2015-03-21T11:08:15.859831", + "detail": "my_detail_three", + }, + ], + }, + ) + get_response = test_client.get( + f"/v1/test-executions/{test_execution.id}/status_update" + ) + + assert get_response.status_code == 200 + json = get_response.json() + assert json[0]["event_name"] == "started_setup" + assert json[0]["timestamp"] == "2015-03-21T11:08:14.859831" + assert json[0]["detail"] == "my_detail_one" + assert json[1]["event_name"] == "ended_setup" + assert json[1]["timestamp"] == "2015-03-21T11:08:15.859831" + assert json[1]["detail"] == "my_detail_two" + + +def test_status_updates_invalid_timestamp( + test_client: TestClient, generator: DataGenerator +): + artefact = generator.gen_artefact("beta") + artefact_build = generator.gen_artefact_build(artefact) + environment = generator.gen_environment() + test_execution = generator.gen_test_execution( + artefact_build, environment, ci_link="http://localhost" + ) + + response = test_client.put( + f"/v1/test-executions/{test_execution.id}/status_update", + json={ + "agent_id": "test_agent", + "job_queue": "test_job_queue", + "events": [ + { + "event_name": "started_setup", + "timestamp": "201-03-21T11:08:14.859831", + "detail": "my_detail_one", + }, + { + "event_name": "ended_setup", + "timestamp": "20-03-21T11:08:15.859831", + "detail": "my_detail_two", + }, + ], + }, + ) + assert response.status_code == 422 + + +def test_status_update_normal_exit(test_client: TestClient, generator: DataGenerator): + artefact = generator.gen_artefact("beta") + artefact_build = generator.gen_artefact_build(artefact) + environment = generator.gen_environment() + test_execution = generator.gen_test_execution( + artefact_build, environment, ci_link="http://localhost" + ) + + test_client.put( + f"/v1/test-executions/{test_execution.id}/status_update", + json={ + "agent_id": "test_agent", + "job_queue": "test_job_queue", + "events": [ + { + "event_name": "started_setup", + "timestamp": "201-03-21T11:08:14.859831", + "detail": "my_detail_one", + }, + { + "event_name": "ended_setup", + "timestamp": "20-03-21T11:08:15.859831", + "detail": "my_detail_two", + }, + { + "event_name": "job_end", + "timestamp": "2015-03-21T11:08:15.859831", + "detail": "normal_exit", + }, + ], + }, + ) + assert test_execution.status != "ENDED_PREMATURELY" diff --git a/frontend/lib/models/test_event.dart b/frontend/lib/models/test_event.dart new file mode 100644 index 00000000..c0560799 --- /dev/null +++ b/frontend/lib/models/test_event.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'test_event.freezed.dart'; +part 'test_event.g.dart'; + +@freezed +class TestEvent with _$TestEvent { + const factory TestEvent({ + @JsonKey(name: 'event_name') required String eventName, + required String timestamp, + required String detail, + }) = _TestEvent; + + factory TestEvent.fromJson(Map json) => + _$TestEventFromJson(json); +} diff --git a/frontend/lib/models/test_execution.dart b/frontend/lib/models/test_execution.dart index 4bad811b..a0249be5 100644 --- a/frontend/lib/models/test_execution.dart +++ b/frontend/lib/models/test_execution.dart @@ -50,13 +50,16 @@ enum TestExecutionStatus { @JsonValue('IN_PROGRESS') inProgress, @JsonValue('PASSED') - passed; + passed, + @JsonValue('ENDED_PREMATURELY') + endedPrematurely; bool get isCompleted { switch (this) { case notStarted: case inProgress: case notTested: + case endedPrematurely: return false; case passed: case failed: @@ -76,6 +79,8 @@ enum TestExecutionStatus { return 'Failed'; case notTested: return 'Not Tested'; + case endedPrematurely: + return 'Ended Prematurely'; } } @@ -92,6 +97,8 @@ enum TestExecutionStatus { return const Icon(YaruIcons.error, color: YaruColors.red, size: size); case notTested: return const Icon(YaruIcons.information, size: size); + case endedPrematurely: + return const Icon(YaruIcons.junk_filled, size: size); } } } diff --git a/frontend/lib/providers/test_events.dart b/frontend/lib/providers/test_events.dart new file mode 100644 index 00000000..3cd0a43e --- /dev/null +++ b/frontend/lib/providers/test_events.dart @@ -0,0 +1,15 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../models/test_event.dart'; +import 'api.dart'; + +part 'test_events.g.dart'; + +@riverpod +Future> testEvents( + TestEventsRef ref, + int testExecutionId, +) async { + final api = ref.watch(apiProvider); + return await api.getTestExecutionEvents(testExecutionId); +} diff --git a/frontend/lib/repositories/api_repository.dart b/frontend/lib/repositories/api_repository.dart index 54946ae7..b89d1d27 100644 --- a/frontend/lib/repositories/api_repository.dart +++ b/frontend/lib/repositories/api_repository.dart @@ -7,6 +7,7 @@ import '../models/family_name.dart'; import '../models/rerun_request.dart'; import '../models/test_execution.dart'; import '../models/test_result.dart'; +import '../models/test_event.dart'; class ApiRepository { final Dio dio; @@ -66,6 +67,16 @@ class ApiRepository { return testResults; } + Future> getTestExecutionEvents(int testExecutionId) async { + final response = + await dio.get('/v1/test-executions/$testExecutionId/status_update'); + final List testEventsJson = response.data; + final testEvents = + testEventsJson.map((json) => TestEvent.fromJson(json)).toList(); + return testEvents; + } + + Future> rerunTestExecutions( Set testExecutionIds, ) async { diff --git a/frontend/lib/ui/artefact_page/test_event_log_expandable.dart b/frontend/lib/ui/artefact_page/test_event_log_expandable.dart new file mode 100644 index 00000000..f693197f --- /dev/null +++ b/frontend/lib/ui/artefact_page/test_event_log_expandable.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/test_event.dart'; +import '../spacing.dart'; + +class TestEventLogExpandable extends ConsumerWidget { + const TestEventLogExpandable({ + super.key, + required this.testExecutionId, + required this.initiallyExpanded, + required this.testEvents, + }); + + final int testExecutionId; + final bool initiallyExpanded; + final List testEvents; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ExpansionTile( + controlAffinity: ListTileControlAffinity.leading, + childrenPadding: const EdgeInsets.only(left: Spacing.level4), + shape: const Border(), + title: const Text('Event Log'), + initiallyExpanded: initiallyExpanded, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columns: const [ + DataColumn( + label: Expanded( + child: Text( + 'Event Name', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ), + ), + DataColumn( + label: Expanded( + child: Text( + 'Timestamp', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ), + ), + DataColumn( + label: Expanded( + child: Text( + 'Detail', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ), + ), + ], + rows: testEvents.map( + (testEvent) => DataRow( + cells: [ + DataCell(Text(testEvent.eventName)), + DataCell(Text(testEvent.timestamp)), + DataCell(Text(testEvent.detail)), + ], + ), + ) + .toList(), + ), + ), + ], + ); + } +} diff --git a/frontend/lib/ui/artefact_page/test_execution_expandable.dart b/frontend/lib/ui/artefact_page/test_execution_expandable.dart index 89082610..b8fc5f38 100644 --- a/frontend/lib/ui/artefact_page/test_execution_expandable.dart +++ b/frontend/lib/ui/artefact_page/test_execution_expandable.dart @@ -1,15 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:yaru_widgets/yaru_widgets.dart'; import '../../models/test_execution.dart'; import '../../models/test_result.dart'; import '../../providers/artefact_builds.dart'; +import '../../providers/test_events.dart'; import '../../routing.dart'; import '../inline_url_text.dart'; import '../spacing.dart'; import 'test_execution_review.dart'; import 'test_result_filter_expandable.dart'; +import 'test_event_log_expandable.dart'; class TestExecutionExpandable extends ConsumerWidget { const TestExecutionExpandable({super.key, required this.testExecution}); @@ -18,11 +21,42 @@ class TestExecutionExpandable extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final testEvents = ref.watch(testEventsProvider(testExecution.id)); + + Widget eventLogExpandable(bool initiallyExpanded) => testEvents.when( + loading: () => const Center(child: YaruCircularProgressIndicator()), + error: (error, stackTrace) => Center(child: Text('Error: $error')), + data: (testEvents) => TestEventLogExpandable( + testExecutionId: testExecution.id, + initiallyExpanded: initiallyExpanded, + testEvents: testEvents, + ), + ); + + final executionTitle = _TestExecutionTileTitle( + testExecution: testExecution, + titleAdditions: testEvents.when( + loading: () => '', + error: (error, stackTrace) => '', + data: (testEvents) { + if (testEvents.isNotEmpty) { + return ' (${testEvents[testEvents.length - 1].eventName})'; + } else { + return ''; + } + }, + ), + ); + if (!testExecution.status.isCompleted) { - return ListTile( - onTap: () {}, + return ExpansionTile( + controlAffinity: ListTileControlAffinity.leading, + childrenPadding: const EdgeInsets.only(left: Spacing.level4), shape: const Border(), - title: _TestExecutionTileTitle(testExecution: testExecution), + title: executionTitle, + children: [ + eventLogExpandable(true), + ], ); } @@ -30,8 +64,16 @@ class TestExecutionExpandable extends ConsumerWidget { controlAffinity: ListTileControlAffinity.leading, childrenPadding: const EdgeInsets.only(left: Spacing.level4), shape: const Border(), - title: _TestExecutionTileTitle(testExecution: testExecution), - children: TestResultStatus.values + title: executionTitle, + children: [ + eventLogExpandable(false), + ExpansionTile( + controlAffinity: ListTileControlAffinity.leading, + childrenPadding: const EdgeInsets.only(left: Spacing.level4), + shape: const Border(), + title: const Text('Test Results'), + initiallyExpanded: true, + children: TestResultStatus.values .map( (status) => TestResultsFilterExpandable( statusToFilterBy: status, @@ -39,14 +81,17 @@ class TestExecutionExpandable extends ConsumerWidget { ), ) .toList(), + ), + ], ); } } class _TestExecutionTileTitle extends StatelessWidget { - const _TestExecutionTileTitle({required this.testExecution}); + const _TestExecutionTileTitle({required this.testExecution, required this.titleAdditions}); final TestExecution testExecution; + final String titleAdditions; @override Widget build(BuildContext context) { @@ -54,12 +99,11 @@ class _TestExecutionTileTitle extends StatelessWidget { final c3Link = testExecution.c3Link; return Row( - children: [ - if (!testExecution.status.isCompleted) const SizedBox(width: 36.0), + children: [ testExecution.status.icon, const SizedBox(width: Spacing.level4), Text( - testExecution.environment.name, + testExecution.environment.name + titleAdditions, style: Theme.of(context).textTheme.titleLarge, ), const Spacer(), diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 3e9daf4c..0419f72f 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -459,26 +459,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" lints: dependency: transitive description: @@ -515,10 +515,10 @@ packages: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.0" mime: dependency: transitive description: @@ -816,26 +816,26 @@ packages: dependency: "direct dev" description: name: test - sha256: a1f7595805820fcc05e5c52e3a231aedd0b72972cb333e8c738a8b1239448b6f + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" url: "https://pub.dev" source: hosted - version: "1.24.9" + version: "1.25.2" test_api: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" test_core: dependency: transitive description: name: test_core - sha256: a757b14fc47507060a162cc2530d9a4a2f92f5100a952c7443b5cad5ef5b106a + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" url: "https://pub.dev" source: hosted - version: "0.5.9" + version: "0.6.0" time: dependency: transitive description: @@ -944,10 +944,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.1" watcher: dependency: transitive description: