diff --git a/.gitignore b/.gitignore index 7ff5de2bd8..3e658cb327 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,4 @@ developer.code-workspace __pycache__ playwright/tests/enterprise -.vscode/settings.json +.vscode diff --git a/docs.openc3.com/docs/guides/scripting-api.md b/docs.openc3.com/docs/guides/scripting-api.md index f210d51fc0..bf69756524 100644 --- a/docs.openc3.com/docs/guides/scripting-api.md +++ b/docs.openc3.com/docs/guides/scripting-api.md @@ -409,6 +409,59 @@ for file in files: +### open_bucket_dialog + +> 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. + + + + +```ruby +open_bucket_dialog("", "<Message>") +``` + +</TabItem> + +<TabItem value="python" label="Python Syntax"> + +```python +open_bucket_dialog("<Title>", "<Message>") +``` + +</TabItem> +</Tabs> + +| Parameter | Description | +| --------- | ------------------------------------------------------------- | +| Title | The title to put on the dialog. Required. | +| Message | The message to display in the dialog box. Optional parameter. | + +<Tabs groupId="script-language"> +<TabItem value="ruby" label="Ruby Example"> + +```ruby +file = open_bucket_dialog("Select a File", "Choose a file from a bucket") +puts file.filename # The name of the selected file +puts file.read +file.delete +``` + +</TabItem> + +<TabItem value="python" label="Python Example"> + +```python +file = open_bucket_dialog("Select a File", "Choose a file from a bucket") +print(file.filename) # The name of the selected file +print(file.read()) +file.close() +``` + +</TabItem> +</Tabs> + ## File Manipulation These methods provide capability to interact with files in the target directory. diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/file_dialog.rb b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/file_dialog.rb index 8d2ca6f386..76edf22595 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/file_dialog.rb +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST/procedures/file_dialog.rb @@ -13,3 +13,11 @@ puts file.read file.delete end + +# Specify the title and message and filter to txt files +file = open_bucket_dialog("Open a file from the buckets", "Choose something interesting") +puts file # Ruby File object +puts file.path # Path of the tempfile (generally not used) +puts file.filename # Filename that was selected in the dialog +puts file.read +file.delete diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/file_dialog.py b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/file_dialog.py index 0398d255f4..70377f30f0 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/file_dialog.py +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-demo/targets/INST2/procedures/file_dialog.py @@ -15,3 +15,12 @@ print(file.filename()) print(file.read()) file.close() + +# Specify the title and message +file = open_bucket_dialog( + "Open a file from the buckets", "Choose something interesting" +) +print(file) # Python tempfile.NamedTemporaryFile object +print(file.filename()) # Filename that was selected in the dialog +print(file.read()) +file.close() diff --git a/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-bucketexplorer/src/tools/BucketExplorer/BucketExplorer.vue b/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-bucketexplorer/src/tools/BucketExplorer/BucketExplorer.vue index 29fa0c11ae..a178c27cef 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-bucketexplorer/src/tools/BucketExplorer/BucketExplorer.vue +++ b/openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-bucketexplorer/src/tools/BucketExplorer/BucketExplorer.vue @@ -65,7 +65,7 @@ </v-btn> <v-text-field v-model="search" - label="Search" + label="Search current directory" prepend-inner-icon="mdi-magnify" clearable variant="outlined" diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/Dialogs/BucketDialog.vue b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/Dialogs/BucketDialog.vue new file mode 100644 index 0000000000..87ddbe408a --- /dev/null +++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/scriptrunner/Dialogs/BucketDialog.vue @@ -0,0 +1,294 @@ +<!-- +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# 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 LICENSE.md for more details. + +# This file may also be used under the terms of a commercial license +# if purchased from OpenC3, Inc. +--> + +<template> + <v-dialog v-model="show" persistent width="800" @keydown.esc="cancelHandler"> + <v-card> + <v-toolbar height="24"> + <v-spacer /> + <span> Bucket Dialog </span> + <v-spacer /> + </v-toolbar> + <div class="pa-2"> + <v-card-text> + <v-row> + <span class="text-h6">{{ title }}</span> + </v-row> + <v-row v-if="message"> + <span class="ma-3" style="white-space: pre-line" v-text="message" /> + </v-row> + <v-row class="my-2"> + <v-chip + v-for="(bucket, index) in buckets" + :key="index" + :variant="selectedBucket === bucket ? 'elevated' : 'outlined'" + color="primary" + class="ma-2" + data-test="bucket-chip" + @click.stop="selectBucket(bucket)" + > + <v-icon start>mdi-bucket</v-icon> + {{ bucket }} + </v-chip> + </v-row> + <v-row v-if="selectedBucket" class="my-1"> + <v-col class="pa-0"> + <v-row + class="ma-0 align-center" + style="background-color: var(--color-background-surface-header)" + > + <v-btn + icon="mdi-chevron-left-box-outline" + variant="text" + density="compact" + class="ml-2" + data-test="bucket-nav-back" + aria-label="Navigate Back" + @click.stop="backArrow" + /> + <div + class="ma-2" + style="font-size: 0.875rem" + data-test="bucket-path" + > + {{ selectedBucket }}/<span + v-for="(part, index) in breadcrumbPath" + :key="index" + ><a + style="cursor: pointer" + @click.prevent="gotoPath(part.path)" + >{{ part.name }}</a + >/</span + > + </div> + </v-row> + <v-text-field + v-model="search" + label="Search" + prepend-inner-icon="mdi-magnify" + clearable + variant="outlined" + density="compact" + single-line + hide-details + class="mx-2 mt-2" + data-test="bucket-search" + /> + <v-list + density="compact" + class="bucket-file-list" + data-test="bucket-file-list" + > + <v-list-item v-if="loading"> + <v-progress-circular indeterminate size="20" class="mr-2" /> + Loading... + </v-list-item> + <v-list-item + v-for="item in filteredFiles" + :key="item.name" + :class="{ + 'selected-file': + selectedFile === item.name && item.icon === 'mdi-file', + }" + :data-test="`bucket-item-${item.name}`" + @click="itemClick(item)" + @dblclick="itemDblClick(item)" + > + <template #prepend> + <v-icon>{{ item.icon }}</v-icon> + </template> + <v-list-item-title>{{ item.name }}</v-list-item-title> + <template v-if="item.size" #append> + <span class="text-caption">{{ + formatSize(item.size) + }}</span> + </template> + </v-list-item> + <v-list-item v-if="!loading && filteredFiles.length === 0"> + <v-list-item-title class="text-caption text-grey"> + No files found + </v-list-item-title> + </v-list-item> + </v-list> + </v-col> + </v-row> + </v-card-text> + </div> + <v-card-actions class="px-2"> + <v-spacer /> + <v-btn + variant="outlined" + data-test="bucket-cancel" + @click="cancelHandler" + > + Cancel + </v-btn> + <v-btn + variant="flat" + data-test="bucket-ok" + :disabled="!selectedFile" + @click.prevent="submitHandler" + > + Ok + </v-btn> + </v-card-actions> + </v-card> + </v-dialog> +</template> + +<script> +import { Api } from '@openc3/js-common/services' + +export default { + props: { + title: { + type: String, + required: true, + }, + message: { + type: String, + default: null, + }, + modelValue: Boolean, + }, + emits: ['update:modelValue', 'response'], + data() { + return { + buckets: [], + selectedBucket: null, + path: '', + files: [], + selectedFile: null, + search: '', + loading: false, + } + }, + computed: { + show: { + get() { + return this.modelValue + }, + set(value) { + this.$emit('update:modelValue', value) + }, + }, + breadcrumbPath() { + if (!this.path) return [] + const parts = this.path.split('/').filter((p) => !!p) + return parts.map((part, index) => ({ + name: part, + path: parts.slice(0, index + 1).join('/') + '/', + })) + }, + filteredFiles() { + if (!this.search) return this.files + const term = this.search.toLowerCase() + return this.files.filter((f) => f.name.toLowerCase().includes(term)) + }, + }, + created() { + Api.get('/openc3-api/storage/buckets').then((response) => { + this.buckets = response.data + }) + }, + methods: { + formatSize(bytes) { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + }, + selectBucket(bucket) { + this.selectedBucket = bucket + this.path = '' + this.selectedFile = null + this.updateFiles() + }, + backArrow() { + if (this.path === '') return + const parts = this.path.split('/') + this.path = parts.slice(0, -2).join('/') + if (this.path) { + this.path += '/' + } + this.selectedFile = null + this.updateFiles() + }, + gotoPath(path) { + this.path = path + this.selectedFile = null + this.updateFiles() + }, + itemClick(item) { + if (item.icon === 'mdi-folder') { + this.path += `${item.name}/` + this.selectedFile = null + this.updateFiles() + } else { + this.selectedFile = item.name + } + }, + itemDblClick(item) { + if (item.icon === 'mdi-file') { + this.selectedFile = item.name + this.submitHandler() + } + }, + updateFiles() { + this.loading = true + const root = this.selectedBucket.toUpperCase() + Api.get(`/openc3-api/storage/files/OPENC3_${root}_BUCKET/${this.path}`) + .then((response) => { + this.files = response.data[0].map((dir) => ({ + name: dir, + icon: 'mdi-folder', + })) + this.files = this.files.concat( + response.data[1].map((item) => ({ + name: item.name, + icon: 'mdi-file', + size: item.size, + })), + ) + this.loading = false + }) + .catch(() => { + this.files = [] + this.loading = false + }) + }, + submitHandler() { + this.$emit('response', { + bucket: `OPENC3_${this.selectedBucket.toUpperCase()}_BUCKET`, + path: `${this.path}${this.selectedFile}`, + filename: this.selectedFile, + }) + }, + cancelHandler() { + this.$emit('response', 'Cancel') + }, + }, +} +</script> + +<style scoped> +.bucket-file-list { + max-height: 300px; + overflow-y: auto; + border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); + border-radius: 4px; + margin-top: 8px; +} +.selected-file { + background-color: rgba(var(--v-theme-primary), 0.15); +} +</style> 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 8f3fafe2d5..8baa661ccd 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 @@ -556,6 +556,13 @@ :filter="file.filter" @response="fileDialogCallback" /> + <bucket-dialog + v-if="bucket.show" + v-model="bucket.show" + :title="bucket.title" + :message="bucket.message" + @response="bucketDialogCallback" + /> <information-dialog v-if="information.show" v-model="information.show" @@ -694,6 +701,7 @@ import { fileIcon } from '@/util' import { EventListDialog } from '@/tools/calendar' import AskDialog from '@/tools/scriptrunner/Dialogs/AskDialog.vue' +import BucketDialog from '@/tools/scriptrunner/Dialogs/BucketDialog.vue' import FileDialog from '@/tools/scriptrunner/Dialogs/FileDialog.vue' import InformationDialog from '@/tools/scriptrunner/Dialogs/InformationDialog.vue' import OverridesDialog from '@/tools/scriptrunner/Dialogs/OverridesDialog.vue' @@ -728,6 +736,7 @@ export default { Pane, TopBar, AskDialog, + BucketDialog, FileDialog, InformationDialog, EventListDialog, @@ -852,6 +861,11 @@ export default { multiple: false, callback: () => {}, }, + bucket: { + show: false, + title: '', + message: '', + }, prompt: { show: false, title: '', @@ -2247,6 +2261,7 @@ export default { this.prompt.show = false this.ask.show = false this.file.show = false + this.bucket.show = false return } this.activePromptId = data.prompt_id @@ -2382,6 +2397,11 @@ export default { } this.showMetadata() break + case 'open_bucket_dialog': + this.bucket.title = data.args[0] + this.bucket.message = data.args[1] + this.bucket.show = true + break // This is called continuously by the backend case 'open_file_dialog': case 'open_files_dialog': @@ -2444,6 +2464,16 @@ export default { this.file.show = false // Close the dialog immediately to avoid race condition }) }, + bucketDialogCallback(response) { + this.bucket.show = false + Api.post(`/script-api/running-script/${this.scriptId}/prompt`, { + data: { + method: 'open_bucket_dialog', + answer: response, + prompt_id: this.activePromptId, + }, + }) + }, setError(event) { this.alertType = 'error' this.alertText = `Error: ${event}` 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 8d7d988135..10ba60ec94 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 @@ -173,7 +173,9 @@ def prompt elsif params[:multiple] running_script_publish("cmd-running-script-channel:#{params[:id]}", { method: params[:method], multiple: JSON.generate(params[:answer], allow_nan: true), prompt_id: params[:prompt_id] }) else - running_script_publish("cmd-running-script-channel:#{params[:id]}", { method: params[:method], answer: params[:answer], prompt_id: params[:prompt_id] }) + # Convert ActionController::Parameters to a plain hash for nested objects (e.g. open_bucket_dialog) + answer = params[:answer].respond_to?(:to_unsafe_h) ? params[:answer].to_unsafe_h : params[:answer] + running_script_publish("cmd-running-script-channel:#{params[:id]}", { method: params[:method], answer: answer, prompt_id: params[:prompt_id] }) end OpenC3::Logger.info("Script prompt action #{params[:method]}: #{running_script['filename']}", scope: params[:scope], user: username()) head :ok diff --git a/openc3-cosmos-script-runner-api/scripts/run_script.py b/openc3-cosmos-script-runner-api/scripts/run_script.py index b5c209e8c3..9a2a9ffaef 100644 --- a/openc3-cosmos-script-runner-api/scripts/run_script.py +++ b/openc3-cosmos-script-runner-api/scripts/run_script.py @@ -153,6 +153,7 @@ def run_script_log(script_id, message, color="BLACK", message_log=True): | "metadata_input" | "open_file_dialog" | "open_files_dialog" + | "open_bucket_dialog" ): if running_script.prompt_id is not None: if "prompt_id" in parsed_cmd and running_script.prompt_id == parsed_cmd["prompt_id"]: @@ -164,6 +165,12 @@ def run_script_log(script_id, message, color="BLACK", message_log=True): script_id, 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 + run_script_log(script_id, f"Bucket file: {running_script.user_input}") elif "open_file" in parsed_cmd["method"]: running_script.user_input = parsed_cmd["answer"] run_script_log(script_id, f"File(s): {running_script.user_input}") diff --git a/openc3-cosmos-script-runner-api/scripts/run_script.rb b/openc3-cosmos-script-runner-api/scripts/run_script.rb index 12b192eda1..268ee001e6 100644 --- a/openc3-cosmos-script-runner-api/scripts/run_script.rb +++ b/openc3-cosmos-script-runner-api/scripts/run_script.rb @@ -106,7 +106,7 @@ def run_script_log(id, message, color = 'BLACK', message_log = true) case parsed_cmd["method"] # This list matches the list in running_script.rb:44 when "ask", "ask_string", "message_box", "vertical_message_box", "combo_box", "prompt", "prompt_for_hazardous", - "prompt_for_critical_cmd", "metadata_input", "open_file_dialog", "open_files_dialog" + "prompt_for_critical_cmd", "metadata_input", "open_file_dialog", "open_files_dialog", "open_bucket_dialog" unless running_script.prompt_id.nil? if running_script.prompt_id == parsed_cmd["prompt_id"] if parsed_cmd["password"] @@ -114,6 +114,9 @@ def run_script_log(id, message, color = 'BLACK', message_log = true) elsif parsed_cmd["multiple"] running_script.user_input = JSON.parse(parsed_cmd["multiple"]) run_script_log(id, "Multiple input: #{running_script.user_input}") + elsif parsed_cmd["method"] == 'open_bucket_dialog' + running_script.user_input = parsed_cmd["answer"] + run_script_log(id, "Bucket file: #{running_script.user_input}") elsif parsed_cmd["method"].include?('open_file') running_script.user_input = parsed_cmd["answer"] run_script_log(id, "File(s): #{running_script.user_input}") diff --git a/openc3/lib/openc3/script/script.rb b/openc3/lib/openc3/script/script.rb index 2168f0c801..8e5f7f41c5 100644 --- a/openc3/lib/openc3/script/script.rb +++ b/openc3/lib/openc3/script/script.rb @@ -199,6 +199,16 @@ def open_files_dialog(title, message = "Open File(s)", filter:) _file_dialog(title, message, filter) end + def open_bucket_dialog(title, message = "Open Bucket File") + answer = '' + while answer.empty? + print "#{title}\n#{message}\n<Type bucket file path (e.g. BUCKET/path/to/file)>:" + answer = gets + answer.chomp! + end + return answer + end + def prompt(string, text_color: nil, background_color: nil, font_size: nil, font_family: nil, details: nil) print "#{string}: " print "Details: #{details}\n" if details diff --git a/openc3/lib/openc3/script/storage.rb b/openc3/lib/openc3/script/storage.rb index 8c9a363402..55d05fe339 100644 --- a/openc3/lib/openc3/script/storage.rb +++ b/openc3/lib/openc3/script/storage.rb @@ -141,13 +141,13 @@ def _get_download_url(path, scope: $openc3_scope) return result['url'] end - def _get_storage_file(path, scope: $openc3_scope) + def _get_storage_file(path, bucket: 'OPENC3_CONFIG_BUCKET', scope: $openc3_scope) # Create Tempfile to store data file = Tempfile.new('target', binmode: true) file.filename = path endpoint = "/openc3-api/storage/download/#{scope}/#{path}" - result = _get_presigned_request(endpoint, scope: scope) + result = _get_presigned_request(endpoint, bucket: bucket, scope: scope) puts "Reading #{scope}/#{path}" # Try to get the file @@ -186,11 +186,11 @@ def _get_uri(url) end end - def _get_presigned_request(endpoint, external: nil, scope: $openc3_scope) + def _get_presigned_request(endpoint, external: nil, bucket: 'OPENC3_CONFIG_BUCKET', scope: $openc3_scope) if external or !$openc3_in_cluster - response = $api_server.request('get', endpoint, query: { bucket: 'OPENC3_CONFIG_BUCKET' }, scope: scope) + response = $api_server.request('get', endpoint, query: { bucket: bucket }, scope: scope) else - response = $api_server.request('get', endpoint, query: { bucket: 'OPENC3_CONFIG_BUCKET', internal: true }, scope: scope) + response = $api_server.request('get', endpoint, query: { bucket: bucket, internal: true }, scope: scope) end if response.nil? || response.status != 201 raise "Failed to get presigned URL for #{endpoint}" diff --git a/openc3/lib/openc3/utilities/running_script.rb b/openc3/lib/openc3/utilities/running_script.rb index 1fa505f5bf..9a219f7760 100644 --- a/openc3/lib/openc3/utilities/running_script.rb +++ b/openc3/lib/openc3/utilities/running_script.rb @@ -61,7 +61,7 @@ module Script # Define all the user input methods used in scripting which we need to broadcast to the frontend # Note: This list matches the list in run_script.rb:116 SCRIPT_METHODS = %i[ask ask_string message_box vertical_message_box combo_box prompt prompt_for_hazardous - prompt_for_critical_cmd metadata_input open_file_dialog open_files_dialog] + prompt_for_critical_cmd metadata_input open_file_dialog open_files_dialog open_bucket_dialog] SCRIPT_METHODS.each do |method| define_method(method) do |*args, **kwargs| while true @@ -75,7 +75,18 @@ module Script if input == 'Cancel' RunningScript.instance.perform_pause else - if (method.to_s.include?('open_file')) + if method.to_s == 'open_bucket_dialog' + bucket = input['bucket'] + path = input['path'] + filename = input['filename'] + # Path from bucket dialog already includes scope prefix (e.g. DEFAULT/targets/...) + # _get_storage_file prepends scope, so strip it from the path to avoid doubling + scope = RunningScript.instance.scope + path = path.sub(/\A#{Regexp.escape(scope)}\//, '') + file = _get_storage_file(path, bucket: bucket, scope: scope) + file.filename = filename + return file + elsif (method.to_s.include?('open_file')) files = input.map do |filename| file = _get_storage_file("tmp/#{filename}", scope: RunningScript.instance.scope) # Set filename method we added to Tempfile in the core_ext diff --git a/openc3/python/openc3/script/__init__.py b/openc3/python/openc3/script/__init__.py index c40ed1a7f5..78b1001758 100644 --- a/openc3/python/openc3/script/__init__.py +++ b/openc3/python/openc3/script/__init__.py @@ -112,6 +112,14 @@ def open_files_dialog(title, message="Open File", filter=None): _file_dialog(title, message, filter) +def open_bucket_dialog(title, message="Open Bucket File"): + answer = "" + while len(answer) == 0: + print(f"{title}\n{message}\n<Type bucket file path (e.g. BUCKET/path/to/file)>:") + answer = input() + return answer + + def prompt( string, text_color=None, diff --git a/openc3/python/openc3/script/storage.py b/openc3/python/openc3/script/storage.py index 6601746c57..0075973bbf 100644 --- a/openc3/python/openc3/script/storage.py +++ b/openc3/python/openc3/script/storage.py @@ -161,12 +161,12 @@ def _get_download_url(path: str, scope: str = OPENC3_SCOPE): return result["url"] -def _get_storage_file(path, scope=OPENC3_SCOPE): +def _get_storage_file(path, bucket="OPENC3_CONFIG_BUCKET", scope=OPENC3_SCOPE): # Create Tempfile to store data file = tempfile.NamedTemporaryFile(mode="w+b") # noqa: SIM115 - returned to caller endpoint = f"/openc3-api/storage/download/{scope}/{path}" - result = _get_presigned_request(endpoint, scope=scope) + result = _get_presigned_request(endpoint, bucket=bucket, scope=scope) print(f"Reading {scope}/{path}") # Try to get the file @@ -196,16 +196,14 @@ def _get_uri(url): return f"{openc3.script.API_SERVER.generate_url()}{url}" -def _get_presigned_request(endpoint, external=None, scope=OPENC3_SCOPE): +def _get_presigned_request(endpoint, external=None, bucket="OPENC3_CONFIG_BUCKET", scope=OPENC3_SCOPE): if external or not openc3.script.OPENC3_IN_CLUSTER: - response = openc3.script.API_SERVER.request( - "get", endpoint, query={"bucket": "OPENC3_CONFIG_BUCKET"}, scope=scope - ) + response = openc3.script.API_SERVER.request("get", endpoint, query={"bucket": bucket}, scope=scope) else: response = openc3.script.API_SERVER.request( "get", endpoint, - query={"bucket": "OPENC3_CONFIG_BUCKET", "internal": True}, + query={"bucket": bucket, "internal": True}, scope=scope, ) diff --git a/openc3/python/openc3/utilities/running_script.py b/openc3/python/openc3/utilities/running_script.py index 2a171d9461..2f61bc36ce 100644 --- a/openc3/python/openc3/utilities/running_script.py +++ b/openc3/python/openc3/utilities/running_script.py @@ -138,6 +138,7 @@ def _openc3_script_sleep(sleep_time=None): "metadata_input", "open_file_dialog", "open_files_dialog", + "open_bucket_dialog", ] @@ -156,7 +157,24 @@ def running_script_method(method, *args, **kwargs): if user_input == "Cancel": RunningScript.instance.perform_pause() else: - if "open_file" in method: + if method == "open_bucket_dialog": + bucket = user_input["bucket"] + path = user_input["path"] + the_filename = user_input["filename"] + # Path from bucket dialog already includes scope prefix (e.g. DEFAULT/targets/...) + # _get_storage_file prepends scope, so strip it from the path to avoid doubling + scope = RunningScript.instance.scope() + if path.startswith(f"{scope}/"): + path = path[len(scope) + 1 :] + file = _get_storage_file(path, bucket=bucket, scope=scope) + file._filename = the_filename + + def filename(self): + return self._filename + + type(file).filename = filename + return file + elif "open_file" in method: files = [] for the_filename in user_input: file = _get_storage_file(f"tmp/{the_filename}", scope=RunningScript.instance.scope()) diff --git a/playwright/tests/script-runner/prompts.p.spec.ts b/playwright/tests/script-runner/prompts.p.spec.ts index 289d43788a..3e45274a15 100644 --- a/playwright/tests/script-runner/prompts.p.spec.ts +++ b/playwright/tests/script-runner/prompts.p.spec.ts @@ -282,11 +282,34 @@ test('opens a dialog for prompt', async ({ page, utils }) => { await expect(page.locator('[data-test=state] input')).toHaveValue('completed') }) -test('opens a file dialog', async ({ page, utils }) => { - await page.locator('textarea') - .fill(`file = open_file_dialog("Open a single file", "Choose something interesting") -puts file.read -file.delete`) +async function openFile(page, utils, filename) { + 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 + await expect( + page.locator('.v-dialog').getByText('INST2', { exact: true }), + ).toBeVisible() + let parts = filename.split('.') + await page.locator('[data-test=file-open-save-search] input').fill(parts[0]) + await utils.sleep(100) + await page + .locator('[data-test=file-open-save-search] input') + .fill(`.${parts[1]}`) + await page.locator(`text=${filename}`).click() + await page.locator('[data-test=file-open-save-submit-btn]').click() + await expect(page.locator('.v-dialog')).not.toBeVisible() + + // Check for potential "<User> is editing this script" + // This can happen if we had to do a retry on this test + const someone = page.getByText('is editing this script') + if (await someone.isVisible()) { + await page.locator('[data-test="unlock-button"]').click() + await page.locator('[data-test="confirm-dialog-force unlock"]').click() + } +} + +async function runScript(page, utils, filename) { + await openFile(page, utils, filename) await page.locator('[data-test=start-button]').click() await expect(page.locator('.v-dialog')).toBeVisible({ timeout: 20000, @@ -314,13 +337,76 @@ file.delete`) // Open the file chooser page.getByLabel('Choose File').first().click(), ]) - await fileChooser.setFiles('package.json') + await fileChooser.setFiles('.prettierrc.js') + 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('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 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( + 'Open a file from the buckets', + ) + await page.getByText('config', { exact: true }).click() + await page + .locator('[data-test="bucket-item-DEFAULT"]') + .getByText('DEFAULT') + .click() + await page + .locator('[data-test="bucket-item-targets"]') + .getByText('targets') + .click() + await page + .locator('[data-test="bucket-item-INST2"]') + .getByText('INST2') + .click() + await page.getByText('target.txt').click() + 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): ["package.json"]', + /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['"]\]/, ) + // Verify something from pnpm-workspace.yaml await expect(page.locator('[data-test=output-messages]')).toContainText( - 'devDependencies', + 'nodeLinker: hoisted', ) + // 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', + ) + // Verify something from INST2/target.txt + await expect(page.locator('[data-test=output-messages]')).toContainText( + 'TELEMETRY inst_tlm_override.txt', + ) +} + +test('test ruby prompts', async ({ page, utils }) => { + await runScript(page, utils, 'file_dialog.rb') +}) + +test('test python prompts', async ({ page, utils }) => { + await runScript(page, utils, 'file_dialog.py') })