diff --git a/docs.openc3.com/docs/guides/scripting-api.md b/docs.openc3.com/docs/guides/scripting-api.md index dcdfb0ab8e..ee4729fe4a 100644 --- a/docs.openc3.com/docs/guides/scripting-api.md +++ b/docs.openc3.com/docs/guides/scripting-api.md @@ -60,7 +60,7 @@ The following API methods have been removed from COSMOS v7. Since WITH_UNITS wer | wait_tolerance_raw | api_shared | Removed, use [wait_tolerance](#wait_tolerance) and pass type | | wait_check_tolerance_raw | api_shared | Removed, use [wait_check_tolerance](#wait_check_tolerance) and pass type | -The following API methods now return `COSMOS__CANCEL` instead of `Cancel` when the Cancel button is pushed in Script Runner: `ask`, `ask_string`, `message_box`, `vertical_message_box`, `combo_box`, `check_box`, `prompt`, `prompt_for_hazardous`, `prompt_for_critical_cmd`, `metadata_input`, `open_file_dialog`, `open_files_dialog`, `open_bucket_dialog`. Unless you are _explicitly_ checking the return value for the word 'Cancel' there are no changes required. +The following API methods now use `COSMOS__CANCEL` instead of `Cancel` as the answer recorded when the Cancel button is pushed in Script Runner: `ask`, `ask_string`, `message_box`, `vertical_message_box`, `combo_box`, `check_box`, `prompt`, `prompt_for_hazardous`, `prompt_for_critical_cmd`, `metadata_input`, `open_file_dialog`, `open_files_dialog`, `open_bucket_dialog`. The script is paused at the prompt line and nothing is returned to it, so no changes are required. `COSMOS__CANCEL` only appears in the script output and the Script Runner internals. ### Migration from COSMOS v5 to v6 @@ -175,7 +175,7 @@ These methods allow the user to enter values that are needed by the script. Since 5.0.0 -Prompts the user for input with a question. User input is automatically converted from a string to the appropriate data type. For example if the user enters "1", the number 1 as an integer will be returned. +Prompts the user for input with a question. User input is automatically converted from a string to the appropriate data type. For example if the user enters "1", the number 1 as an integer will be returned. If the user clicks Cancel, the script is paused but remains at the prompt line. Pressing Go re-displays the prompt. @@ -228,7 +228,7 @@ password = ask("Enter your password", false, true) Since 5.0.0 -Prompts the user for input with a question. User input is always returned as a string. For example if the user enters "1", the string "1" will be returned. +Prompts the user for input with a question. User input is always returned as a string. For example if the user enters "1", the string "1" will be returned. If the user clicks Cancel, the script is paused but remains at the prompt line. Pressing Go re-displays the prompt. @@ -292,7 +292,7 @@ password = ask_string("Enter your password", false, true) Since 7.0.0 -The message_box, vertical_message_box, combo_box and check_box methods create a message box with buttons / selections / checkboxes that the user can click. The text of the button / selection / checkbox is returned. +The message_box, vertical_message_box, combo_box and check_box methods create a message box with buttons / selections / checkboxes that the user can click. The text of the button / selection / checkbox is returned. If the user clicks Cancel, the script is paused but remains at the prompt line. Pressing Go re-displays the prompt. @@ -379,7 +379,7 @@ end Since 5.0.0 -The open_file_dialog and open_files_dialog methods create a file dialog box so the user can select a single or multiple files. The selected file(s) is returned. +The open_file_dialog and open_files_dialog methods create a file dialog box so the user can select a single or multiple files. The selected file(s) is returned. If the user clicks Cancel, the script is paused but remains at the prompt line. Pressing Go re-displays the prompt. Note: COSMOS 5 has deprecated the save_file_dialog and open_directory_dialog methods. save_file_dialog can be replaced by put_target_file if you want to write a file back to the target. open_directory_dialog doesn't make sense in new architecture so you must request individual files. @@ -452,7 +452,7 @@ end Since 7.0.0 -The open_bucket_dialog method creates a dialog box that allows the user to browse S3 bucket files and select one. It presents the available buckets (similar to Bucket Explorer) and allows navigating directories within the selected bucket. The selected file is downloaded and returned as a file object, similar to open_file_dialog. +The open_bucket_dialog method creates a dialog box that allows the user to browse S3 bucket files and select one. It presents the available buckets (similar to Bucket Explorer) and allows navigating directories within the selected bucket. The selected file is downloaded and returned as a file object, similar to open_file_dialog. If the user clicks Cancel, the script is paused but remains at the prompt line. Pressing Go re-displays the prompt. The returned file object also provides `filename`, `bucket`, and `bucket_path` (methods in Python) for the selected file's name, the bucket it came from (e.g. `OPENC3_CONFIG_BUCKET`), and its full path within that bucket (e.g. `DEFAULT/targets/INST/procedures/test.rb`). These are only available when running in Script Runner. @@ -10321,7 +10321,7 @@ metadata_update({ 'key' => 'value' }) Since 5.2.0 -Prompts the user to set existing metadata values or create new a new one. +Prompts the user to set existing metadata values or create a new one. If the user clicks Cancel, the script is paused but remains at the prompt line. Pressing Go re-displays the prompt. diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptRunner.vue b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptRunner.vue index c7ca847936..f7cb2d7020 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptRunner.vue +++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/ScriptRunner.vue @@ -2395,6 +2395,9 @@ export default { async scriptComplete() { // Completion can also be reached through status refreshes or failed run // requests rather than a queued 'complete' event. + // Stop the waiting timer on every path: the 'complete' message sets a + // terminal state without going through processLine() + this.clearWaiting() this.queueReceivedOutputLines() this.flushOutputLines(true) // Supersede any scriptStart still awaiting its subscription. Must @@ -2755,6 +2758,12 @@ export default { this.results.text = data.report this.results.show = true } + // A crash in the script process itself (not the script thread) + // never publishes a terminal state with a line event, so take + // the final state from the 'complete' message + if (data.state) { + this.state = data.state + } // Set before the teardown below: 'complete' is terminal even if // scriptComplete() throws, and the catch would otherwise let the // loop run on against a half-torn-down session. diff --git a/openc3-cosmos-script-runner-api/app/controllers/running_script_controller.rb b/openc3-cosmos-script-runner-api/app/controllers/running_script_controller.rb index 7d79427399..ff78e60365 100644 --- a/openc3-cosmos-script-runner-api/app/controllers/running_script_controller.rb +++ b/openc3-cosmos-script-runner-api/app/controllers/running_script_controller.rb @@ -114,9 +114,13 @@ def delete end end - if pid.nil? or not stopped - # Also need to cleanup the status model in this case because the process will not die and cleanup - running_script.end_time = Time.now.utc.iso8601 + # Re-fetch because the process may have updated the model while stopping. + # If the process was killed by a signal (or never died) it never moved + # itself from running to completed so do that here. Setting state is a + # no-op if the process already reached a complete state. + running_script = OpenC3::ScriptStatusModel.get_model(name: params[:id], scope: params[:scope]) || running_script + if not running_script.is_complete? + running_script.end_time ||= Time.now.utc.iso8601 running_script.state = "killed" running_script.update end diff --git a/openc3-cosmos-script-runner-api/scripts/run_script.py b/openc3-cosmos-script-runner-api/scripts/run_script.py index edfd75161e..8750d2525b 100644 --- a/openc3-cosmos-script-runner-api/scripts/run_script.py +++ b/openc3-cosmos-script-runner-api/scripts/run_script.py @@ -179,10 +179,7 @@ def run_script_log(script_id, message, color="BLACK", message_log=True): f"Multiple input: {running_script.user_input}", ) elif parsed_cmd["method"] == "open_bucket_dialog": - answer = parsed_cmd["answer"] - if isinstance(answer, str): - answer = json.loads(answer) - running_script.user_input = answer + running_script.user_input = parsed_cmd["answer"] run_script_log( script_id, f"Bucket file: {running_script.user_input}", diff --git a/openc3-cosmos-script-runner-api/spec/controllers/running_script_controller_spec.rb b/openc3-cosmos-script-runner-api/spec/controllers/running_script_controller_spec.rb index 8104ba0d13..b34ac19563 100644 --- a/openc3-cosmos-script-runner-api/spec/controllers/running_script_controller_spec.rb +++ b/openc3-cosmos-script-runner-api/spec/controllers/running_script_controller_spec.rb @@ -217,6 +217,11 @@ script_model = double("ScriptModel") allow(script_model).to receive(:filename).and_return("INST/procedures/test.rb") allow(script_model).to receive(:pid).and_return("12345") + allow(script_model).to receive(:is_complete?).and_return(false) + allow(script_model).to receive(:end_time).and_return(nil) + allow(script_model).to receive(:end_time=) + allow(script_model).to receive(:state=) + allow(script_model).to receive(:update) allow(OpenC3::ScriptStatusModel).to receive(:get_model).and_return(script_model) end @@ -229,6 +234,48 @@ expect(response.status).to eq(200) end + it "moves the script to completed if the process died without cleaning up" do + allow(Process).to receive(:getpgid).and_raise(Errno::ESRCH) + allow_any_instance_of(RunningScriptController).to receive(:running_script_publish) + script_model = OpenC3::ScriptStatusModel.get_model(name: "1", scope: "DEFAULT") + expect(script_model).to receive(:state=).with("killed") + expect(script_model).to receive(:update) + + delete :delete, params: {id: "1", scope: "DEFAULT"} + + expect(response.status).to eq(200) + end + + it "leaves the script alone if the process already completed it" do + allow(Process).to receive(:getpgid).and_raise(Errno::ESRCH) + allow_any_instance_of(RunningScriptController).to receive(:running_script_publish) + script_model = OpenC3::ScriptStatusModel.get_model(name: "1", scope: "DEFAULT") + allow(script_model).to receive(:is_complete?).and_return(true) + expect(script_model).not_to receive(:update) + + delete :delete, params: {id: "1", scope: "DEFAULT"} + + expect(response.status).to eq(200) + end + + it "re-fetches the model to see a completion written while stopping" do + allow(Process).to receive(:getpgid).and_raise(Errno::ESRCH) + allow_any_instance_of(RunningScriptController).to receive(:running_script_publish) + # The initial fetch sees the script running, then the process + # moves itself to completed before it exits + running_model = OpenC3::ScriptStatusModel.get_model(name: "1", scope: "DEFAULT") + completed_model = double("CompletedScriptModel") + allow(completed_model).to receive(:filename).and_return("INST/procedures/test.rb") + allow(completed_model).to receive(:is_complete?).and_return(true) + allow(OpenC3::ScriptStatusModel).to receive(:get_model).and_return(running_model, completed_model) + expect(running_model).not_to receive(:update) + expect(completed_model).not_to receive(:update) + + delete :delete, params: {id: "1", scope: "DEFAULT"} + + expect(response.status).to eq(200) + end + it "uses SIGINT if stop command doesn't work" do # First call to Process.getpgid returns true (process still running) # Second call raises ESRCH (process stopped after SIGINT) @@ -257,6 +304,8 @@ script_model = double("ScriptModel") allow(script_model).to receive(:filename).and_return("INST/procedures/test.rb") allow(script_model).to receive(:pid).and_return("12345") + allow(script_model).to receive(:is_complete?).and_return(false) + allow(script_model).to receive(:end_time).and_return(nil) allow(script_model).to receive(:end_time=) do |value| @end_time = value end @@ -281,6 +330,8 @@ script_model = double("ScriptModel") allow(script_model).to receive(:filename).and_return("INST/procedures/test.rb") allow(script_model).to receive(:pid).and_return(nil) + allow(script_model).to receive(:is_complete?).and_return(false) + allow(script_model).to receive(:end_time).and_return(nil) allow(script_model).to receive(:end_time=) allow(script_model).to receive(:state=) allow(script_model).to receive(:update) diff --git a/playwright/tests/script-runner/prompts.p.spec.ts b/playwright/tests/script-runner/prompts.p.spec.ts index d46a454773..d367180422 100644 --- a/playwright/tests/script-runner/prompts.p.spec.ts +++ b/playwright/tests/script-runner/prompts.p.spec.ts @@ -12,7 +12,9 @@ # All Rights Reserved */ -import { test, expect } from '../fixture' +import type { Page } from '@playwright/test' +import type { Utilities } from '../../utilities' +import { expect, test } from '../fixture' test.use({ toolPath: '/tools/scriptrunner', @@ -41,12 +43,8 @@ test('prompts for hazardous commands', async ({ page, utils }) => { await expect(page.locator('.v-dialog')).toContainText('Hazardous Command') await page.getByRole('dialog').getByRole('button', { name: 'Send' }).click() await expect(page.locator('[data-test=state] input')).toHaveValue('completed') - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'User input: Send', - ) - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'cmd("INST CLEAR")', - ) + await expectOutput(page, 'User input: Send') + await expectOutput(page, 'cmd("INST CLEAR")') }) test('does not hazardous prompt for cmd_no_hazardous_check, cmd_no_checks', async ({ @@ -80,9 +78,7 @@ test('errors for out of range command parameters', async ({ page, utils }) => { }) await page.locator('[data-test=go-button]').click() await expect(page.locator('[data-test=state] input')).toHaveValue('completed') - await expect(page.locator('[data-test=output-messages]')).toContainText( - '11 not in valid range', - ) + await expectOutput(page, '11 not in valid range') }) test('does not out of range error for cmd_no_range_check, cmd_no_checks', async ({ @@ -107,224 +103,289 @@ cmd_no_checks("INST COLLECT with DURATION 11, TYPE 'NORMAL'")`) ) }) -test('ask accepts default, password, and required', async ({ page, utils }) => { - await page.locator('textarea').fill(`value = ask("Enter password:") -puts value -value = ask("Optionally enter password:", true) -puts "blank:#{value.empty?}" -value = ask("Enter default password:", 67890) -puts value -value = ask("Enter SECRET password:", false, true) -wait -puts value`) - await page.locator('[data-test=start-button]').click() - await expect(page.locator('.v-dialog')).toBeVisible({ - timeout: 20000, - }) - await page.locator('.v-dialog >> button:has-text("Cancel")').click() - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'User input: COSMOS__CANCEL', - ) - await expect(page.locator('[data-test=state] input')).toHaveValue( - /paused \d+s/, - ) +// Language specific script snippets and expected output so the same +// interaction tests can run against both Ruby and Python +const languages = [ + { + name: 'ruby', + t: 'true', + f: 'false', + print: (expr: string) => `puts ${expr}`, + wait: 'wait', + blank: (v: string) => `puts "blank:#{${v}.empty?}"`, + blankOut: 'blank:true', + typed: (label: string, v: string) => + `puts "${label}:#{${v}} #{${v}.class}"`, + types: { int: 'Integer', float: 'Float', string: 'String' }, + multiOut: 'Multiple input: ["abc123", "ghi789"]', + }, + { + name: 'python', + t: 'True', + f: 'False', + // Use an f-string so Script Runner detects Python rather than falling + // back to the default scripting language for otherwise ambiguous scripts + print: (expr: string) => `print(f"{${expr}}")`, + wait: 'wait()', + blank: (v: string) => `print(f"blank:{${v} == ''}")`, + blankOut: 'blank:True', + typed: (label: string, v: string) => + `print(f"${label}:{${v}} {type(${v}).__name__}")`, + types: { int: 'int', float: 'float', string: 'str' }, + multiOut: "Multiple input: ['abc123', 'ghi789']", + }, +] +const cancelCases = [ + { + method: 'ask', + title: 'Enter value:', + script: `value = ask("Enter value:")`, + output: 'User input: COSMOS__CANCEL', + }, + { + method: 'ask_string', + title: 'Enter string:', + script: `value = ask_string("Enter string:")`, + output: 'User input: COSMOS__CANCEL', + }, + { + method: 'message_box', + title: 'Message select', + script: `value = message_box("Message select", "ONE", "TWO")`, + output: 'User input: COSMOS__CANCEL', + }, + { + method: 'vertical_message_box', + title: 'Vertical select', + script: `value = vertical_message_box("Vertical select", "FOUR", "FIVE")`, + output: 'User input: COSMOS__CANCEL', + }, + { + method: 'combo_box', + title: 'Combo select', + script: `value = combo_box("Combo select", "abc123", "def456")`, + output: 'User input: COSMOS__CANCEL', + }, + { + method: 'check_box', + title: 'Check select', + script: `value = check_box("Check select", "abc123", "def456")`, + output: 'Multiple input: COSMOS__CANCEL', + }, + { + method: 'prompt', + title: 'Continue?', + script: `value = prompt("Continue?")`, + output: 'User input: COSMOS__CANCEL', + }, + { + method: 'open_file_dialog', + title: 'Open single file', + script: `value = open_file_dialog("Open single file", "Choose file")`, + output: 'File(s): COSMOS__CANCEL', + }, + { + method: 'open_files_dialog', + title: 'Open multiple files', + script: `value = open_files_dialog("Open multiple files", "Choose files")`, + output: 'File(s): COSMOS__CANCEL', + }, + { + method: 'open_bucket_dialog', + title: 'Open bucket file', + script: `value = open_bucket_dialog("Open bucket file", "Choose file")`, + output: 'Bucket file: COSMOS__CANCEL', + }, +] - // Clicking go re-launches the dialog - await page.locator('[data-test=go-button]').click() - await expect( - page.locator('.v-dialog >> button:has-text("Ok")'), - ).toBeDisabled() - await page.locator('.v-dialog >> input').fill('12345') - await page.locator('.v-dialog >> button:has-text("Ok")').click() - await expect(page.locator('[data-test=output-messages]')).toContainText( - '12345', - ) - // Now nothing is required so OK is enabled - await expect(page.locator('.v-dialog >> button:has-text("Ok")')).toBeEnabled() - await page.locator('.v-dialog >> button:has-text("Ok")').click() - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'blank:true', - ) - // Verify the default value - expect(await page.inputValue('[data-test=ask-value-input] input')).toMatch( - '67890', - ) - await page.locator('.v-dialog >> button:has-text("Ok")').click() - await expect(page.locator('[data-test=output-messages]')).toContainText( - '67890', - ) - // Now type the secret password - await page.locator('.v-dialog >> input').fill('abc123!') - await page.locator('.v-dialog >> button:has-text("Ok")').click() +async function expectOutput(page: Page, expectedOutput: string | RegExp) { + await expect(page.locator('[data-test=output-messages]')).toContainText(expectedOutput) +} - await expect(page.locator('[data-test=state] input')).toHaveValue( - /waiting \d+s/, - ) - // Verify we're not outputting the secret password on input - await expect(page.locator('[data-test=output-messages]')).not.toContainText( - 'abc123!', - ) - // Once we restart we should see it since we print it - await page.locator('[data-test=go-button]').click() +async function scriptWithDialogCompletes(page: Page, scriptText: string, actions: () => Promise, expectedOutput: string) { + await page.locator('textarea').fill(scriptText) + await page.locator('[data-test=start-button]').click() + await expect(page.locator('.v-dialog')).toBeVisible({ timeout: 20000 }) + await actions() await expect(page.locator('[data-test=state] input')).toHaveValue('completed') - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'abc123!', - ) -}) + await expectOutput(page, expectedOutput) +} + +for (const L of languages) { + test.describe(`${L.name} user input`, () => { + test.describe('happy path', () => { + test('ask accepts default, password, and required', async ({ + page, + utils, + }) => { + await scriptWithDialogCompletes(page, + `value = ask("Enter password:") +${L.print('value')} +value = ask("Optionally enter password:", ${L.t}) +${L.blank('value')} +value = ask("Enter default password:", 67890) +${L.print('value')} +value = ask("Enter SECRET password:", ${L.f}, ${L.t}) +${L.wait} +${L.print('value')}`, + async () => { + // Required so Ok is disabled until something is entered + const okButton = page.locator('.v-dialog >> button:has-text("Ok")') + await expect(okButton).toBeDisabled() + await page.locator('.v-dialog >> input').fill('12345') + await okButton.click() + await expectOutput(page, '12345') + // Now nothing is required so OK is enabled + await expect(okButton).toBeEnabled() + await okButton.click() + await expectOutput(page, L.blankOut) + // Verify the default value + expect( + await page.inputValue('[data-test=ask-value-input] input'), + ).toMatch('67890') + await okButton.click() + await expectOutput(page, '67890') + // Now type the secret password + await page.locator('.v-dialog >> input').fill('abc123!') + await okButton.click() + + await expect(page.locator('[data-test=state] input')).toHaveValue(/waiting \d+s/) + // Verify we're not outputting the secret password on input + await expect( + page.locator('[data-test=output-messages]'), + ).not.toContainText('abc123!') + // Once we continue we should see it since we print it + await page.locator('[data-test=go-button]').click() + }, + 'abc123!' + ) + }) -test('converts value for ask but not ask_string', async ({ page, utils }) => { - await page.locator('textarea').fill(`value = ask("Enter integer:") -puts "int:#{value} #{value.class}" + test('converts value for ask but not ask_string', async ({ + page, + utils, + }) => { + await scriptWithDialogCompletes(page, + `value = ask("Enter integer:") +${L.typed('int', 'value')} value = ask("Enter float:") -puts "float:#{value} #{value.class}" +${L.typed('float', 'value')} value = ask_string("Enter float:") -puts "string:#{value} #{value.class}"`) - await page.locator('[data-test=start-button]').click() - await expect(page.locator('.v-dialog')).toBeVisible({ - timeout: 20000, - }) - await page.locator('.v-dialog >> input').fill('123') - await page.locator('.v-dialog >> button:has-text("Ok")').click() - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'int:123 Integer', - ) - await page.locator('.v-dialog >> input').fill('5.5') - await page.locator('.v-dialog >> button:has-text("Ok")').click() - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'float:5.5 Float', - ) - await page.locator('.v-dialog >> input').fill('5.5') - await page.locator('.v-dialog >> button:has-text("Ok")').click() - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'string:5.5 String', - ) - await expect(page.locator('[data-test=state] input')).toHaveValue('completed') -}) +${L.typed('string', 'value')}`, + async () => { + await page.locator('.v-dialog >> input').fill('123') + await page.locator('.v-dialog >> button:has-text("Ok")').click() + await expectOutput(page, `int:123 ${L.types.int}`) + await page.locator('.v-dialog >> input').fill('5.5') + await page.locator('.v-dialog >> button:has-text("Ok")').click() + await expectOutput(page, `float:5.5 ${L.types.float}`) + await page.locator('.v-dialog >> input').fill('5.5') + await page.locator('.v-dialog >> button:has-text("Ok")').click() + }, + `string:5.5 ${L.types.string}` + ) + }) -test('opens a dialog with buttons for message_box, vertical_message_box', async ({ - page, - utils, -}) => { - await page.locator('textarea') - .fill(`value = message_box("Select", "ONE", "TWO", "THREE") -puts value -value = vertical_message_box("Select", "FOUR", "FIVE", "SIX") -puts value`) - await page.locator('[data-test=start-button]').click() - await expect(page.locator('.v-dialog')).toBeVisible({ - timeout: 20000, - }) - await page.locator('.v-dialog >> button:has-text("Cancel")').click() - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'User input: COSMOS__CANCEL', - ) - await expect(page.locator('[data-test=state] input')).toHaveValue( - /paused \d+s/, - ) + test('message_box returns the clicked button', async ({ page, utils }) => { + await scriptWithDialogCompletes( + page, + `value = message_box("Select", "ONE", "TWO", "THREE")\n${L.print('value')}`, + () => page.locator('.v-dialog >> button:has-text("TWO")').click(), + 'User input: TWO', + ) + }) - // Clicking Go re-launches the dialog - await page.locator('[data-test=go-button]').click() - await page.locator('.v-dialog >> button:has-text("TWO")').click() - await page.locator('.v-dialog >> button:has-text("FOUR")').click() - await expect(page.locator('[data-test=state] input')).toHaveValue('completed') - await expect(page.locator('[data-test=output-messages]')).toContainText('TWO') - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'FOUR', - ) -}) + test('vertical_message_box returns the clicked button', async ({ + page, + utils, + }) => { + await scriptWithDialogCompletes( + page, + `value = vertical_message_box("Select", "FOUR", "FIVE", "SIX")\n${L.print('value')}`, + () => page.locator('.v-dialog >> button:has-text("FIVE")').click(), + 'User input: FIVE', + ) + }) -test('opens a dialog with dropdowns for combo_box', async ({ page, utils }) => { - await page - .locator('textarea') - .fill( - `value = combo_box("Select value from combo", "abc123", "def456")\nputs value`, - ) - await page.locator('[data-test=start-button]').click() - await expect(page.locator('.v-dialog')).toBeVisible({ - timeout: 20000, - }) - await page.locator('.v-dialog >> button:has-text("Cancel")').click() - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'User input: COSMOS__CANCEL', - ) - await expect(page.locator('[data-test=state] input')).toHaveValue( - /paused \d+s/, - ) + test('combo_box returns the selected value', async ({ page, utils }) => { + await scriptWithDialogCompletes( + page, + `value = combo_box("Select value from combo", "abc123", "def456")\n${L.print('value')}`, + async () => { + await page.getByRole('combobox').filter({ hasText: 'Select' }).click() + await page.locator('div[role="listbox"] >> text=def456').click() + await page.locator('.v-dialog >> button:has-text("Ok")').click() + }, + 'User input: def456', + ) + }) - // Clicking go re-launches the dialog - await page.locator('[data-test=go-button]').click() - await page.getByRole('combobox').filter({ hasText: 'Select' }).click() - await expect(page.locator('[data-test=state] input')).toHaveValue( - /waiting \d+s/, - ) - await page.locator('div[role="listbox"] >> text=def456').click() - await page.locator('.v-dialog >> button:has-text("Ok")').click() - await expect(page.locator('[data-test=state] input')).toHaveValue('completed') - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'User input: def456', - ) -}) + test('check_box returns the checked values', async ({ page, utils }) => { + await scriptWithDialogCompletes( + page, + `value = check_box("Select values to enable", "abc123", "def456", "ghi789")\n${L.print('value')}`, + async () => { + await page.getByRole('checkbox', { name: 'abc123' }).check() + await page.getByRole('checkbox', { name: 'ghi789' }).check() + await page.locator('[data-test="prompt-ok"]').click() + }, + L.multiOut, + ) + }) -test('opens a dialog with checkboxes for check_box', async ({ - page, - utils, -}) => { - await page - .locator('textarea') - .fill( - `value = check_box("Select values to enable", "abc123", "def456", "ghi789")\nputs value`, - ) - await page.locator('[data-test=start-button]').click() - await expect(page.locator('.v-dialog')).toBeVisible({ - timeout: 20000, - }) - await page.locator('.v-dialog >> button:has-text("Cancel")').click() - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'Multiple input: COSMOS__CANCEL', - ) - await expect(page.locator('[data-test=state] input')).toHaveValue( - /paused \d+s/, - ) + test('prompt returns the clicked button', async ({ page, utils }) => { + // Default choices for prompt is Ok and Cancel + await scriptWithDialogCompletes( + page, + `value = prompt("Continue?")\n${L.print('value')}`, + async () => { + await expect(page.locator('.v-dialog')).toContainText('Continue?', { + timeout: 20000, + }) + await page.locator('.v-dialog >> button:has-text("Ok")').click() + }, + 'Ok', + ) + }) + }) - // Clicking go re-launches the dialog - await page.locator('[data-test=go-button]').click() - await expect(page.locator('[data-test=state] input')).toHaveValue( - /waiting \d+s/, - ) - await page.getByRole('checkbox', { name: 'abc123' }).check() - await page.getByRole('checkbox', { name: 'ghi789' }).check() - await page.locator('[data-test="prompt-ok"]').click() - await expect(page.locator('[data-test=state] input')).toHaveValue('completed') - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'Multiple input: ["abc123", "ghi789"]', - ) -}) + test.describe('canceling prompts', () => { + // Each method is cancelled in its own test: Cancel closes the dialog, + // pauses the script at the prompt and Go re-displays the same dialog + for (const c of cancelCases) { + test(`${c.method}`, async ({ page, utils }) => { + await page.locator('textarea').fill(`${c.script} +${L.print('value')}`) + await page.locator('[data-test=start-button]').click() + await expect(page.locator('.v-dialog')).toContainText(c.title, { + timeout: 20000, + }) + await utils.sleep(500) + await page.locator('.v-dialog >> button:has-text("Cancel")').click() + await expect(page.locator('.v-dialog')).not.toBeVisible() + await expectOutput(page, c.output) + await expect(page.locator('[data-test=state] input')).toHaveValue( + /paused \d+s/, + ) -test('opens a dialog for prompt', async ({ page, utils }) => { - // Default choices for prompt is Ok and Cancel - await page.locator('textarea').fill(`value = prompt("Continue?")\nputs value`) - await page.locator('[data-test=start-button]').click() - await expect(page.locator('.v-dialog')).toBeVisible({ - timeout: 20000, - }) - await expect(page.locator('.v-dialog')).toContainText('Continue?') - await page.locator('.v-dialog >> button:has-text("Cancel")').click() - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'User input: COSMOS__CANCEL', - ) - await expect(page.locator('[data-test=state] input')).toHaveValue( - /paused \d+s/, - ) + // Clicking Go re-launches the dialog + await page.locator('[data-test=go-button]').click() + await expect(page.locator('.v-dialog')).toContainText(c.title) - // Clicking Go re-executes the prompt - await page.locator('[data-test=go-button]').click() - await expect(page.locator('.v-dialog')).toContainText('Continue?') - await page.locator('.v-dialog >> button:has-text("Ok")').click() - await expect(page.locator('[data-test=output-messages]')).toContainText('Ok') - await expect(page.locator('[data-test=state] input')).toHaveValue('completed') -}) + // Dismiss the dialog and stop the script + await page.locator('.v-dialog >> button:has-text("Cancel")').click() + await expect(page.locator('.v-dialog')).not.toBeVisible() + await page.locator('[data-test=stop-button]').click() + await expect(page.locator('[data-test=state] input')).toHaveValue( + 'stopped', + ) + }) + } + }) + }) +} -async function openFile(page, utils, filename) { +async function openFile(page: Page, utils: Utilities, filename: string) { await page.locator('[data-test=script-runner-file]').click() await page.locator('text=Open File').click() await utils.sleep(500) // Allow background data to fetch @@ -345,7 +406,22 @@ async function openFile(page, utils, filename) { } } -async function runScript(page, utils, filename) { +// Open the file chooser, select files and click Ok. +// Note that Promise.all prevents a race condition +// between clicking and waiting for the file chooser. +async function chooseFiles(page: Page, utils: Utilities, files: string | string[]) { + const [fileChooser] = await Promise.all([ + // It is important to call waitForEvent before click to set up waiting. + page.waitForEvent('filechooser'), + // Open the file chooser + page.getByLabel('Choose File').first().click(), + ]) + await fileChooser.setFiles(files) + await page.locator('.v-dialog >> button:has-text("Ok")').click() + await utils.sleep(500) +} + +async function runScript(page: Page, utils: Utilities, filename: string) { await openFile(page, utils, filename) await page.locator('[data-test=start-button]').click() await expect(page.locator('.v-dialog')).toBeVisible({ @@ -356,41 +432,15 @@ async function runScript(page, utils, filename) { 'Choose something interesting', ) await utils.sleep(500) - await page.locator('.v-dialog >> button:has-text("Cancel")').click() - await expect(page.locator('.v-dialog')).not.toBeVisible() - await expect(page.locator('[data-test=state] input')).toHaveValue( - /paused \d+s/, - ) - await utils.sleep(500) - // Clicking Go re-executes the prompt - await page.locator('[data-test=go-button]').click() - await expect(page.locator('.v-dialog')).toBeVisible() - // Note that Promise.all prevents a race condition - // between clicking and waiting for the file chooser. - const [fileChooser] = await Promise.all([ - // It is important to call waitForEvent before click to set up waiting. - page.waitForEvent('filechooser'), - // Open the file chooser - page.getByLabel('Choose File').first().click(), - ]) - await fileChooser.setFiles('.prettierrc.js') - await page.locator('.v-dialog >> button:has-text("Ok")').click() - await utils.sleep(500) + await chooseFiles(page, utils, '.prettierrc.js') await expect(page.locator('.v-dialog')).toBeVisible() await expect(page.locator('.v-dialog')).toContainText('Open multiple files') - // Note that Promise.all prevents a race condition - // between clicking and waiting for the file chooser. - const [fileChooser2] = await Promise.all([ - // It is important to call waitForEvent before click to set up waiting. - page.waitForEvent('filechooser'), - // Open the file chooser - page.getByLabel('Choose File').first().click(), + await chooseFiles(page, utils, [ + 'pnpm-workspace.yaml', + 'reset_storage_state.sh', ]) - await fileChooser2.setFiles(['pnpm-workspace.yaml', 'reset_storage_state.sh']) - await page.locator('.v-dialog >> button:has-text("Ok")').click() - await utils.sleep(500) await expect(page.locator('.v-dialog')).toBeVisible() await expect(page.locator('.v-dialog')).toContainText( @@ -422,27 +472,15 @@ async function runScript(page, utils, filename) { await page.locator('[data-test="bucket-ok"]').click() await expect(page.locator('[data-test=state] input')).toHaveValue('completed') - await expect(page.locator('[data-test=output-messages]')).toContainText( - /File\(s\): \[['"].prettierrc.js['"]\]/, - ) + await expectOutput(page, /File\(s\): \[['"].prettierrc.js['"]\]/,) // Verify something from .prettierrc.js - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'bracketSpacing: true', - ) - await expect(page.locator('[data-test=output-messages]')).toContainText( - /File\(s\): \[['"]pnpm-workspace.yaml['"], ['"]reset_storage_state.sh['"]\]/, - ) + await expectOutput(page, 'bracketSpacing: true') + await expectOutput(page, /File\(s\): \[['"]pnpm-workspace.yaml['"], ['"]reset_storage_state.sh['"]\]/) // Verify something from reset_storage_state.sh - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'Initialize an empty storageState', - ) - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'Reading DEFAULT/targets/INST2/target.txt', - ) + await expectOutput(page, 'Initialize an empty storageState') + await expectOutput(page, 'Reading DEFAULT/targets/INST2/target.txt') // Verify something from INST2/target.txt - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'TELEMETRY inst_tlm_override.txt', - ) + await expectOutput(page, 'TELEMETRY inst_tlm_override.txt') } test('test ruby prompts', async ({ page, utils }) => { @@ -457,9 +495,7 @@ test('handles ruby crashes', async ({ page, utils }) => { // puts will resolve to Ruby await page.locator('textarea').fill(`puts "HI"\ndef def`) await page.locator('[data-test=start-button]').click() - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'SyntaxError', - ) + await expectOutput(page, 'SyntaxError') await expect(page.locator('[data-test=state] input')).toHaveValue('crashed') await expect(page.locator('[data-test=start-button]')).toBeEnabled() }) @@ -468,9 +504,7 @@ test('handles python crashes', async ({ page, utils }) => { // print will resolve to Python await page.locator('textarea').fill(`print "HI"`) await page.locator('[data-test=start-button]').click() - await expect(page.locator('[data-test=output-messages]')).toContainText( - 'SyntaxError', - ) + await expectOutput(page, 'SyntaxError') await expect(page.locator('[data-test=state] input')).toHaveValue('crashed') await expect(page.locator('[data-test=start-button]')).toBeEnabled() })