'
+ if filename.exists():
+ with open(filename, 'r') as expected_file:
+ expected = expected_file.read()
+ # update to pick up latest data
+ if force_update:
+ self.force_update()
+ # force urwid to draw the screen
+ # (the main loop isn't runing so this doesn't happen automatically)
+ self.app.loop.draw_screen()
+ # take a screenshot
+ screenshot = self.html_fragment.screenshot_collect()[-1]
+
+ try:
+ if expected != screenshot:
+ # screenshot does not match
+ # => write an html file with the visual diff
+ out = self.test_dir / filename.name
+ with open(out, 'w+') as out_file:
+ out_file.write(
+ format_test_failure(
+ expected,
+ screenshot,
+ description,
+ )
+ )
+ raise Exception(
+ 'Screenshot differs:'
+ '\n* Set "CYLC_UPDATE_SCREENSHOTS=true" to update'
+ f'\n* To debug see: file:////{out}'
+ )
+
+ break
+ except Exception as exc_:
+ exc = exc_
+ # wait a while to allow the updater to do its job
+ sleep(delay)
+ else:
+ if os.environ.get('CYLC_UPDATE_SCREENSHOTS', '').lower() == 'true':
+ with open(filename, 'w+') as expected_file:
+ expected_file.write(screenshot)
+ else:
+ raise exc
+
+ def force_update(self):
+ """Run Tui's update method.
+
+ This is done automatically by compare_screenshot but you may want to
+ call it in a test, e.g. before pressing navigation keys.
+
+ With Raikura, the Tui event loop is not running so the data is never
+ refreshed.
+
+ You do NOT need to call this method for key presses, but you do need to
+ call this if the data has changed (e.g. if you've changed a task state)
+ OR if you've changed any filters (because filters are handled by the
+ update code).
+ """
+ # flush any prior updates
+ self.app.get_update()
+ # wait for the next update
+ while not self.app.update():
+ pass
+
+ def wait_until_loaded(self, *ids, retries=20):
+ """Wait until the given ID appears in the Tui tree, then expand them.
+
+ Useful for waiting whilst Tui loads a workflow.
+
+ Note, this is a blocking wait with no timeout!
+ """
+ exc = None
+ try:
+ ids = self.app.wait_until_loaded(*ids, retries=retries)
+ except Exception as _exc:
+ exc = _exc
+ if ids:
+ msg = (
+ 'Requested nodes did not appear in Tui after'
+ f' {retries} retries: '
+ + ', '.join(ids)
+ )
+ if exc:
+ msg += f'\n{exc}'
+ self.compare_screenshot(f'fail-{uuid1()}', msg, 1)
+
+
+@pytest.fixture
+def raikura(test_dir, request, monkeypatch):
+ """Visual regression test framework for Urwid apps.
+
+ Like Cypress but for Tui so named after a NZ island with lots of Tuis.
+
+ When called this yields a RaikuraSession object loaded with test
+ utilities. All tests have default retries to avoid flaky tests.
+
+ Similar to the "start" fixture, which starts a Scheduler without running
+ the main loop, raikura starts Tui without running the main loop.
+
+ Arguments:
+ workflow_id:
+ The "WORKFLOW" argument of the "cylc tui" command line.
+ size:
+ The virtual terminal size for screenshots as a comma
+ separated string e.g. "80,50" for 80 cols wide by 50 rows tall.
+
+ Returns:
+ A RaikuraSession context manager which provides useful utilities for
+ testing.
+
+ """
+ return _raikura(test_dir, request, monkeypatch)
+
+
+@pytest.fixture
+def mod_raikura(test_dir, request, monkeypatch):
+ """Same as raikura but configured to view module-scoped workflows.
+
+ Note: This is *not* a module-scoped fixture (no need, creating Tui sessions
+ is not especially slow), it is configured to display module-scoped
+ "scheduler" fixtures (which may be more expensive to create/destroy).
+ """
+ return _raikura(test_dir.parent, request, monkeypatch)
+
+
+def _raikura(test_dir, request, monkeypatch):
+ # make the workflow and scan update intervals match (more reliable)
+ # and speed things up a little whilst we're at it
+ monkeypatch.setattr(
+ 'cylc.flow.tui.updater.Updater.BASE_UPDATE_INTERVAL',
+ 0.1,
+ )
+ monkeypatch.setattr(
+ 'cylc.flow.tui.updater.Updater.BASE_SCAN_INTERVAL',
+ 0.1,
+ )
+
+ # the user name and the prefix of workflow IDs are both variable
+ # so we patch the render functions to make test output stable
+ def get_display_id(id_):
+ tokens = Tokens(id_)
+ return _get_display_id(
+ tokens.duplicate(
+ user='cylc',
+ workflow=tokens.get('workflow', '').rsplit('/', 1)[-1],
+ ).id
+ )
+ monkeypatch.setattr('cylc.flow.tui.util.ME', 'cylc')
+ monkeypatch.setattr(
+ 'cylc.flow.tui.util._display_workflow_id',
+ lambda data: data['name'].rsplit('/', 1)[-1]
+ )
+ monkeypatch.setattr(
+ 'cylc.flow.tui.overlay._get_display_id',
+ get_display_id,
+ )
+
+ # filter Tui so that only workflows created within our test show up
+ id_base = str(test_dir.relative_to(Path("~/cylc-run").expanduser()))
+ workflow_filter = re.escape(id_base) + r'/.*'
+
+ @contextmanager
+ def _raikura(workflow_id=None, size='80,50'):
+ screen, html_fragment = configure_screenshot(size)
+ app = TuiApp(screen=screen)
+ with app.main(
+ workflow_id,
+ id_filter=workflow_filter,
+ interactive=False,
+ ):
+ yield RaikuraSession(
+ app,
+ html_fragment,
+ test_dir,
+ request.function.__name__,
+ )
+
+ return _raikura
diff --git a/tests/integration/tui/screenshots/test_auto_expansion.later-time.html b/tests/integration/tui/screenshots/test_auto_expansion.later-time.html
new file mode 100644
index 00000000000..f5a19fd428d
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_auto_expansion.later-time.html
@@ -0,0 +1,21 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ - one - paused
+ - ̿○ 1
+ ̿○ b
+ - ̿○ 2
+ - ̿○ A
+ ̿○ a
+ ○ b
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_auto_expansion.on-load.html b/tests/integration/tui/screenshots/test_auto_expansion.on-load.html
new file mode 100644
index 00000000000..df3c9f5c41b
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_auto_expansion.on-load.html
@@ -0,0 +1,21 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ - one - paused
+ - ̿○ 1
+ - ̿○ A
+ ̿○ a
+ ○ b
+
+
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_errors.list-error.html b/tests/integration/tui/screenshots/test_errors.list-error.html
new file mode 100644
index 00000000000..02448aa0267
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_errors.list-error.html
@@ -0,0 +1,31 @@
+┌────────────────┌────────────────────────────────────────────────┐────────────┐
+│ Error: Somethi│ Error │ │
+│ │ │ │
+│ < Select File │ Something went wrong :( │ > │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ q to close │ q to close │ │
+└────────────────└────────────────────────────────────────────────┘────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_errors.open-error.html b/tests/integration/tui/screenshots/test_errors.open-error.html
new file mode 100644
index 00000000000..142d0d88c72
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_errors.open-error.html
@@ -0,0 +1,31 @@
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ Error: Something went wrong :( │
+│ │
+│ < Select File > │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ q to close │
+└──────────────────────────────────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_job_logs.01-job.out.html b/tests/integration/tui/screenshots/test_job_logs.01-job.out.html
new file mode 100644
index 00000000000..c1c767b98cf
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_job_logs.01-job.out.html
@@ -0,0 +1,31 @@
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ Host: myhost │
+│ Path: mypath │
+│ < Select File > │
+│ │
+│ job: 1/a/01 │
+│ this is a job log │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ q to close │
+└──────────────────────────────────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_job_logs.02-job.out.html b/tests/integration/tui/screenshots/test_job_logs.02-job.out.html
new file mode 100644
index 00000000000..0eb94051201
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_job_logs.02-job.out.html
@@ -0,0 +1,31 @@
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ Host: myhost │
+│ Path: mypath │
+│ < Select File > │
+│ │
+│ job: 1/a/02 │
+│ this is a job log │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ q to close │
+└──────────────────────────────────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_navigation.cursor-at-bottom-of-screen.html b/tests/integration/tui/screenshots/test_navigation.cursor-at-bottom-of-screen.html
new file mode 100644
index 00000000000..bf5e3812008
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_navigation.cursor-at-bottom-of-screen.html
@@ -0,0 +1,31 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ - one - paused
+ - ̿○ 1
+ + ̿○ A
+ - ̿○ B
+ - ̿○ B1
+ ̿○ b11
+ ̿○ b12
+ - ̿○ B2
+ ̿○ b21
+ ̿○ b22
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_navigation.family-A-collapsed.html b/tests/integration/tui/screenshots/test_navigation.family-A-collapsed.html
new file mode 100644
index 00000000000..cefab5264f4
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_navigation.family-A-collapsed.html
@@ -0,0 +1,31 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ - one - paused
+ - ̿○ 1
+ + ̿○ A
+ - ̿○ B
+ - ̿○ B1
+ ̿○ b11
+ ̿○ b12
+ - ̿○ B2
+ ̿○ b21
+ ̿○ b22
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_navigation.on-load.html b/tests/integration/tui/screenshots/test_navigation.on-load.html
new file mode 100644
index 00000000000..a0bd107742b
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_navigation.on-load.html
@@ -0,0 +1,31 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ + one - paused
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_navigation.workflow-expanded.html b/tests/integration/tui/screenshots/test_navigation.workflow-expanded.html
new file mode 100644
index 00000000000..6b26ced563e
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_navigation.workflow-expanded.html
@@ -0,0 +1,31 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ - one - paused
+ - ̿○ 1
+ - ̿○ A
+ ̿○ a1
+ ̿○ a2
+ - ̿○ B
+ - ̿○ B1
+ ̿○ b11
+ ̿○ b12
+ - ̿○ B2
+ ̿○ b21
+ ̿○ b22
+
+
+
+
+
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_offline_mutation.clean-command-error.html b/tests/integration/tui/screenshots/test_offline_mutation.clean-command-error.html
new file mode 100644
index 00000000000..88defab9486
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_offline_mutation.clean-command-error.html
@@ -0,0 +1,16 @@
+Cylc Tui work┌────┌────────────────────────────────────────────────┐
+ │ id│ Error │
+- ~cylc │ │ │
+ + one - stop│ Ac│ Error in command cylc clean --yes one │
+ │ < │ mock-stderr │
+ │ │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ │ │
+ │ │ │
+ │ │ │
+quit: q help: │ q t│ q to close │ome End
+filter tasks: T└────└────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_offline_mutation.clean-mutation-selected.html b/tests/integration/tui/screenshots/test_offline_mutation.clean-mutation-selected.html
new file mode 100644
index 00000000000..f28cced0714
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_offline_mutation.clean-mutation-selected.html
@@ -0,0 +1,16 @@
+Cylc Tui work┌────────────────────────────────────────────────┐
+ │ id: ~cylc/one │
+- ~cylc │ │
+ + one - stop│ Action │
+ │ < (cancel) > │
+ │ │
+ │ < clean > │
+ │ < log > │
+ │ < play > │
+ │ < reinstall-reload > │
+ │ │
+ │ │
+ │ │
+quit: q help: │ q to close │↥ ↧ Home End
+filter tasks: T└────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_offline_mutation.stop-all-mutation-selected.html b/tests/integration/tui/screenshots/test_offline_mutation.stop-all-mutation-selected.html
new file mode 100644
index 00000000000..c2355597f78
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_offline_mutation.stop-all-mutation-selected.html
@@ -0,0 +1,16 @@
+Cylc Tui work┌────────────────────────────────────────────────┐
+ │ id: ~cylc/root │
+- ~cylc │ │
+ + one - stop│ Action │
+ │ < (cancel) > │
+ │ │
+ │ < stop-all > │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+quit: q help: │ q to close │↥ ↧ Home End
+filter tasks: T└────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_online_mutation.command-failed-client-error.html b/tests/integration/tui/screenshots/test_online_mutation.command-failed-client-error.html
new file mode 100644
index 00000000000..895856c6ea2
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_online_mutation.command-failed-client-error.html
@@ -0,0 +1,16 @@
+Cylc Tui work┌────┌────────────────────────────────────────────────┐
+ │ id│ Error │
+- ~cylc │ │ │
+ - one - paus│ Ac│ Error connecting to workflow: mock error │
+ - ̿○ 1 │ < │ │
+ ̿○ on│ │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ │ │
+quit: q help: │ q t│ q to close │ome End
+filter tasks: T└────└────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_online_mutation.command-failed-workflow-stopped.html b/tests/integration/tui/screenshots/test_online_mutation.command-failed-workflow-stopped.html
new file mode 100644
index 00000000000..6f9954926ef
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_online_mutation.command-failed-workflow-stopped.html
@@ -0,0 +1,16 @@
+Cylc Tui work┌────┌────────────────────────────────────────────────┐
+ │ id│ Error │
+- ~cylc │ │ │
+ - one - paus│ Ac│ Cannot peform command hold on a stopped │
+ - ̿○ 1 │ < │ workflow │
+ ̿○ on│ │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ │ │
+quit: q help: │ q t│ q to close │ome End
+filter tasks: T└────└────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_online_mutation.command-failed.html b/tests/integration/tui/screenshots/test_online_mutation.command-failed.html
new file mode 100644
index 00000000000..fae4a429cc6
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_online_mutation.command-failed.html
@@ -0,0 +1,16 @@
+Cylc Tui work┌────┌────────────────────────────────────────────────┐
+ │ id│ Error │
+- ~cylc │ │ │
+ - one - paus│ Ac│ Cannot peform command hold on a stopped │
+ - ̿○ 1 │ < │ workflow │
+ ̿○ on│ │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ │ │
+ │ │ │
+quit: q help: │ q t│ q to close │ome End
+filter tasks: T└────└────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_online_mutation.hold-mutation-selected.html b/tests/integration/tui/screenshots/test_online_mutation.hold-mutation-selected.html
new file mode 100644
index 00000000000..34be2ffa0ce
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_online_mutation.hold-mutation-selected.html
@@ -0,0 +1,16 @@
+Cylc Tui work┌────────────────────────────────────────────────┐
+ │ id: 1/one │
+- ~cylc │ │
+ - one - paus│ Action │
+ - ̿○ 1 │ < (cancel) > │
+ ̿○ on│ │
+ │ < hold > │
+ │ < kill > │
+ │ < log > │
+ │ < poll > │
+ │ < release > │
+ │ < show > │
+ │ │
+quit: q help: │ q to close │↥ ↧ Home End
+filter tasks: T└────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_online_mutation.task-selected.html b/tests/integration/tui/screenshots/test_online_mutation.task-selected.html
new file mode 100644
index 00000000000..7d94d5e43dd
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_online_mutation.task-selected.html
@@ -0,0 +1,16 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ - one - paused
+ - ̿○ 1
+ ̿○ one
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_restart_reconnect.1-workflow-running.html b/tests/integration/tui/screenshots/test_restart_reconnect.1-workflow-running.html
new file mode 100644
index 00000000000..74c02508239
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_restart_reconnect.1-workflow-running.html
@@ -0,0 +1,21 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ - one - paused
+ - ̿○ 1
+ ̿○ one
+
+
+
+
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_restart_reconnect.2-workflow-stopped.html b/tests/integration/tui/screenshots/test_restart_reconnect.2-workflow-stopped.html
new file mode 100644
index 00000000000..09c3bbd7fb0
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_restart_reconnect.2-workflow-stopped.html
@@ -0,0 +1,21 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ - one - stopped
+ Workflow is not running
+
+
+
+
+
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_restart_reconnect.3-workflow-restarted.html b/tests/integration/tui/screenshots/test_restart_reconnect.3-workflow-restarted.html
new file mode 100644
index 00000000000..74c02508239
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_restart_reconnect.3-workflow-restarted.html
@@ -0,0 +1,21 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ - one - paused
+ - ̿○ 1
+ ̿○ one
+
+
+
+
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_scheduler_logs.log-file-selection.html b/tests/integration/tui/screenshots/test_scheduler_logs.log-file-selection.html
new file mode 100644
index 00000000000..f88e1b0124d
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_scheduler_logs.log-file-selection.html
@@ -0,0 +1,31 @@
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ Host: myhost │
+│ Path: mypath │
+│ < Select File > │
+│ │
+│ this is the │
+│ scheduler log file │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ ┌──────────────────────────────────────┐ │
+│ │ Select File │ │
+│ │ │ │
+│ │ < config/01-start-01.cylc > │ │
+│ │ < config/flow-processed.cylc > │ │
+│ │ < scheduler/01-start-01.log > │ │
+│ │ │ │
+│ │ q to close │ │
+│ └──────────────────────────────────────┘ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ q to close │
+└──────────────────────────────────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_scheduler_logs.scheduler-log-file.html b/tests/integration/tui/screenshots/test_scheduler_logs.scheduler-log-file.html
new file mode 100644
index 00000000000..68dbcc10f9c
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_scheduler_logs.scheduler-log-file.html
@@ -0,0 +1,31 @@
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ Host: myhost │
+│ Path: mypath │
+│ < Select File > │
+│ │
+│ this is the │
+│ scheduler log file │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ q to close │
+└──────────────────────────────────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_scheduler_logs.workflow-configuration-file.html b/tests/integration/tui/screenshots/test_scheduler_logs.workflow-configuration-file.html
new file mode 100644
index 00000000000..e3fcdfbab22
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_scheduler_logs.workflow-configuration-file.html
@@ -0,0 +1,31 @@
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ Host: myhost │
+│ Path: mypath │
+│ < Select File > │
+│ │
+│ [scheduling] │
+│ [[graph]] │
+│ R1 = a │
+│ [runtime] │
+│ [[a]] │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ q to close │
+└──────────────────────────────────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_show.fail.html b/tests/integration/tui/screenshots/test_show.fail.html
new file mode 100644
index 00000000000..f788e5b3a55
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_show.fail.html
@@ -0,0 +1,41 @@
+Cylc Tui workflows┌────────────────────────────────────────────────┐
+ │ Error │
+- ~cylc │ │
+ - one - paused │ :( │
+ - ̿○ 1 │ │
+ ̿○ foo │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ ┌────│ │
+ │ id│ │
+ │ │ │
+ │ Ac│ │
+ │ < │ │
+ │ │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ < │ │
+ │ │ │
+ │ │ │
+ │ │ │
+ │ │ │
+ │ │ │
+ │ q t│ │
+ └────│ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+quit: q help: h co│ q to close │ome End
+filter tasks: T f s └────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_show.success.html b/tests/integration/tui/screenshots/test_show.success.html
new file mode 100644
index 00000000000..afdcd1a73b4
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_show.success.html
@@ -0,0 +1,41 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ - one - paused
+ - ̿○ 1
+ ̿○ foo
+
+
+
+
+
+ ┌────────────────────────────────────────────────┐
+ │ title: Foo │
+ │ description: The first metasyntactic │
+ │ variable. │
+ │ URL: (not given) │
+ │ state: waiting │
+ │ prerequisites: (None) │
+ │ outputs: ('-': not completed) │
+ │ - 1/foo expired │
+ │ - 1/foo submitted │
+ │ - 1/foo submit-failed │
+ │ - 1/foo started │
+ │ - 1/foo succeeded │
+ │ - 1/foo failed │
+ │ │
+ │ │
+ │ q to close │
+ └────────────────────────────────────────────────┘
+
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_subscribe_unsubscribe.subscribed.html b/tests/integration/tui/screenshots/test_subscribe_unsubscribe.subscribed.html
new file mode 100644
index 00000000000..019184ec897
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_subscribe_unsubscribe.subscribed.html
@@ -0,0 +1,16 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ - one - paused
+ - ̿○ 1
+ ̿○ one
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_subscribe_unsubscribe.unsubscribed.html b/tests/integration/tui/screenshots/test_subscribe_unsubscribe.unsubscribed.html
new file mode 100644
index 00000000000..8fa0f4329a1
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_subscribe_unsubscribe.unsubscribed.html
@@ -0,0 +1,16 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ + one - paused
+
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_task_logs.latest-job.err.html b/tests/integration/tui/screenshots/test_task_logs.latest-job.err.html
new file mode 100644
index 00000000000..4814892df7a
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_task_logs.latest-job.err.html
@@ -0,0 +1,31 @@
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ Host: myhost │
+│ Path: mypath │
+│ < Select File > │
+│ │
+│ job: 1/a/02 │
+│ this is a job error │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ q to close │
+└──────────────────────────────────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_task_logs.latest-job.out.html b/tests/integration/tui/screenshots/test_task_logs.latest-job.out.html
new file mode 100644
index 00000000000..0eb94051201
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_task_logs.latest-job.out.html
@@ -0,0 +1,31 @@
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ Host: myhost │
+│ Path: mypath │
+│ < Select File > │
+│ │
+│ job: 1/a/02 │
+│ this is a job log │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ q to close │
+└──────────────────────────────────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_tui_basics.test-raikura-enter.html b/tests/integration/tui/screenshots/test_tui_basics.test-raikura-enter.html
new file mode 100644
index 00000000000..d54d9538d26
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_tui_basics.test-raikura-enter.html
@@ -0,0 +1,41 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+
+
+
+
+
+
+
+ ┌────────────────────────────────────────────────┐
+ │ id: ~cylc/root │
+ │ │
+ │ Action │
+ │ < (cancel) > │
+ │ │
+ │ < stop-all > │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ q to close │
+ └────────────────────────────────────────────────┘
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_tui_basics.test-raikura-help.html b/tests/integration/tui/screenshots/test_tui_basics.test-raikura-help.html
new file mode 100644
index 00000000000..1795c586d9a
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_tui_basics.test-raikura-help.html
@@ -0,0 +1,41 @@
+Cylc Tui ┌──────────────────────────────────────────────────────────┐
+ │ │
+- ~cylc │ _ _ _ │
+ │ | | | | (_) │
+ │ ___ _ _| | ___ | |_ _ _ _ │
+ │ / __| | | | |/ __| | __| | | | | │
+ │ | (__| |_| | | (__ | |_| |_| | | │
+ │ \___|\__, |_|\___| \__|\__,_|_| │
+ │ __/ | │
+ │ |___/ │
+ │ │
+ │ ( scroll using arrow keys ) │
+ │ │
+ │ │
+ │ │
+ │ _,@@@@@@. │
+ │ <=@@@, `@@@@@. │
+ │ `-@@@@@@@@@@@' │
+ │ :@@@@@@@@@@. │
+ │ (.@@@@@@@@@@@ │
+ │ ( '@@@@@@@@@@@@. │
+ │ ;.@@@@@@@@@@@@@@@ │
+ │ '@@@@@@@@@@@@@@@@@@, │
+ │ ,@@@@@@@@@@@@@@@@@@@@' │
+ │ :.@@@@@@@@@@@@@@@@@@@@@. │
+ │ .@@@@@@@@@@@@@@@@@@@@@@@@. │
+ │ '@@@@@@@@@@@@@@@@@@@@@@@@@. │
+ │ ;@@@@@@@@@@@@@@@@@@@@@@@@@@@ │
+ │ .@@@@@@@@@@@@@@@@@@@@@@@@@@. │
+ │ .@@@@@@@@@@@@@@@@@@@@@@@@@@, │
+ │ .@@@@@@@@@@@@@@@@@@@@@@@@@' │
+ │ .@@@@@@@@@@@@@@@@@@@@@@@@' , │
+ │ :@@@@@@@@@@@@@@@@@@@@@..''';,,,;::- │
+ │ '@@@@@@@@@@@@@@@@@@@. `. ` │
+ │ .@@@@@@.: ,.@@@@@@@. ` │
+ │ :@@@@@@@, ;.@, │
+ │ '@@@@@@. `@' │
+ │ │
+quit: q h│ q to close │ome End
+filter tas└──────────────────────────────────────────────────────────┘
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_tui_basics.test-raikura.html b/tests/integration/tui/screenshots/test_tui_basics.test-raikura.html
new file mode 100644
index 00000000000..7f80031804b
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_tui_basics.test-raikura.html
@@ -0,0 +1,41 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_workflow_states.filter-active.html b/tests/integration/tui/screenshots/test_workflow_states.filter-active.html
new file mode 100644
index 00000000000..282f76735ed
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_workflow_states.filter-active.html
@@ -0,0 +1,16 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ + one - stopping
+ + two - paused
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_workflow_states.filter-starts-with-t.html b/tests/integration/tui/screenshots/test_workflow_states.filter-starts-with-t.html
new file mode 100644
index 00000000000..8c26ce6ccc9
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_workflow_states.filter-starts-with-t.html
@@ -0,0 +1,16 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ + tre - stopped
+ + two - paused
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_workflow_states.filter-stopped-or-paused.html b/tests/integration/tui/screenshots/test_workflow_states.filter-stopped-or-paused.html
new file mode 100644
index 00000000000..8c26ce6ccc9
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_workflow_states.filter-stopped-or-paused.html
@@ -0,0 +1,16 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ + tre - stopped
+ + two - paused
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_workflow_states.filter-stopped.html b/tests/integration/tui/screenshots/test_workflow_states.filter-stopped.html
new file mode 100644
index 00000000000..1ff602df101
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_workflow_states.filter-stopped.html
@@ -0,0 +1,16 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ + tre - stopped
+
+
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/screenshots/test_workflow_states.unfiltered.html b/tests/integration/tui/screenshots/test_workflow_states.unfiltered.html
new file mode 100644
index 00000000000..0651eedec30
--- /dev/null
+++ b/tests/integration/tui/screenshots/test_workflow_states.unfiltered.html
@@ -0,0 +1,16 @@
+Cylc Tui workflows filtered (W - edit, E - reset)
+
+- ~cylc
+ + one - stopping
+ + tre - stopped
+ + two - paused
+
+
+
+
+
+
+
+quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End
+filter tasks: T f s r R filter workflows: W E p
+
\ No newline at end of file
diff --git a/tests/integration/tui/test_app.py b/tests/integration/tui/test_app.py
new file mode 100644
index 00000000000..e6ed8c6e24d
--- /dev/null
+++ b/tests/integration/tui/test_app.py
@@ -0,0 +1,388 @@
+#!/usr/bin/env python3
+# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE.
+# Copyright (C) NIWA & British Crown (Met Office) & Contributors.
+#
+# 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 pytest
+import urwid
+
+from cylc.flow.cycling.integer import IntegerPoint
+from cylc.flow.task_state import (
+# TASK_STATUS_RUNNING,
+ TASK_STATUS_SUCCEEDED,
+# TASK_STATUS_FAILED,
+# TASK_STATUS_WAITING,
+)
+from cylc.flow.workflow_status import StopMode
+
+
+def set_task_state(schd, task_states):
+ """Force tasks into the desired states.
+
+ Task states should be of the format (cycle, task, state, is_held).
+ """
+ for cycle, task, state, is_held in task_states:
+ itask = schd.pool.get_task(cycle, task)
+ if not itask:
+ itask = schd.pool.spawn_task(task, cycle, {1})
+ itask.state_reset(state, is_held=is_held)
+ schd.data_store_mgr.delta_task_state(itask)
+ schd.data_store_mgr.increment_graph_window(
+ itask.tokens,
+ cycle,
+ {1},
+ )
+
+
+async def test_tui_basics(raikura):
+ """Test basic Tui interaction with no workflows."""
+ with raikura(size='80,40') as rk:
+ # the app should open
+ rk.compare_screenshot('test-raikura', 'the app should have loaded')
+
+ # "h" should bring up the onscreen help
+ rk.user_input('h')
+ rk.compare_screenshot(
+ 'test-raikura-help',
+ 'the help screen should be visible'
+ )
+
+ # "q" should close the popup
+ rk.user_input('q')
+ rk.compare_screenshot(
+ 'test-raikura',
+ 'the help screen should have closed',
+ )
+
+ # "enter" should bring up the context menu
+ rk.user_input('enter')
+ rk.compare_screenshot(
+ 'test-raikura-enter',
+ 'the context menu should have opened',
+ )
+
+ # "enter" again should close it via the "cancel" button
+ rk.user_input('enter')
+ rk.compare_screenshot(
+ 'test-raikura',
+ 'the context menu should have closed',
+ )
+
+ # "ctrl d" should exit Tui
+ with pytest.raises(urwid.ExitMainLoop):
+ rk.user_input('ctrl d')
+
+ # "q" should exit Tui
+ with pytest.raises(urwid.ExitMainLoop):
+ rk.user_input('q')
+
+
+async def test_subscribe_unsubscribe(one_conf, flow, scheduler, start, raikura):
+ """Test a simple workflow with one task."""
+ id_ = flow(one_conf, name='one')
+ schd = scheduler(id_)
+ async with start(schd):
+ await schd.update_data_structure()
+ with raikura(size='80,15') as rk:
+ rk.compare_screenshot(
+ 'unsubscribed',
+ 'the workflow should be collapsed'
+ ' (no subscription no state totals)',
+ )
+
+ # expand the workflow to subscribe to it
+ rk.user_input('down', 'right')
+ rk.wait_until_loaded()
+ rk.compare_screenshot(
+ 'subscribed',
+ 'the workflow should be expanded',
+ )
+
+ # collapse the workflow to unsubscribe from it
+ rk.user_input('left', 'up')
+ rk.force_update()
+ rk.compare_screenshot(
+ 'unsubscribed',
+ 'the workflow should be collapsed'
+ ' (no subscription no state totals)',
+ )
+
+
+async def test_workflow_states(one_conf, flow, scheduler, start, raikura):
+ """Test viewing multiple workflows in different states."""
+ # one => stopping
+ id_1 = flow(one_conf, name='one')
+ schd_1 = scheduler(id_1)
+ # two => paused
+ id_2 = flow(one_conf, name='two')
+ schd_2 = scheduler(id_2)
+ # tre => stopped
+ flow(one_conf, name='tre')
+
+ async with start(schd_1):
+ schd_1.stop_mode = StopMode.AUTO # make it look like we're stopping
+ await schd_1.update_data_structure()
+
+ async with start(schd_2):
+ await schd_2.update_data_structure()
+ with raikura(size='80,15') as rk:
+ rk.compare_screenshot(
+ 'unfiltered',
+ 'All workflows should be visible (one, two, tree)',
+ )
+
+ # filter for active workflows (i.e. paused, running, stopping)
+ rk.user_input('p')
+ rk.compare_screenshot(
+ 'filter-active',
+ 'Only active workflows should be visible (one, two)'
+ )
+
+ # invert the filter so we are filtering for stopped workflows
+ rk.user_input('W', 'enter', 'q')
+ rk.compare_screenshot(
+ 'filter-stopped',
+ 'Only stopped workflow should be visible (tre)'
+ )
+
+ # filter in paused workflows
+ rk.user_input('W', 'down', 'enter', 'q')
+ rk.force_update()
+ rk.compare_screenshot(
+ 'filter-stopped-or-paused',
+ 'Only stopped or paused workflows should be visible'
+ ' (two, tre)',
+ )
+
+ # reset the state filters
+ rk.user_input('W', 'down', 'down', 'enter', 'down', 'enter')
+
+ # scroll to the id filter text box
+ rk.user_input('down', 'down', 'down', 'down')
+
+ # scroll to the end of the ID
+ rk.user_input(*['right'] * (
+ len(schd_1.tokens['workflow'].rsplit('/', 1)[0]) + 1)
+ )
+
+ # type the letter "t"
+ # (this should filter for workflows starting with "t")
+ rk.user_input('t')
+ rk.force_update() # this is required for the tests
+ rk.user_input('page up', 'q') # close the dialogue
+
+ rk.compare_screenshot(
+ 'filter-starts-with-t',
+ 'Only workflows starting with the letter "t" should be'
+ ' visible (two, tre)',
+ )
+
+
+# TODO: Task state filtering is currently broken
+# see: https://github.com/cylc/cylc-flow/issues/5716
+#
+# async def test_task_states(flow, scheduler, start, raikura):
+# id_ = flow({
+# 'scheduler': {
+# 'allow implicit tasks': 'true',
+# },
+# 'scheduling': {
+# 'initial cycle point': '1',
+# 'cycling mode': 'integer',
+# 'runahead limit': 'P1',
+# 'graph': {
+# 'P1': '''
+# a => b => c
+# b[-P1] => b
+# '''
+# }
+# }
+# }, name='test_task_states')
+# schd = scheduler(id_)
+# async with start(schd):
+# set_task_state(
+# schd,
+# [
+# (IntegerPoint('1'), 'a', TASK_STATUS_SUCCEEDED, False),
+# # (IntegerPoint('1'), 'b', TASK_STATUS_FAILED, False),
+# (IntegerPoint('1'), 'c', TASK_STATUS_RUNNING, False),
+# # (IntegerPoint('2'), 'a', TASK_STATUS_RUNNING, False),
+# (IntegerPoint('2'), 'b', TASK_STATUS_WAITING, True),
+# ]
+# )
+# await schd.update_data_structure()
+#
+# with raikura(schd.tokens.id, size='80,20') as rk:
+# rk.compare_screenshot('unfiltered')
+#
+# # filter out waiting tasks
+# rk.user_input('T', 'down', 'enter', 'q')
+# rk.compare_screenshot('filter-not-waiting')
+
+
+async def test_navigation(flow, scheduler, start, raikura):
+ """Test navigating with the arrow keys."""
+ id_ = flow({
+ 'scheduling': {
+ 'graph': {
+ 'R1': 'A & B1 & B2',
+ }
+ },
+ 'runtime': {
+ 'A': {},
+ 'B': {},
+ 'B1': {'inherit': 'B'},
+ 'B2': {'inherit': 'B'},
+ 'a1': {'inherit': 'A'},
+ 'a2': {'inherit': 'A'},
+ 'b11': {'inherit': 'B1'},
+ 'b12': {'inherit': 'B1'},
+ 'b21': {'inherit': 'B2'},
+ 'b22': {'inherit': 'B2'},
+ }
+ }, name='one')
+ schd = scheduler(id_)
+ async with start(schd):
+ await schd.update_data_structure()
+
+ with raikura(size='80,30') as rk:
+ # wait for the workflow to appear (collapsed)
+ rk.wait_until_loaded('#spring')
+
+ rk.compare_screenshot(
+ 'on-load',
+ 'the workflow should be collapsed when Tui is loaded',
+ )
+
+ # pressing "right" should connect to the workflow
+ # and expand it once the data arrives
+ rk.user_input('down', 'right')
+ rk.wait_until_loaded(schd.tokens.id)
+ rk.compare_screenshot(
+ 'workflow-expanded',
+ 'the workflow should be expanded',
+ )
+
+ # pressing "left" should collapse the node
+ rk.user_input('down', 'down', 'left')
+ rk.compare_screenshot(
+ 'family-A-collapsed',
+ 'the family "1/A" should be collapsed',
+ )
+
+ # the "page up" and "page down" buttons should navigate to the top
+ # and bottom of the screen
+ rk.user_input('page down')
+ rk.compare_screenshot(
+ 'cursor-at-bottom-of-screen',
+ 'the cursor should be at the bottom of the screen',
+ )
+
+
+async def test_auto_expansion(flow, scheduler, start, raikura):
+ """It should automatically expand cycles and top-level families.
+
+ When a workflow is expanded, Tui should auto expand cycles and top-level
+ families. Any new cycles and top-level families should be auto-expanded
+ when added.
+ """
+ id_ = flow({
+ 'scheduling': {
+ 'runahead limit': 'P1',
+ 'initial cycle point': '1',
+ 'cycling mode': 'integer',
+ 'graph': {
+ 'P1': 'b[-P1] => a => b'
+ },
+ },
+ 'runtime': {
+ 'A': {},
+ 'a': {'inherit': 'A'},
+ 'b': {},
+ },
+ }, name='one')
+ schd = scheduler(id_)
+ with raikura(size='80,20') as rk:
+ async with start(schd):
+ await schd.update_data_structure()
+ # wait for the workflow to appear (collapsed)
+ rk.wait_until_loaded('#spring')
+
+ # open the workflow
+ rk.force_update()
+ rk.user_input('down', 'right')
+ rk.wait_until_loaded(schd.tokens.id)
+
+ rk.compare_screenshot(
+ 'on-load',
+ 'cycle "1" and top-level family "1/A" should be expanded',
+ )
+
+ for task in ('a', 'b'):
+ itask = schd.pool.get_task(IntegerPoint('1'), task)
+ itask.state_reset(TASK_STATUS_SUCCEEDED)
+ schd.pool.spawn_on_output(itask, TASK_STATUS_SUCCEEDED)
+ await schd.update_data_structure()
+
+ rk.compare_screenshot(
+ 'later-time',
+ 'cycle "2" and top-level family "2/A" should be expanded',
+ )
+
+
+async def test_restart_reconnect(one_conf, flow, scheduler, start, raikura):
+ """It should handle workflow shutdown and restart.
+
+ The Cylc client can raise exceptions e.g. WorkflowStopped. Any text written
+ to stdout/err will mess with Tui. The purpose of this test is to ensure Tui
+ can handle shutdown / restart without any errors occuring and any spurious
+ text appearing on the screen.
+ """
+ with raikura(size='80,20') as rk:
+ schd = scheduler(flow(one_conf, name='one'))
+
+ # 1- start the workflow
+ async with start(schd):
+ await schd.update_data_structure()
+ # wait for the workflow to appear (collapsed)
+ rk.wait_until_loaded('#spring')
+
+ # expand the workflow (subscribes to updates from it)
+ rk.force_update()
+ rk.user_input('down', 'right')
+
+ # wait for workflow to appear (expanded)
+ rk.wait_until_loaded(schd.tokens.id)
+ rk.compare_screenshot(
+ '1-workflow-running',
+ 'the workflow should appear in tui and be expanded',
+ )
+
+ # 2 - stop the worlflow
+ rk.compare_screenshot(
+ '2-workflow-stopped',
+ 'the stopped workflow should be collapsed with a message saying'
+ ' workflow stopped',
+ )
+
+ # 3- restart the workflow
+ schd = scheduler(flow(one_conf, name='one'))
+ async with start(schd):
+ await schd.update_data_structure()
+ rk.wait_until_loaded(schd.tokens.id)
+ rk.compare_screenshot(
+ '3-workflow-restarted',
+ 'the restarted workflow should be expanded',
+ )
diff --git a/tests/integration/tui/test_logs.py b/tests/integration/tui/test_logs.py
new file mode 100644
index 00000000000..66531a1e42d
--- /dev/null
+++ b/tests/integration/tui/test_logs.py
@@ -0,0 +1,363 @@
+#!/usr/bin/env python3
+# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE.
+# Copyright (C) NIWA & British Crown (Met Office) & Contributors.
+#
+# 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 asyncio
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from cylc.flow.cycling.integer import IntegerPoint
+from cylc.flow.exceptions import ClientError
+from cylc.flow.task_job_logs import get_task_job_log
+from cylc.flow.task_state import (
+ TASK_STATUS_FAILED,
+ TASK_STATUS_SUCCEEDED,
+)
+from cylc.flow.tui.data import _get_log
+
+import pytest
+
+if TYPE_CHECKING:
+ from cylc.flow.id import Tokens
+
+
+def get_job_log(tokens: 'Tokens', suffix: str) -> Path:
+ """Return the path to a job log file.
+
+ Args:
+ tokens: Job tokens.
+ suffix: Filename.
+
+ """
+ return Path(get_task_job_log(
+ tokens['workflow'],
+ tokens['cycle'],
+ tokens['task'],
+ tokens['job'],
+ suffix=suffix,
+ ))
+
+
+@pytest.fixture(scope='module')
+def standarise_host_and_path(mod_monkeypatch):
+ """Replace variable content in the log view.
+
+ The log view displays the "Host" and "Path" of the log file. These will
+ differer from user to user, so we mock away the difference to produce
+ stable results.
+ """
+ def _parse_log_header(contents):
+ _header, text = contents.split('\n', 1)
+ return 'myhost', 'mypath', text
+
+ mod_monkeypatch.setattr(
+ 'cylc.flow.tui.data._parse_log_header',
+ _parse_log_header,
+ )
+
+
+@pytest.fixture
+def wait_log_loaded(monkeypatch):
+ """Wait for Tui to successfully open a log file."""
+ # previous log open count
+ before = 0
+ # live log open count
+ count = 0
+
+ # wrap the Tui "_get_log" method to count the number of times it has
+ # returned
+ def __get_log(*args, **kwargs):
+ nonlocal count
+ try:
+ ret = _get_log(*args, **kwargs)
+ except ClientError as exc:
+ count += 1
+ raise exc
+ count += 1
+ return ret
+ monkeypatch.setattr(
+ 'cylc.flow.tui.data._get_log',
+ __get_log,
+ )
+
+ async def _wait_log_loaded(tries: int = 25, delay: float = 0.1):
+ """Wait for the log file to be loaded.
+
+ Args:
+ tries: The number of (re)tries to attempt before failing.
+ delay: The delay between retries.
+
+ """
+ nonlocal before, count
+ for _try in range(tries):
+ if count > before:
+ await asyncio.sleep(0)
+ before += 1
+ return
+ await asyncio.sleep(delay)
+ raise Exception(f'Log file was not loaded within {delay * tries}s')
+
+ return _wait_log_loaded
+
+
+@pytest.fixture(scope='module')
+async def workflow(mod_flow, mod_scheduler, mod_start, standarise_host_and_path):
+ """Test fixture providing a workflow with some log files to poke at."""
+ id_ = mod_flow({
+ 'scheduling': {
+ 'graph': {
+ 'R1': 'a',
+ }
+ },
+ 'runtime': {
+ 'a': {},
+ }
+ }, name='one')
+ schd = mod_scheduler(id_)
+ async with mod_start(schd):
+ # create some log files for tests to inspect
+
+ # create a scheduler log
+ # (note the scheduler log doesn't get created in integration tests)
+ scheduler_log = Path(schd.workflow_log_dir, '01-start-01.log')
+ with open(scheduler_log, 'w+') as logfile:
+ logfile.write('this is the\nscheduler log file')
+
+ # task 1/a
+ itask = schd.pool.get_task(IntegerPoint('1'), 'a')
+ itask.submit_num = 2
+
+ # mark 1/a/01 as failed
+ job_1 = schd.tokens.duplicate(cycle='1', task='a', job='01')
+ schd.data_store_mgr.insert_job(
+ 'a',
+ IntegerPoint('1'),
+ TASK_STATUS_SUCCEEDED,
+ {'submit_num': 1, 'platform': {'name': 'x'}}
+ )
+ schd.data_store_mgr.delta_job_state(job_1, TASK_STATUS_FAILED)
+
+ # mark 1/a/02 as succeeded
+ job_2 = schd.tokens.duplicate(cycle='1', task='a', job='02')
+ schd.data_store_mgr.insert_job(
+ 'a',
+ IntegerPoint('1'),
+ TASK_STATUS_SUCCEEDED,
+ {'submit_num': 2, 'platform': {'name': 'x'}}
+ )
+ schd.data_store_mgr.delta_job_state(job_1, TASK_STATUS_SUCCEEDED)
+ schd.data_store_mgr.delta_task_state(itask)
+
+ # mark 1/a as succeeded
+ itask.state_reset(TASK_STATUS_SUCCEEDED)
+ schd.data_store_mgr.delta_task_state(itask)
+
+ # 1/a/01 - job.out
+ job_1_out = get_job_log(job_1, 'job.out')
+ job_1_out.parent.mkdir(parents=True)
+ with open(job_1_out, 'w+') as log:
+ log.write(f'job: {job_1.relative_id}\nthis is a job log\n')
+
+ # 1/a/02 - job.out
+ job_2_out = get_job_log(job_2, 'job.out')
+ job_2_out.parent.mkdir(parents=True)
+ with open(job_2_out, 'w+') as log:
+ log.write(f'job: {job_2.relative_id}\nthis is a job log\n')
+
+ # 1/a/02 - job.err
+ job_2_err = get_job_log(job_2, 'job.err')
+ with open(job_2_err, 'w+') as log:
+ log.write(f'job: {job_2.relative_id}\nthis is a job error\n')
+
+ # 1/a/NN -> 1/a/02
+ (job_2_out.parent.parent / 'NN').symlink_to(
+ (job_2_out.parent.parent / '02'),
+ target_is_directory=True,
+ )
+
+ # populate the data store
+ await schd.update_data_structure()
+
+ yield schd
+
+
+async def test_scheduler_logs(
+ workflow,
+ mod_raikura,
+ wait_log_loaded,
+):
+ """Test viewing the scheduler log files."""
+ with mod_raikura(size='80,30') as rk:
+ # wait for the workflow to appear (collapsed)
+ rk.wait_until_loaded('#spring')
+
+ # open the workflow in Tui
+ rk.user_input('down', 'right')
+ rk.wait_until_loaded(workflow.tokens.id)
+
+ # open the log view for the workflow
+ rk.user_input('enter')
+ rk.user_input('down', 'down', 'enter')
+
+ # wait for the default log file to load
+ await wait_log_loaded()
+ rk.compare_screenshot(
+ 'scheduler-log-file',
+ 'the scheduler log file should be open',
+ )
+
+ # open the list of log files
+ rk.user_input('enter')
+ rk.compare_screenshot(
+ 'log-file-selection',
+ 'the list of available log files should be displayed'
+ )
+
+ # select the processed workflow configuration file
+ rk.user_input('down', 'enter')
+
+ # wait for the file to load
+ await wait_log_loaded()
+ rk.compare_screenshot(
+ 'workflow-configuration-file',
+ 'the workflow configuration file should be open'
+ )
+
+
+async def test_task_logs(
+ workflow,
+ mod_raikura,
+ wait_log_loaded,
+):
+ """Test viewing task log files.
+
+ I.E. Test viewing job log files by opening the log view on a task.
+ """
+ with mod_raikura(size='80,30') as rk:
+ # wait for the workflow to appear (collapsed)
+ rk.wait_until_loaded('#spring')
+
+ # open the workflow in Tui
+ rk.user_input('down', 'right')
+ rk.wait_until_loaded(workflow.tokens.id)
+
+ # open the context menu for the task 1/a
+ rk.user_input('down', 'down', 'enter')
+
+ # open the log view for the task 1/a
+ rk.user_input('down', 'down', 'down', 'enter')
+
+ # wait for the default log file to load
+ await wait_log_loaded()
+ rk.compare_screenshot(
+ 'latest-job.out',
+ 'the job.out file for the second job should be open',
+ )
+
+ rk.user_input('enter')
+ rk.user_input('enter')
+
+ # wait for the job.err file to load
+ await wait_log_loaded()
+ rk.compare_screenshot(
+ 'latest-job.err',
+ 'the job.out file for the second job should be open',
+ )
+
+
+async def test_job_logs(
+ workflow,
+ mod_raikura,
+ wait_log_loaded,
+):
+ """Test viewing the job log files.
+
+ I.E. Test viewing job log files by opening the log view on a job.
+ """
+ with mod_raikura(size='80,30') as rk:
+ # wait for the workflow to appear (collapsed)
+ rk.wait_until_loaded('#spring')
+
+ # open the workflow in Tui
+ rk.user_input('down', 'right')
+ rk.wait_until_loaded(workflow.tokens.id)
+
+ # open the context menu for the job 1/a/02
+ rk.user_input('down', 'down', 'right', 'down', 'enter')
+
+ # open the log view for the job 1/a/02
+ rk.user_input('down', 'down', 'down', 'enter')
+
+ # wait for the default log file to load
+ await wait_log_loaded()
+ rk.compare_screenshot(
+ '02-job.out',
+ 'the job.out file for the *second* job should be open',
+ )
+
+ # close log view
+ rk.user_input('q')
+
+ # open the log view for the job 1/a/01
+ rk.user_input('down', 'enter')
+ rk.user_input('down', 'down', 'down', 'enter')
+
+ # wait for the default log file to load
+ await wait_log_loaded()
+ rk.compare_screenshot(
+ '01-job.out',
+ 'the job.out file for the *first* job should be open',
+ )
+
+
+async def test_errors(
+ workflow,
+ mod_raikura,
+ wait_log_loaded,
+ monkeypatch,
+):
+ """Test error handing of cat-log commands."""
+ # make it look like cat-log commands are failing
+ def cli_cmd_fail(*args, **kwargs):
+ raise ClientError('Something went wrong :(')
+
+ monkeypatch.setattr(
+ 'cylc.flow.tui.data.cli_cmd',
+ cli_cmd_fail,
+ )
+
+ with mod_raikura(size='80,30') as rk:
+ # wait for the workflow to appear (collapsed)
+ rk.wait_until_loaded('#spring')
+
+ # open the log view on scheduler
+ rk.user_input('down', 'enter', 'down', 'down', 'enter')
+
+ # it will fail to open
+ await wait_log_loaded()
+ rk.compare_screenshot(
+ 'open-error',
+ 'the error message should be displayed in the log view header',
+ )
+
+ # open the file selector
+ rk.user_input('enter')
+
+ # it will fail to list avialable log files
+ rk.compare_screenshot(
+ 'list-error',
+ 'the error message should be displayed in a pop up',
+ )
diff --git a/tests/integration/tui/test_mutations.py b/tests/integration/tui/test_mutations.py
new file mode 100644
index 00000000000..659c74ac1d0
--- /dev/null
+++ b/tests/integration/tui/test_mutations.py
@@ -0,0 +1,216 @@
+#!/usr/bin/env python3
+# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE.
+# Copyright (C) NIWA & British Crown (Met Office) & Contributors.
+#
+# 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 asyncio
+
+import pytest
+
+from cylc.flow.exceptions import ClientError
+
+
+async def gen_commands(schd):
+ """Yield commands from the scheduler's command queue."""
+ while True:
+ await asyncio.sleep(0.1)
+ if not schd.command_queue.empty():
+ yield schd.command_queue.get()
+
+
+async def test_online_mutation(
+ one_conf,
+ flow,
+ scheduler,
+ start,
+ raikura,
+ monkeypatch,
+):
+ """Test a simple workflow with one task."""
+ id_ = flow(one_conf, name='one')
+ schd = scheduler(id_)
+ with raikura(size='80,15') as rk:
+ async with start(schd):
+ await schd.update_data_structure()
+ assert schd.command_queue.empty()
+
+ # open the workflow
+ rk.force_update()
+ rk.user_input('down', 'right')
+ rk.wait_until_loaded(schd.tokens.id)
+
+ # focus on a task
+ rk.user_input('down', 'right', 'down', 'right')
+ rk.compare_screenshot(
+ # take a screenshot to ensure we have focused on the task
+ # successfully
+ 'task-selected',
+ 'the cursor should be on the task 1/foo',
+ )
+
+ # focus on the hold mutation for a task
+ rk.user_input('enter', 'down')
+ rk.compare_screenshot(
+ # take a screenshot to ensure we have focused on the mutation
+ # successfully
+ 'hold-mutation-selected',
+ 'the cursor should be on the "hold" mutation',
+ )
+
+ # run the hold mutation
+ rk.user_input('enter')
+
+ # the mutation should be in the scheduler's command_queue
+ command = None
+ async for command in gen_commands(schd):
+ break
+ assert command == ('hold', (['1/one'],), {})
+
+ # close the dialogue and re-run the hold mutation
+ rk.user_input('q', 'q', 'enter')
+ rk.compare_screenshot(
+ 'command-failed-workflow-stopped',
+ 'an error should be visible explaining that the operation'
+ ' cannot be performed on a stopped workflow',
+ # NOTE: don't update so Tui still thinks the workflow is running
+ force_update=False,
+ )
+
+ # force mutations to raise ClientError
+ def _get_client(*args, **kwargs):
+ raise ClientError('mock error')
+ monkeypatch.setattr(
+ 'cylc.flow.tui.data.get_client',
+ _get_client,
+ )
+
+ # close the dialogue and re-run the hold mutation
+ rk.user_input('q', 'q', 'enter')
+ rk.compare_screenshot(
+ 'command-failed-client-error',
+ 'an error should be visible explaining that the operation'
+ ' failed due to a client error',
+ # NOTE: don't update so Tui still thinks the workflow is running
+ force_update=False,
+ )
+
+
+@pytest.fixture
+def standardise_cli_cmds(monkeypatch):
+ """This remove the variable bit of the workflow ID from CLI commands.
+
+ The workflow ID changes from run to run. In order to make screenshots
+ stable, this
+ """
+ from cylc.flow.tui.data import extract_context
+ def _extract_context(selection):
+ context = extract_context(selection)
+ if 'workflow' in context:
+ context['workflow'] = [
+ workflow.rsplit('/', 1)[-1]
+ for workflow in context.get('workflow', [])
+ ]
+ return context
+ monkeypatch.setattr(
+ 'cylc.flow.tui.data.extract_context',
+ _extract_context,
+ )
+
+@pytest.fixture
+def capture_commands(monkeypatch):
+ ret = []
+ returncode = [0]
+
+ class _Popen:
+ def __init__(self, *args, **kwargs):
+ nonlocal ret
+ ret.append(args)
+
+ def communicate(self):
+ return 'mock-stdout', 'mock-stderr'
+
+ @property
+ def returncode(self):
+ nonlocal returncode
+ return returncode[0]
+
+ monkeypatch.setattr(
+ 'cylc.flow.tui.data.Popen',
+ _Popen,
+ )
+
+ return ret, returncode
+
+
+async def test_offline_mutation(
+ one_conf,
+ flow,
+ raikura,
+ capture_commands,
+ standardise_cli_cmds,
+):
+ id_ = flow(one_conf, name='one')
+ commands, returncode = capture_commands
+
+ with raikura(size='80,15') as rk:
+ # run the stop-all mutation
+ rk.wait_until_loaded('root')
+ rk.user_input('enter', 'down')
+ rk.compare_screenshot(
+ # take a screenshot to ensure we have focused on the task
+ # successfully
+ 'stop-all-mutation-selected',
+ 'the stop-all mutation should be selected',
+ )
+ rk.user_input('enter')
+
+ # the command "cylc stop '*'" should have been run
+ assert commands == [(['cylc', 'stop', '*'],)]
+ commands.clear()
+
+ # run the clean command on the workflow
+ rk.user_input('down', 'enter', 'down')
+ rk.compare_screenshot(
+ # take a screenshot to ensure we have focused on the mutation
+ # successfully
+ 'clean-mutation-selected',
+ 'the clean mutation should be selected',
+ )
+ rk.user_input('enter')
+
+ # the command "cylc clean " should have been run
+ assert commands == [(['cylc', 'clean', '--yes', 'one'],)]
+ commands.clear()
+
+ # make commands fail
+ returncode[:] = [1]
+ rk.user_input('enter', 'down')
+ rk.compare_screenshot(
+ # take a screenshot to ensure we have focused on the mutation
+ # successfully
+ 'clean-mutation-selected',
+ 'the clean mutation should be selected',
+ )
+ rk.user_input('enter')
+
+ assert commands == [(['cylc', 'clean', '--yes', 'one'],)]
+
+ rk.compare_screenshot(
+ # take a screenshot to ensure we have focused on the mutation
+ # successfully
+ 'clean-command-error',
+ 'there should be a box displaying the error containing the stderr'
+ ' returned by the command',
+ )
diff --git a/tests/integration/tui/test_show.py b/tests/integration/tui/test_show.py
new file mode 100644
index 00000000000..ac6aa8532a6
--- /dev/null
+++ b/tests/integration/tui/test_show.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE.
+# Copyright (C) NIWA & British Crown (Met Office) & Contributors.
+#
+# 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 cylc.flow.exceptions import ClientError
+from cylc.flow.tui.data import _show
+
+
+async def test_show(flow, scheduler, start, raikura, monkeypatch):
+ """Test "cylc show" support in Tui."""
+ id_ = flow({
+ 'scheduling': {
+ 'graph': {
+ 'R1': 'foo'
+ },
+ },
+ 'runtime': {
+ 'foo': {
+ 'meta': {
+ 'title': 'Foo',
+ 'description': 'The first metasyntactic variable.'
+ },
+ },
+ },
+ }, name='one')
+ schd = scheduler(id_)
+ async with start(schd):
+ await schd.update_data_structure()
+
+ with raikura(size='80,40') as rk:
+ rk.user_input('down', 'right')
+ rk.wait_until_loaded(schd.tokens.id)
+
+ # select a task
+ rk.user_input('down', 'down', 'enter')
+
+ # select the "show" context option
+ rk.user_input(*(['down'] * 6), 'enter')
+ rk.compare_screenshot(
+ 'success',
+ 'the show output should be displayed',
+ )
+
+ # make it look like "cylc show" failed
+ def cli_cmd_fail(*args, **kwargs):
+ raise ClientError(':(')
+ monkeypatch.setattr(
+ 'cylc.flow.tui.data.cli_cmd',
+ cli_cmd_fail,
+ )
+
+ # select the "show" context option
+ rk.user_input('q', 'enter', *(['down'] * 6), 'enter')
+ rk.compare_screenshot(
+ 'fail',
+ 'the error should be displayed',
+ )
diff --git a/tests/integration/tui/test_updater.py b/tests/integration/tui/test_updater.py
new file mode 100644
index 00000000000..b3daac5a328
--- /dev/null
+++ b/tests/integration/tui/test_updater.py
@@ -0,0 +1,225 @@
+#!/usr/bin/env python3
+# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE.
+# Copyright (C) NIWA & British Crown (Met Office) & Contributors.
+#
+# 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 copy import deepcopy
+from pathlib import Path
+from queue import Queue
+import re
+
+from async_timeout import timeout
+import pytest
+
+from cylc.flow.cycling.integer import IntegerPoint
+from cylc.flow.id import Tokens
+from cylc.flow.tui.updater import (
+ Updater,
+ get_default_filters,
+)
+from cylc.flow.workflow_status import WorkflowStatus
+
+
+@pytest.fixture
+def updater(monkeypatch, test_dir):
+ """Return an updater ready for testing."""
+ # patch the update intervals so that everything runs for every update
+ monkeypatch.setattr(
+ 'cylc.flow.tui.updater.Updater.BASE_UPDATE_INTERVAL',
+ 0,
+ )
+ monkeypatch.setattr(
+ 'cylc.flow.tui.updater.Updater.BASE_SCAN_INTERVAL',
+ 0,
+ )
+
+ # create the updater
+ updater = Updater()
+
+ # swap multiprocessing.Queue for queue.Queue
+ # (this means queued operations are instant making tests more stable)
+ updater.update_queue = Queue()
+ updater._command_queue = Queue()
+
+ # set up the filters
+ # (these filter for the workflows created in this test only)
+ filters = get_default_filters()
+ id_base = str(test_dir.relative_to(Path("~/cylc-run").expanduser()))
+ filters['workflows']['id'] = f'^{re.escape(id_base)}/.*'
+ updater._update_filters(filters)
+
+ return updater
+
+
+def get_child_tokens(root_node, types, relative=False):
+ """Return all ID of the specified types contained within the provided tree.
+
+ Args:
+ root_node:
+ The Tui tree you want to look for IDs in.
+ types:
+ The Tui types (e.g. 'workflow' or 'task') you want to extract.
+ relative:
+ If True, the relative IDs will be returned.
+
+ """
+ ret = set()
+ stack = [root_node]
+ while stack:
+ node = stack.pop()
+ stack.extend(node['children'])
+ if node['type_'] in types:
+
+ tokens = Tokens(node['id_'])
+ if relative:
+ ret.add(tokens.relative_id)
+ else:
+ ret.add(tokens.id)
+ return ret
+
+
+async def test_subscribe(one_conf, flow, scheduler, run, updater):
+ """It should subscribe and unsubscribe from workflows."""
+ id_ = flow(one_conf)
+ schd = scheduler(id_)
+
+ async with run(schd):
+ # run the updater and the test
+ async with timeout(10):
+ # wait for the first update
+ root_node = await updater._update()
+
+ # there should be a root root_node
+ assert root_node['id_'] == 'root'
+ # a single root_node representing the workflow
+ assert root_node['children'][0]['id_'] == schd.tokens.id
+ # and a "spring" root_node used to active the subscription
+ # mechanism
+ assert root_node['children'][0]['children'][0]['id_'] == '#spring'
+
+ # subscribe to the workflow
+ updater.subscribe(schd.tokens.id)
+ root_node = await updater._update()
+
+ # check the workflow contains one cycle with one task in it
+ workflow_node = root_node['children'][0]
+ assert len(workflow_node['children']) == 1
+ cycle_node = workflow_node['children'][0]
+ assert Tokens(cycle_node['id_']).relative_id == '1' # cycle ID
+ assert len(cycle_node['children']) == 1
+ task_node = cycle_node['children'][0]
+ assert Tokens(task_node['id_']).relative_id == '1/one' # task ID
+
+ # unsubscribe from the workflow
+ updater.unsubscribe(schd.tokens.id)
+ root_node = await updater._update()
+
+ # the workflow should be replaced by a "spring" node again
+ assert root_node['children'][0]['children'][0]['id_'] == '#spring'
+
+
+async def test_filters(one_conf, flow, scheduler, run, updater):
+ """It should filter workflow and task states.
+
+ Note:
+ The workflow ID filter is not explicitly tested here, but it is
+ indirectly tested, otherwise other workflows would show up in the
+ updater results.
+
+ """
+ one = scheduler(flow({
+ 'scheduler': {
+ 'allow implicit tasks': 'True',
+ },
+ 'scheduling': {
+ 'graph': {
+ 'R1': 'a & b & c',
+ }
+ }
+ }, name='one'), paused_start=True)
+ two = scheduler(flow(one_conf, name='two'))
+ tre = scheduler(flow(one_conf, name='tre'))
+
+ # start workflow "one"
+ async with run(one):
+ # mark "1/a" as running and "1/b" as succeeded
+ one_a = one.pool.get_task(IntegerPoint('1'), 'a')
+ one_a.state_reset('running')
+ one.data_store_mgr.delta_task_state(one_a)
+ one.pool.get_task(IntegerPoint('1'), 'b').state_reset('succeeded')
+
+ # start workflow "two"
+ async with run(two):
+ # run the updater and the test
+ filters = deepcopy(updater.filters)
+
+ root_node = await updater._update()
+ assert {child['id_'] for child in root_node['children']} == {
+ one.tokens.id,
+ two.tokens.id,
+ tre.tokens.id,
+ }
+
+ # filter out paused workflows
+ filters = deepcopy(filters)
+ filters['workflows'][WorkflowStatus.STOPPED.value] = True
+ filters['workflows'][WorkflowStatus.PAUSED.value] = False
+ updater.update_filters(filters)
+
+ # "one" and "two" should now be filtered out
+ root_node = await updater._update()
+ assert {child['id_'] for child in root_node['children']} == {
+ tre.tokens.id,
+ }
+
+ # filter out stopped workflows
+ filters = deepcopy(filters)
+ filters['workflows'][WorkflowStatus.STOPPED.value] = False
+ filters['workflows'][WorkflowStatus.PAUSED.value] = True
+ updater.update_filters(filters)
+
+ # "tre" should now be filtered out
+ root_node = await updater._update()
+ assert {child['id_'] for child in root_node['children']} == {
+ one.tokens.id,
+ two.tokens.id,
+ }
+
+ # subscribe to "one"
+ updater._subscribe(one.tokens.id)
+ root_node = await updater._update()
+ assert get_child_tokens(
+ root_node, types={'task'}, relative=True
+ ) == {
+ '1/a',
+ '1/b',
+ '1/c',
+ }
+
+ # filter out running tasks
+ # TODO: see https://github.com/cylc/cylc-flow/issues/5716
+ # filters = deepcopy(filters)
+ # filters['tasks'][TASK_STATUS_RUNNING] = False
+ # updater.update_filters(filters)
+
+ # root_node = await updater._update()
+ # assert get_child_tokens(
+ # root_node,
+ # types={'task'},
+ # relative=True
+ # ) == {
+ # '1/b',
+ # '1/c',
+ # }
diff --git a/tests/unit/scripts/test_cylc.py b/tests/unit/scripts/test_cylc.py
index 819583a296c..9928024bb66 100644
--- a/tests/unit/scripts/test_cylc.py
+++ b/tests/unit/scripts/test_cylc.py
@@ -133,18 +133,16 @@ def test_pythonpath_manip(monkeypatch):
and adds items from CYLC_PYTHONPATH
"""
- # If PYTHONPATH is set...
- monkeypatch.setenv('PYTHONPATH', '/remove-from-sys.path')
- monkeypatch.setattr('sys.path', ['/leave-alone', '/remove-from-sys.path'])
+ monkeypatch.setenv('PYTHONPATH', '/remove1:/remove2')
+ monkeypatch.setattr('sys.path', ['/leave-alone', '/remove1', '/remove2'])
pythonpath_manip()
# ... we don't change PYTHONPATH
- assert os.environ['PYTHONPATH'] == '/remove-from-sys.path'
+ assert os.environ['PYTHONPATH'] == '/remove1:/remove2'
# ... but we do remove PYTHONPATH items from sys.path, and don't remove
# items there not in PYTHONPATH
assert sys.path == ['/leave-alone']
-
# If CYLC_PYTHONPATH is set we retrieve its contents and
# add them to the sys.path:
- monkeypatch.setenv('CYLC_PYTHONPATH', '/add-to-sys.path')
+ monkeypatch.setenv('CYLC_PYTHONPATH', '/add1:/add2')
pythonpath_manip()
- assert sys.path == ['/add-to-sys.path', '/leave-alone']
+ assert sys.path == ['/add1', '/add2', '/leave-alone']
diff --git a/tests/unit/scripts/test_lint.py b/tests/unit/scripts/test_lint.py
index d33fca7efc1..6ee1018cf96 100644
--- a/tests/unit/scripts/test_lint.py
+++ b/tests/unit/scripts/test_lint.py
@@ -102,6 +102,7 @@
pre-script = "echo ${CYLC_SUITE_DEF_PATH}"
script = {{HELLOWORLD}}
post-script = "echo ${CYLC_SUITE_INITIAL_CYCLE_TIME}"
+ env-script = POINT=$(rose date 2059 --offset P1M)
[[[suite state polling]]]
template = and
[[[remote]]]
@@ -333,6 +334,12 @@ def test_check_cylc_file_jinja2_comments():
assert not any('S011' in msg for msg in lint.messages)
+def test_check_cylc_file_jinja2_comments_shell_arithmetic_not_warned():
+ """Jinja2 after a $((10#$variable)) should not warn"""
+ lint = lint_text('#!jinja2\na = b$((10#$foo+5)) {{ BAR }}', ['style'])
+ assert not any('S011' in msg for msg in lint.messages)
+
+
@pytest.mark.parametrize(
# 11 won't be tested because there is no jinja2 shebang
'number', set(range(1, len(MANUAL_DEPRECATIONS) + 1)) - {11}
diff --git a/tests/unit/test_async_util.py b/tests/unit/test_async_util.py
index 17823817dbe..56373e7185d 100644
--- a/tests/unit/test_async_util.py
+++ b/tests/unit/test_async_util.py
@@ -15,6 +15,7 @@
# along with this program. If not, see .
import asyncio
+from inspect import signature
import logging
from pathlib import Path
from random import random
@@ -209,14 +210,17 @@ def test_pipe_brackets():
@pipe
-async def documented(x):
+async def documented(x: str, y: int = 0):
"""The docstring for the pipe function."""
pass
def test_documentation():
- """It should preserve the docstring of pipe functions."""
+ """It should preserve the docstring, signature & annotations of
+ the wrapped function."""
assert documented.__doc__ == 'The docstring for the pipe function.'
+ assert documented.__annotations__ == {'x': str, 'y': int}
+ assert str(signature(documented)) == '(x: str, y: int = 0)'
def test_rewind():
diff --git a/tests/unit/tui/test_data.py b/tests/unit/tui/test_data.py
index a2d17bf2e76..85805a5d1ea 100644
--- a/tests/unit/tui/test_data.py
+++ b/tests/unit/tui/test_data.py
@@ -28,7 +28,7 @@ def test_generate_mutation(monkeypatch):
monkeypatch.setattr(cylc.flow.tui.data, 'ARGUMENT_TYPES', arg_types)
assert generate_mutation(
'my_mutation',
- ['foo', 'bar']
+ {'foo': 'foo', 'bar': 'bar', 'user': 'user'}
) == '''
mutation($foo: String!, $bar: [Int]) {
my_mutation (foos: $foo, bars: $bar) {
diff --git a/tests/unit/tui/test_overlay.py b/tests/unit/tui/test_overlay.py
index 42334aac009..013e8480c21 100644
--- a/tests/unit/tui/test_overlay.py
+++ b/tests/unit/tui/test_overlay.py
@@ -21,7 +21,9 @@
import pytest
import urwid
+from cylc.flow.tui.app import BINDINGS
import cylc.flow.tui.overlay
+from cylc.flow.workflow_status import WorkflowStatus
@pytest.fixture
@@ -39,6 +41,7 @@ def overlay_functions():
getattr(cylc.flow.tui.overlay, obj.name)
for obj in tree.body
if isinstance(obj, ast.FunctionDef)
+ and not obj.name.startswith('_')
]
@@ -47,14 +50,21 @@ def test_interface(overlay_functions):
for function in overlay_functions:
# mock up an app object to keep things working
app = Mock(
- filter_states={},
+ filters={'tasks': {}, 'workflows': {'id': '.*'}},
+ bindings=BINDINGS,
tree_walker=Mock(
get_focus=Mock(
return_value=[
Mock(
get_node=Mock(
return_value=Mock(
- get_value=lambda: {'id_': 'a'}
+ get_value=lambda: {
+ 'id_': '~u/a',
+ 'type_': 'workflow',
+ 'data': {
+ 'status': WorkflowStatus.RUNNING,
+ },
+ }
)
)
)
diff --git a/tests/unit/tui/test_util.py b/tests/unit/tui/test_util.py
index 00ac9fa95be..2b3231e0f7e 100644
--- a/tests/unit/tui/test_util.py
+++ b/tests/unit/tui/test_util.py
@@ -189,77 +189,87 @@ def test_compute_tree():
"""
tree = compute_tree({
- 'id': 'workflow id',
- 'cyclePoints': [
- {
- 'id': '1/family-suffix',
- 'cyclePoint': '1'
- }
- ],
- 'familyProxies': [
- { # top level family
- 'name': 'FOO',
- 'id': '1/FOO',
- 'cyclePoint': '1',
- 'firstParent': {'name': 'root', 'id': '1/root'}
- },
- { # nested family
- 'name': 'FOOT',
- 'id': '1/FOOT',
- 'cyclePoint': '1',
- 'firstParent': {'name': 'FOO', 'id': '1/FOO'}
- },
- ],
- 'taskProxies': [
- { # top level task
- 'name': 'pub',
- 'id': '1/pub',
- 'firstParent': {'name': 'root', 'id': '1/root'},
- 'cyclePoint': '1',
- 'jobs': []
- },
- { # child task (belongs to family)
- 'name': 'fan',
- 'id': '1/fan',
- 'firstParent': {'name': 'fan', 'id': '1/fan'},
- 'cyclePoint': '1',
- 'jobs': []
- },
- { # nested child task (belongs to incestuous family)
- 'name': 'fool',
- 'id': '1/fool',
- 'firstParent': {'name': 'FOOT', 'id': '1/FOOT'},
- 'cyclePoint': '1',
- 'jobs': []
- },
- { # a task which has jobs
- 'name': 'worker',
- 'id': '1/worker',
- 'firstParent': {'name': 'root', 'id': '1/root'},
- 'cyclePoint': '1',
- 'jobs': [
- {'id': '1/worker/03', 'submitNum': '3'},
- {'id': '1/worker/02', 'submitNum': '2'},
- {'id': '1/worker/01', 'submitNum': '1'}
- ]
- }
- ]
+ 'workflows': [{
+ 'id': 'workflow id',
+ 'port': 1234,
+ 'cyclePoints': [
+ {
+ 'id': '1/family-suffix',
+ 'cyclePoint': '1'
+ }
+ ],
+ 'familyProxies': [
+ { # top level family
+ 'name': 'FOO',
+ 'id': '1/FOO',
+ 'cyclePoint': '1',
+ 'firstParent': {'name': 'root', 'id': '1/root'}
+ },
+ { # nested family
+ 'name': 'FOOT',
+ 'id': '1/FOOT',
+ 'cyclePoint': '1',
+ 'firstParent': {'name': 'FOO', 'id': '1/FOO'}
+ },
+ ],
+ 'taskProxies': [
+ { # top level task
+ 'name': 'pub',
+ 'id': '1/pub',
+ 'firstParent': {'name': 'root', 'id': '1/root'},
+ 'cyclePoint': '1',
+ 'jobs': []
+ },
+ { # child task (belongs to family)
+ 'name': 'fan',
+ 'id': '1/fan',
+ 'firstParent': {'name': 'fan', 'id': '1/fan'},
+ 'cyclePoint': '1',
+ 'jobs': []
+ },
+ { # nested child task (belongs to incestuous family)
+ 'name': 'fool',
+ 'id': '1/fool',
+ 'firstParent': {'name': 'FOOT', 'id': '1/FOOT'},
+ 'cyclePoint': '1',
+ 'jobs': []
+ },
+ { # a task which has jobs
+ 'name': 'worker',
+ 'id': '1/worker',
+ 'firstParent': {'name': 'root', 'id': '1/root'},
+ 'cyclePoint': '1',
+ 'jobs': [
+ {'id': '1/worker/03', 'submitNum': '3'},
+ {'id': '1/worker/02', 'submitNum': '2'},
+ {'id': '1/worker/01', 'submitNum': '1'}
+ ]
+ }
+ ]
+ }]
})
+ # the root node
+ assert tree['type_'] == 'root'
+ assert tree['id_'] == 'root'
+ assert len(tree['children']) == 1
+
# the workflow node
- assert tree['type_'] == 'workflow'
- assert tree['id_'] == 'workflow id'
- assert list(tree['data']) == [
+ workflow = tree['children'][0]
+ assert workflow['type_'] == 'workflow'
+ assert workflow['id_'] == 'workflow id'
+ assert set(workflow['data']) == {
# whatever if present on the node should end up in data
- 'id',
'cyclePoints',
'familyProxies',
+ 'id',
+ 'port',
'taskProxies'
- ]
- assert len(tree['children']) == 1
+ }
+ assert len(workflow['children']) == 1
# the cycle point node
- cycle = tree['children'][0]
+ cycle = workflow['children'][0]
assert cycle['type_'] == 'cycle'
assert cycle['id_'] == '//1'
assert list(cycle['data']) == [