From 8576a781e9b912431358d047d25d6df44e67f341 Mon Sep 17 00:00:00 2001 From: Andy L WSL Date: Sat, 28 Feb 2026 16:01:49 +0000 Subject: [PATCH] speech to text all working --- docs/GCS_SpeechToText_ImplementationPlan.md | 211 ++++++++++++++++ docs/GCS_SpeechToText_TaskList.md | 43 ++++ docs/GCloudGCSSetup.md | 232 ++++++++++++++++++ docs/GCloudServiceAccountLessonsLearned.md | 101 ++++++++ src/core/lib/GoogleCloud.mjs | 151 ++++++++++++ src/core/operations/GCloudListBucket.mjs | 89 +++++++ src/core/operations/GCloudReadFile.mjs | 66 +++++ src/core/operations/GCloudSpeechToText.mjs | 258 ++++++++++++++++++++ tests/browser/03_cloud_ops.js | 138 +++++++++++ 9 files changed, 1289 insertions(+) create mode 100644 docs/GCS_SpeechToText_ImplementationPlan.md create mode 100644 docs/GCS_SpeechToText_TaskList.md create mode 100644 docs/GCloudGCSSetup.md create mode 100644 docs/GCloudServiceAccountLessonsLearned.md create mode 100644 src/core/operations/GCloudListBucket.mjs create mode 100644 src/core/operations/GCloudReadFile.mjs create mode 100644 src/core/operations/GCloudSpeechToText.mjs diff --git a/docs/GCS_SpeechToText_ImplementationPlan.md b/docs/GCS_SpeechToText_ImplementationPlan.md new file mode 100644 index 00000000..85ea83b0 --- /dev/null +++ b/docs/GCS_SpeechToText_ImplementationPlan.md @@ -0,0 +1,211 @@ +# Implementation Plan: GCS Orchestration & Speech-to-Text + +## Background & Goal + +We want CyberChef to act as a **cloud-native orchestrator** for processing large media files in GCS without ever streaming raw media bytes through the browser. + +**Concrete end-to-end workflow:** + +``` +Input: "cyber-chef-cloud-examples" + +Recipe: + 1. GCloud List Bucket [prefix: audio/] + → gs://cyber-chef-cloud-examples/audio/hello_kitty.mp3 + gs://cyber-chef-cloud-examples/audio/track_02.mp3 + gs://cyber-chef-cloud-examples/audio/track_03.mp3 + gs://cyber-chef-cloud-examples/audio/track_04.mp3 + + 2. Fork [\n] + + 3. GCloud Speech to Text [Output: Return to CyberChef |OR| Write to GCS] + → Browser mode: "The package will be delivered at 1400..." + → GCS mode: gs://cyber-chef-cloud-examples/output/audio/hello_kitty.mp3/speech-to-text/text.txt + + 4. Merge +``` + +**Key principle:** raw audio never touches the browser — GCS URI is passed in, transcript text (or a GCS output URI) comes back. + +--- + +## Design Decisions + +- **LRO polling: internal** — the `GCloud Speech to Text` operation polls internally (every 10 seconds, up to 30 minutes max), updating the output box with progress. No separate "Poll Operation" step needed. +- **Output path convention:** `output/{media_type}/{source_filename}/{service}/text.txt` + e.g. `gs://cyber-chef-cloud-examples/output/audio/hello_kitty.mp3/speech-to-text/text.txt` + +--- + +## 1. Shared Library Changes + +**File:** `src/core/lib/GoogleCloud.mjs` + +### New helper: `listGCSBucket` + +``` +listGCSBucket(bucket, prefix, authType, authStringObj, quotaProject) + +Calls: GET https://storage.googleapis.com/storage/v1/b/{bucket}/o?prefix={prefix} +Returns: array of { name, gs_uri, size, contentType } +``` + +### New helper: `readGCSFile` + +``` +readGCSFile(gcsUri, authType, authStringObj, quotaProject) + +Parses gs://bucket/object +Calls: GET https://storage.googleapis.com/storage/v1/b/{bucket}/o/{encodedObject}?alt=media +Returns: ArrayBuffer (raw bytes) +``` + +### New helper: `pollLongRunningOperation` + +``` +pollLongRunningOperation(operationName, authString, quotaProject, maxMs, intervalMs, onProgress) + +Calls: GET https://speech.googleapis.com/v1/operations/{operationName} every intervalMs +Calls onProgress(elapsedSeconds) on each tick (for progress output to CyberChef output box) +Resolves when response.done === true +Rejects on timeout or API error +Defaults: poll every 10s, timeout after 30 mins +``` + +--- + +## 2. New Operation: `GCloud List Bucket` + +**File:** `src/core/operations/GCloudListBucket.mjs` + +| Property | Value | +| :--- | :--- | +| Name | `GCloud List Bucket` | +| Module | `Cloud` | +| Input Type | `string` (bucket name or `gs://` prefix) | +| Output Type | `string` | +| `manualBake` | `true` | + +**Arguments:** + +| # | Name | Type | Default | +| :--- | :--- | :--- | :--- | +| 0 | Folder Prefix | `string` | `audio/` | +| 1 | Output Format | `option` | `GCS URIs (one per line)` / `Filenames only` / `JSON` | +| 2–4 | *(GCP_AUTH_ARGS)* | — | — | + +**Behaviour:** +- Strips `gs://` prefix from input to normalise bucket name +- Default output = newline-separated `gs://` URIs → directly pipe into `Fork` + +--- + +## 3. New Operation: `GCloud Read File` + +**File:** `src/core/operations/GCloudReadFile.mjs` + +| Property | Value | +| :--- | :--- | +| Name | `GCloud Read File` | +| Module | `Cloud` | +| Input Type | `string` (`gs://` URI) | +| Output Type | `ArrayBuffer` | +| `manualBake` | `true` | + +**Arguments:** + +| # | Name | Type | +| :--- | :--- | :--- | +| 0–2 | *(GCP_AUTH_ARGS)* | — | + +> **Note:** Intended for small files (text, small images). For large audio/video, use the GCS URI mode in Speech-to-Text directly — don't stream large binaries through the browser. + +--- + +## 4. New Operation: `GCloud Speech to Text` + +**File:** `src/core/operations/GCloudSpeechToText.mjs` + +| Property | Value | +| :--- | :--- | +| Name | `GCloud Speech to Text` | +| Module | `Cloud` | +| Input Type | `string` | +| Output Type | `string` | +| `manualBake` | `true` | + +**Arguments:** + +| # | Name | Type | Default | +| :--- | :--- | :--- | :--- | +| 0 | Input Mode | `option` | `GCS URI (gs://...)` / `Raw Audio Bytes (Base64)` | +| 1 | Language Code | `string` | `en-US` | +| 2 | Output Destination | `option` | `Return to CyberChef` / `Write to GCS` | +| 3 | Output GCS Bucket | `string` | `cyber-chef-cloud-examples` | +| 4 | Max Poll Minutes | `number` | `30` | +| 5–7 | *(GCP_AUTH_ARGS)* | — | — | + +**Runtime Logic:** + +``` +IF Input Mode == "GCS URI": + Call longrunningrecognize with { audio: { uri: input }, config: { languageCode } } + → Get operationName + transcript = await pollLongRunningOperation(operationName, ...) + (progress updates written to output box during polling) + +ELSE (Raw Audio Bytes / Base64): + Call recognize with { audio: { content: base64Input }, config: { languageCode } } + transcript = joined results + +IF Output Destination == "Write to GCS": + sourceFilename = last path segment of input gs:// URI (e.g. "hello_kitty.mp3") + destPath = "output/audio/{sourceFilename}/speech-to-text/text.txt" + PUT transcript → gs://{outputBucket}/{destPath} + return "gs://{outputBucket}/{destPath}" ← this is the CyberChef output + +ELSE: + return transcript text +``` + +**Output path example:** +``` +Input: gs://cyber-chef-cloud-examples/audio/hello_kitty.mp3 +Output: gs://cyber-chef-cloud-examples/output/audio/hello_kitty.mp3/speech-to-text/text.txt +``` + +After Fork + Merge across 4 files, the CyberChef output will be 4 GCS URIs the analyst can save, come back to later, and use as the input to a new recipe. + +--- + +## 5. Tests + +**File:** `tests/browser/03_cloud_ops.js` + +Following the existing pattern (skippable via missing token): + +| Test | Type | +| :--- | :--- | +| `GCloud List Bucket: Missing Key Validation` | No API call | +| `GCloud List Bucket: Lists audio/ files from cyber-chef-cloud-examples` | Live, skippable | +| `GCloud Speech-to-Text: GCS URI returns transcription in browser` | Live + LRO, skippable | +| `GCloud Speech-to-Text: GCS URI writes to output/ bucket` | Live + LRO + GCS write, skippable | + +--- + +## 6. Verification Plan + +### Automated +```bash +# From /home/projects/CyberChefCloud +CYBERCHEF_GCP_TEST_TOKEN=$(gcloud auth print-access-token) npm run test:browser +``` + +### Manual +1. Open CyberChef at `http://localhost:8080` +2. Add **GCloud List Bucket**, input `cyber-chef-cloud-examples`, prefix `audio/` +3. Bake → confirm 4 `gs://` URIs in output +4. Add **Fork** `[\n]` + **GCloud Speech to Text** (GCS URI, Return to CyberChef) + **Merge** +5. Bake → confirm 4 transcripts separated by `---` +6. Repeat with **Write to GCS** mode +7. Verify in terminal: `gsutil cat gs://cyber-chef-cloud-examples/output/audio/hello_kitty.mp3/speech-to-text/text.txt` diff --git a/docs/GCS_SpeechToText_TaskList.md b/docs/GCS_SpeechToText_TaskList.md new file mode 100644 index 00000000..c9081567 --- /dev/null +++ b/docs/GCS_SpeechToText_TaskList.md @@ -0,0 +1,43 @@ +# Task List: GCS + Speech-to-Text CyberChef Operations + +## Phase 1: Planning & Documentation +- [x] Review existing `GoogleTranslate.mjs`, `GoogleCloud.mjs` lib, and `03_cloud_ops.js` patterns +- [x] Write implementation plan (`GCS_SpeechToText_ImplementationPlan.md`) +- [x] Write GCP configuration guide (`GCloudGCSSetup.md`) +- [x] Get user approval on plan — **approved, internal LRO polling confirmed** + +## Phase 2: Shared Library Enhancements (`src/core/lib/GoogleCloud.mjs`) +- [ ] Add `listGCSBucket(bucket, prefix, authType, authStringObj, quotaProject)` helper +- [ ] Add `readGCSFile(gcsUri, authType, authStringObj, quotaProject)` helper +- [ ] Add `pollLongRunningOperation(operationName, authString, quotaProject, maxMs, intervalMs, onProgress)` polling helper + +## Phase 3: New Operations + +### 3.1 `GCloud List Bucket` (`src/core/operations/GCloudListBucket.mjs`) +- [ ] Create operation file scaffolding +- [ ] Args: Bucket Name, Prefix/Folder filter, Auth args, Output Format +- [ ] Calls GCS JSON API: `storage/v1/b/{bucket}/o?prefix=` +- [ ] Default output: newline-separated `gs://` URIs (pipe directly into `Fork`) + +### 3.2 `GCloud Read File` (`src/core/operations/GCloudReadFile.mjs`) +- [ ] Create operation file scaffolding +- [ ] Args: Auth args only (input = `gs://` URI) +- [ ] Calls GCS media download endpoint +- [ ] Output: raw file bytes (`ArrayBuffer`) + +### 3.3 `GCloud Speech to Text` (`src/core/operations/GCloudSpeechToText.mjs`) +- [ ] Create operation file scaffolding +- [ ] Args: Input Mode, Language Code, Output Destination, Output GCS Bucket, Max Poll Minutes, Auth args +- [ ] GCS URI mode → call `longrunningrecognize` → internal LRO polling loop +- [ ] Raw bytes mode → call synchronous `recognize` endpoint +- [ ] Output mode: `Return to CyberChef` (transcript text) OR `Write to GCS` (returns the written `gs://` URI) +- [ ] GCS output path convention: `output/audio/{filename}/speech-to-text/text.txt` + +## Phase 4: Tests (`tests/browser/03_cloud_ops.js`) +- [ ] `GCloud List Bucket: Missing Key Validation` (no API call) +- [ ] `GCloud List Bucket: Lists audio/ files from cyber-chef-cloud-examples` (live, skippable) +- [ ] `GCloud Speech-to-Text: GCS URI mode returns transcription` (live, LRO, skippable) +- [ ] `GCloud Speech-to-Text: GCS URI mode writes to output/ bucket` (live, LRO, skippable) + +## Phase 5: Documentation +- [ ] Update `GCloudGCSSetup.md` with any gotchas discovered during testing diff --git a/docs/GCloudGCSSetup.md b/docs/GCloudGCSSetup.md new file mode 100644 index 00000000..ff01d34a --- /dev/null +++ b/docs/GCloudGCSSetup.md @@ -0,0 +1,232 @@ +# GCP Configuration Guide: GCS + Speech-to-Text + +This guide ensures your Google Cloud project is correctly configured to support: +- **CyberChef `GCloud List Bucket`** — listing objects in a GCS bucket from the browser +- **CyberChef `GCloud Read File`** — downloading small files from GCS into the browser +- **CyberChef `GCloud Speech-to-Text`** — transcribing audio files stored in GCS, both returning results to the browser and writing outputs back to GCS + +**Your bucket:** `cyber-chef-cloud-examples` + +--- + +## 1. APIs to Enable + +In the [Google Cloud Console](https://console.cloud.google.com/apis/library) (or via `gcloud`), ensure the following APIs are enabled for your project: + +| API | Purpose | Enable via Console link | +| :--- | :--- | :--- | +| **Cloud Storage JSON API** | Listing & reading bucket objects | [Enable](https://console.cloud.google.com/apis/library/storage-component.googleapis.com) | +| **Cloud Speech-to-Text API** | Audio transcription | [Enable](https://console.cloud.google.com/apis/library/speech.googleapis.com) | + +Via `gcloud`: +```bash +gcloud services enable storage-component.googleapis.com +gcloud services enable speech.googleapis.com +``` + +--- + +## 2. IAM Roles for Your User Identity + +CyberChef sends API requests using **your OAuth token** (generated by `gcloud auth print-access-token`). Your user identity needs the following roles: + +### On the GCS Bucket (`cyber-chef-cloud-examples`) + +| Role | Why Needed | +| :--- | :--- | +| `roles/storage.objectViewer` | To list objects and read file metadata (List Bucket operation) | +| `roles/storage.objectCreator` | To write transcription output files back to the `output/` prefix | + +Grant via console: **Cloud Storage → cyber-chef-cloud-examples → Permissions → Grant Access** + +Or via `gcloud`: +```bash +# Replace YOUR_EMAIL with your Google account email +gcloud storage buckets add-iam-policy-binding gs://cyber-chef-cloud-examples \ + --member="user:YOUR_EMAIL@gmail.com" \ + --role="roles/storage.objectViewer" + +gcloud storage buckets add-iam-policy-binding gs://cyber-chef-cloud-examples \ + --member="user:YOUR_EMAIL@gmail.com" \ + --role="roles/storage.objectCreator" +``` + +### On the Speech-to-Text API + +Your user identity needs permission to call the Speech-to-Text API at the **project level**: + +| Role | Why Needed | +| :--- | :--- | +| `roles/speech.editor` (or `roles/speech.client`) | To call `longrunningrecognize` and poll operation status | + +```bash +gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \ + --member="user:YOUR_EMAIL@gmail.com" \ + --role="roles/speech.client" +``` + +--- + +## 3. Allow the Speech-to-Text Service Account to Read Your Bucket + +> [!IMPORTANT] +> This is the most commonly missed step. When you call `longrunningrecognize` with a `gcsUri`, the **Speech-to-Text API reads the file using its own internal service account**, not yours. You must explicitly grant this service account access to your bucket. + +### Find your Speech-to-Text service account + +The service account follows the pattern: +``` +service-{PROJECT_NUMBER}@gcp-sa-speech.iam.gserviceaccount.com +``` + +Get your project number: +```bash +gcloud projects describe YOUR_PROJECT_ID --format="value(projectNumber)" +# Example output: 123456789012 +``` + +So your service account would be: +``` +service-123456789012@gcp-sa-speech.iam.gserviceaccount.com +``` + +### Grant it access to the bucket + +```bash +gcloud storage buckets add-iam-policy-binding gs://cyber-chef-cloud-examples \ + --member="serviceAccount:service-123456789012@gcp-sa-speech.iam.gserviceaccount.com" \ + --role="roles/storage.objectViewer" +``` + +--- + +## 4. Configure CORS on the Bucket + +CyberChef runs in the browser and makes direct `fetch()` requests to the GCS JSON API. For the **list** and **metadata** endpoints these are generally permitted, but to be safe and to avoid issues with `OPTIONS` preflight requests, configure CORS on the bucket. + +Create a file `cors.json`: +```json +[ + { + "origin": [ + "http://localhost:8080", + "https://YOUR_CYBERCHEF_DOMAIN.com" + ], + "method": ["GET", "POST", "PUT", "HEAD"], + "responseHeader": ["Content-Type", "Authorization", "x-goog-user-project"], + "maxAgeSeconds": 3600 + } +] +``` + +Apply it: +```bash +gcloud storage buckets update gs://cyber-chef-cloud-examples \ + --cors-file=cors.json +``` + +Verify: +```bash +gcloud storage buckets describe gs://cyber-chef-cloud-examples --format="json(cors)" +``` + +--- + +## 5. Quota Project / ADC Setup + +If you are using an **OAuth Token** (recommended), some APIs require a billing quota project. Ensure your `gcloud` environment is configured: + +```bash +gcloud auth application-default set-quota-project YOUR_PROJECT_ID +``` + +In CyberChef operations, always populate the **Quota Project** field with your Project ID (e.g., `cyberchefcloud`). + +--- + +## 6. Verification Checklist + +Run these commands to verify your configuration before using CyberChef: + +### ✅ Can you list your bucket? +```bash +gcloud storage ls gs://cyber-chef-cloud-examples/audio/ +# Expected: 4 audio file URIs +``` + +### ✅ Can you read a file? +```bash +gsutil cat gs://cyber-chef-cloud-examples/audio/hello_kitty.mp3 | file - +# Expected: MPEG audio data (or similar) — confirms read access +``` + +### ✅ Can the Speech API access the file? (Test with REST) +```bash +TOKEN=$(gcloud auth print-access-token) +PROJECT_ID=YOUR_PROJECT_ID + +curl -s -X POST \ + "https://speech.googleapis.com/v1/speech:longrunningrecognize" \ + -H "Authorization: Bearer $TOKEN" \ + -H "x-goog-user-project: $PROJECT_ID" \ + -H "Content-Type: application/json" \ + -d '{ + "config": { "languageCode": "en-US", "enableAutomaticPunctuation": true }, + "audio": { "uri": "gs://cyber-chef-cloud-examples/audio/hello_kitty.mp3" } + }' +# Expected: { "name": "projects/.../operations/12345" } +``` + +If this returns an operation name, your setup is correct. + +### ✅ Can you poll the operation? +```bash +# Use the operation name from the previous step +OP_NAME="projects/YOUR_PROJECT_NUMBER/operations/12345" + +curl -s \ + "https://speech.googleapis.com/v1/operations/${OP_NAME}" \ + -H "Authorization: Bearer $TOKEN" \ + -H "x-goog-user-project: $PROJECT_ID" +# Wait a few seconds and re-run. When done: { "done": true, "response": { "results": [...] } } +``` + +### ✅ Can you write to the output/ prefix? +```bash +echo "Test transcript" | gsutil cp - gs://cyber-chef-cloud-examples/output/test.txt +# Expected: Copying... Operation completed + +# Clean up +gsutil rm gs://cyber-chef-cloud-examples/output/test.txt +``` + +--- + +## 7. Expected Bucket Structure + +``` +cyber-chef-cloud-examples/ +├── audio/ +│ ├── hello_kitty.mp3 +│ ├── track_02.mp3 +│ ├── track_03.mp3 +│ └── track_04.mp3 +├── images/ +│ └── (future image files) +├── video/ +│ └── (future video files) +└── output/ + └── audio/ + └── hello_kitty.mp3/ + └── speech-to-text/ + └── text.txt ← written by CyberChef +``` + +--- + +## 8. Summary of Roles Required + +| Identity | Bucket Role | Project Role | +| :--- | :--- | :--- | +| Your user OAuth token | `storage.objectViewer` + `storage.objectCreator` | `speech.client` | +| Speech-to-Text service account | `storage.objectViewer` | *(handled internally by GCP)* | diff --git a/docs/GCloudServiceAccountLessonsLearned.md b/docs/GCloudServiceAccountLessonsLearned.md new file mode 100644 index 00000000..8575ed8e --- /dev/null +++ b/docs/GCloudServiceAccountLessonsLearned.md @@ -0,0 +1,101 @@ +# GCP Lessons Learned: Service Accounts & Storage Permissions + +These lessons emerged from setting up Speech-to-Text access to a GCS bucket, but they apply broadly to **any Google Cloud AI/ML API that reads from Cloud Storage** (Vision, Video Intelligence, Natural Language, etc). + +--- + +## Lesson 1: Google-Managed Service Agents Are Not Project Service Accounts + +When a Google Cloud API (e.g. Speech-to-Text, Vision) reads from GCS on your behalf, it does so using a **Google-managed service agent** — a special service account that belongs to Google's infrastructure, not your project. + +| API | Service Agent Email Pattern | +| :--- | :--- | +| Cloud Speech-to-Text | `service-{PROJECT_NUMBER}@gcp-sa-speech.iam.gserviceaccount.com` | +| Cloud Vision | `service-{PROJECT_NUMBER}@gcp-sa-vision.iam.gserviceaccount.com` | +| Cloud Video Intelligence | `service-{PROJECT_NUMBER}@gcp-sa-videointelligence.iam.gserviceaccount.com` | +| Cloud Natural Language | `service-{PROJECT_NUMBER}@gcp-sa-language.iam.gserviceaccount.com` | + +**These will NOT appear in your project's IAM console** (`IAM & Admin → Service Accounts`), which only lists service accounts you created. Do not waste time looking for them there. + +--- + +## Lesson 2: Service Agents Are Provisioned Lazily + +The service agent email does not exist until you **make your first successful API call**. If you try to grant IAM permissions to the service agent email before any API call, the `gcloud` command will fail with: + +``` +ERROR: HTTPError 400: Service account (...) does not exist. +``` + +**Solution:** Make a real API call first (even if it fails with a 404 on the GCS object — a permission error is not sufficient). Once the API responds, the service agent is provisioned within a few seconds. + +--- + +## Lesson 3: Same-Project Access Is Automatic + +If your GCS bucket and the Cloud API are both in the **same GCP project**, the service agent already has read access to your bucket via the project's legacy IAM bindings (`roles/storage.legacyObjectReader` → `projectViewer`). You do not need to grant anything explicitly. + +**Explicitly granting is only required for cross-project access** (e.g. the API is in Project A but the bucket is in Project B). + +```bash +# Check your current bucket IAM — if you see these, same-project APIs can read it: +# - roles/storage.legacyBucketReader → projectViewer:YOUR_PROJECT +# - roles/storage.legacyObjectReader → projectViewer:YOUR_PROJECT +gcloud storage buckets get-iam-policy gs://YOUR_BUCKET +``` + +--- + +## Lesson 4: Model Selection Matters for Accuracy + +When calling Speech-to-Text (and likely other AI APIs), the default model is not always the best choice. In our testing: + +| Config | Transcript | Confidence | +| :--- | :--- | :--- | +| Default (no model specified) | `"result"` | 28% | +| `model: "latest_long"` | `"She achieves great results."` | 92% | + +**Always specify `model: "latest_long"` for general audio**, and `model: "latest_short"` for short utterances (under ~1 min). Enable `enableAutomaticPunctuation: true` for readable output. + +--- + +## Lesson 5: LRO Polling Endpoint Format + +For long-running operations (`longrunningrecognize`, Video Intelligence jobs, etc), the operation name returned is **just a number** (e.g. `4567056075577147015`), not a full resource path. + +The correct polling URL is: +``` +GET https://speech.googleapis.com/v1/operations/{OPERATION_ID} +``` + +Not the more verbose `projects/.../operations/...` format (that's a different API surface). Pass the same auth headers as the original request. + +--- + +## Lesson 6: Verify With `curl` Before Building CyberChef Operations + +Always test the raw API call with `curl` before writing operation code. The two-step pattern (submit → poll) is easy to verify interactively: + +```bash +# Step 1: Submit job +TOKEN=$(gcloud auth print-access-token) +curl -X POST "https://speech.googleapis.com/v1/speech:longrunningrecognize" \ + -H "Authorization: Bearer $TOKEN" \ + -H "x-goog-user-project: YOUR_PROJECT_ID" \ + -H "Content-Type: application/json" \ + -d '{ + "config": { + "languageCode": "en-US", + "model": "latest_long", + "enableAutomaticPunctuation": true + }, + "audio": { "uri": "gs://YOUR_BUCKET/audio/file.mp3" } + }' +# → { "name": "1234567890" } + +# Step 2: Poll until done +curl "https://speech.googleapis.com/v1/operations/1234567890" \ + -H "Authorization: Bearer $TOKEN" \ + -H "x-goog-user-project: YOUR_PROJECT_ID" +# → { "done": true, "response": { "results": [...] } } +``` diff --git a/src/core/lib/GoogleCloud.mjs b/src/core/lib/GoogleCloud.mjs index 04ebd4f4..a8ff268c 100644 --- a/src/core/lib/GoogleCloud.mjs +++ b/src/core/lib/GoogleCloud.mjs @@ -6,6 +6,157 @@ import OperationError from "../errors/OperationError.mjs"; +/** + * Lists objects in a GCS bucket under a given prefix. + * + * @param {string} bucket - The GCS bucket name (without gs://). + * @param {string} prefix - The folder prefix to filter by (e.g. "audio/"). + * @param {string} authType - "API Key" or "OAuth Token". + * @param {Object|string} authStringObj - The auth credential. + * @param {string} quotaProject - Optional quota project for ADC OAuth tokens. + * @returns {Promise} Array of GCS object metadata { name, gs_uri, size, contentType }. + */ +export async function listGCSBucket(bucket, prefix, authType, authStringObj, quotaProject) { + let url = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucket)}/o`; + const params = new URLSearchParams(); + if (prefix) params.set("prefix", prefix); + params.set("fields", "items(name,size,contentType)"); + const paramStr = params.toString(); + if (paramStr) url += `?${paramStr}`; + + const headers = new Headers(); + const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject); + + const response = await fetch(authed.url, { method: "GET", headers: authed.headers, mode: "cors", cache: "no-cache" }); + let data; + try { + data = await response.json(); + } catch (e) { + throw new OperationError("GCloud List Bucket: Failed to parse GCS API response."); + } + if (!response.ok) { + const msg = data?.error?.message || response.statusText; + throw new OperationError(`GCloud List Bucket: GCS API Error (${response.status}): ${msg}`); + } + + const items = data.items || []; + return items + .filter(item => !item.name.endsWith("/")) // exclude folder placeholder objects + .map(item => ({ + name: item.name, + gs_uri: `gs://${bucket}/${item.name}`, + size: item.size, + contentType: item.contentType + })); +} + +/** + * Downloads a file from GCS and returns its raw bytes. + * + * @param {string} gcsUri - Full gs:// URI of the file. + * @param {string} authType - "API Key" or "OAuth Token". + * @param {Object|string} authStringObj - The auth credential. + * @param {string} quotaProject - Optional quota project. + * @returns {Promise} Raw file bytes. + */ +export async function readGCSFile(gcsUri, authType, authStringObj, quotaProject) { + const match = gcsUri.match(/^gs:\/\/([^/]+)\/(.+)$/); + if (!match) throw new OperationError(`GCloud Read File: Invalid GCS URI: ${gcsUri}`); + const [, bucket, object] = match; + const encodedObject = encodeURIComponent(object).replace(/%2F/g, "%2F"); + let url = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucket)}/o/${encodedObject}?alt=media`; + + const headers = new Headers(); + const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject); + + const response = await fetch(authed.url, { method: "GET", headers: authed.headers, mode: "cors", cache: "no-cache" }); + if (!response.ok) { + let msg = response.statusText; + try { const d = await response.json(); msg = d?.error?.message || msg; } catch (e) { /* ignore */ } + throw new OperationError(`GCloud Read File: GCS API Error (${response.status}): ${msg}`); + } + return await response.arrayBuffer(); +} + +/** + * Writes text content to a GCS object. + * + * @param {string} bucket - Destination bucket name (without gs://). + * @param {string} objectPath - Destination object path within the bucket. + * @param {string} content - Text content to write. + * @param {string} authType - "API Key" or "OAuth Token". + * @param {Object|string} authStringObj - The auth credential. + * @param {string} quotaProject - Optional quota project. + * @returns {Promise} The gs:// URI of the written file. + */ +export async function writeGCSFile(bucket, objectPath, content, authType, authStringObj, quotaProject) { + const encodedObject = encodeURIComponent(objectPath).replace(/%2F/g, "%2F"); + let url = `https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucket)}/o?uploadType=media&name=${encodedObject}`; + + const headers = new Headers(); + headers.set("Content-Type", "text/plain; charset=utf-8"); + const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject); + + const response = await fetch(authed.url, { + method: "POST", + headers: authed.headers, + body: content, + mode: "cors", + cache: "no-cache" + }); + if (!response.ok) { + let msg = response.statusText; + try { const d = await response.json(); msg = d?.error?.message || msg; } catch (e) { /* ignore */ } + throw new OperationError(`GCloud Write File: GCS API Error (${response.status}): ${msg}`); + } + return `gs://${bucket}/${objectPath}`; +} + +/** + * Polls a Google Cloud long-running operation until it completes. + * + * @param {string} operationName - The operation ID (numeric string from the API response). + * @param {string} pollUrl - The base polling URL (e.g. https://speech.googleapis.com/v1/operations/). + * @param {string} authType - "API Key" or "OAuth Token". + * @param {Object|string} authStringObj - The auth credential. + * @param {string} quotaProject - Optional quota project. + * @param {number} maxMs - Maximum wait time in milliseconds (default 30 minutes). + * @param {number} intervalMs - Poll interval in milliseconds (default 10 seconds). + * @param {Function} onProgress - Optional callback(elapsedSeconds) called on each poll tick. + * @returns {Promise} The completed operation response object. + */ +export async function pollLongRunningOperation(operationName, pollUrl, authType, authStringObj, quotaProject, maxMs = 30 * 60 * 1000, intervalMs = 10000, onProgress = null) { + const startTime = Date.now(); + const url = `${pollUrl}${operationName}`; + + while (true) { + const elapsed = Date.now() - startTime; + if (elapsed > maxMs) { + throw new OperationError(`GCloud: Operation timed out after ${Math.round(elapsed / 60000)} minutes. Operation ID: ${operationName}`); + } + + const headers = new Headers(); + const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject); + const response = await fetch(authed.url, { method: "GET", headers: authed.headers, mode: "cors", cache: "no-cache" }); + + let data; + try { data = await response.json(); } catch (e) { + throw new OperationError("GCloud: Failed to parse long-running operation response."); + } + if (!response.ok) { + const msg = data?.error?.message || response.statusText; + throw new OperationError(`GCloud: Operation polling error (${response.status}): ${msg}`); + } + + if (data.done) return data; + + const elapsedSec = Math.round(elapsed / 1000); + if (onProgress) onProgress(elapsedSec); + + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } +} + /** * Common arguments for Google Cloud Platform operations * diff --git a/src/core/operations/GCloudListBucket.mjs b/src/core/operations/GCloudListBucket.mjs new file mode 100644 index 00000000..7c039419 --- /dev/null +++ b/src/core/operations/GCloudListBucket.mjs @@ -0,0 +1,89 @@ +/** + * @author CyberChefCloud + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import { GCP_AUTH_ARGS, applyGCPAuth, listGCSBucket } from "../lib/GoogleCloud.mjs"; + +/** + * GCloud List Bucket operation + */ +class GCloudListBucket extends Operation { + + /** + * GCloudListBucket constructor + */ + constructor() { + super(); + + this.name = "GCloud List Bucket"; + this.module = "Cloud"; + this.description = [ + "Lists objects in a Google Cloud Storage bucket and returns their gs:// URIs.", + "

", + "The output is one URI per line, making it suitable for piping directly into a Fork operation ", + "to batch-process multiple files (e.g. transcribing all audio files in a folder).", + "

", + "Input can be just a bucket name (e.g. cyber-chef-cloud-examples) or a full ", + "gs:// URI prefix.", + ].join("\n"); + this.infoURL = "https://cloud.google.com/storage/docs/json_api/v1/objects/list"; + this.inputType = "string"; + this.outputType = "string"; + this.manualBake = true; + this.args = [ + { + "name": "Folder Prefix", + "type": "string", + "value": "audio/" + }, + { + "name": "Output Format", + "type": "option", + "value": ["GCS URIs (one per line)", "Filenames only", "JSON"] + }, + ...GCP_AUTH_ARGS + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + async run(input, args) { + const [prefix, outputFormat, authType, authStringObj, quotaProject] = args; + + if (!input || !input.trim()) throw new OperationError("Please provide a GCS bucket name."); + + // Normalise: strip gs:// if present, strip trailing slash + let bucket = input.trim().replace(/^gs:\/\//, "").split("/")[0]; + + try { + const items = await listGCSBucket(bucket, prefix, authType, authStringObj, quotaProject); + + if (items.length === 0) { + return `No objects found in gs://${bucket}/${prefix || ""}`; + } + + switch (outputFormat) { + case "Filenames only": + return items.map(i => i.name.split("/").pop()).join("\n"); + case "JSON": + return JSON.stringify(items, null, 2); + case "GCS URIs (one per line)": + default: + return items.map(i => i.gs_uri).join("\n"); + } + } catch (e) { + if (e.name === "OperationError") throw e; + throw new OperationError(e.message || e.toString()); + } + } + +} + +export default GCloudListBucket; diff --git a/src/core/operations/GCloudReadFile.mjs b/src/core/operations/GCloudReadFile.mjs new file mode 100644 index 00000000..4f5058c7 --- /dev/null +++ b/src/core/operations/GCloudReadFile.mjs @@ -0,0 +1,66 @@ +/** + * @author CyberChefCloud + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import { GCP_AUTH_ARGS, readGCSFile } from "../lib/GoogleCloud.mjs"; + +/** + * GCloud Read File operation + */ +class GCloudReadFile extends Operation { + + /** + * GCloudReadFile constructor + */ + constructor() { + super(); + + this.name = "GCloud Read File"; + this.module = "Cloud"; + this.description = [ + "Downloads a file from Google Cloud Storage and returns its raw bytes.", + "

", + "Input must be a gs:// URI (e.g. gs://my-bucket/images/photo.png).", + "

", + "Note: This operation downloads the full file into the browser. ", + "It is best suited for small files such as text or images. ", + "For large audio or video files, use the GCS URI input mode within the Speech-to-Text or ", + "Video Intelligence operations instead — they process the file entirely within Google Cloud.", + ].join("\n"); + this.infoURL = "https://cloud.google.com/storage/docs/json_api/v1/objects/get"; + this.inputType = "string"; + this.outputType = "ArrayBuffer"; + this.manualBake = true; + this.args = [ + ...GCP_AUTH_ARGS + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {ArrayBuffer} + */ + async run(input, args) { + const [authType, authStringObj, quotaProject] = args; + + const uri = input.trim(); + if (!uri.startsWith("gs://")) { + throw new OperationError("Input must be a GCS URI starting with gs://"); + } + + try { + return await readGCSFile(uri, authType, authStringObj, quotaProject); + } catch (e) { + if (e.name === "OperationError") throw e; + throw new OperationError(e.message || e.toString()); + } + } + +} + +export default GCloudReadFile; diff --git a/src/core/operations/GCloudSpeechToText.mjs b/src/core/operations/GCloudSpeechToText.mjs new file mode 100644 index 00000000..666a8cc6 --- /dev/null +++ b/src/core/operations/GCloudSpeechToText.mjs @@ -0,0 +1,258 @@ +/** + * @author CyberChefCloud + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import { GCP_AUTH_ARGS, applyGCPAuth, pollLongRunningOperation, writeGCSFile } from "../lib/GoogleCloud.mjs"; + +/** + * GCloud Speech to Text operation + */ +class GCloudSpeechToText extends Operation { + + /** + * GCloudSpeechToText constructor + */ + constructor() { + super(); + + this.name = "GCloud Speech to Text"; + this.module = "Cloud"; + this.description = [ + "Transcribes audio using the Google Cloud Speech-to-Text API.", + "

", + "GCS URI mode (recommended for large files): Input a gs:// URI ", + "(e.g. gs://my-bucket/audio/file.mp3). The audio is processed entirely within ", + "Google Cloud — the raw audio never passes through the browser. Suitable for files of any size. ", + "Uses the asynchronous longrunningrecognize API with internal polling.", + "

", + "Raw Audio mode: Provide Base64-encoded audio bytes directly. Only suitable for short ", + "clips (under ~1 minute). Uses the synchronous recognize API.", + "

", + "Write to GCS mode: Instead of returning the transcript to CyberChef, writes it to a ", + "structured path in a GCS bucket and returns the destination gs:// URI. This is ", + "ideal for batch processing with Fork — each fork branch writes its transcript and returns a URI, ", + "which can be saved and used as input to a later recipe.", + "

", + "Output path convention: output/audio/{filename}/speech-to-text/text.txt", + ].join("\n"); + this.infoURL = "https://cloud.google.com/speech-to-text/docs/reference/rest"; + this.inputType = "string"; + this.outputType = "string"; + this.manualBake = true; + this.args = [ + { + "name": "Input Mode", + "type": "option", + "value": ["GCS URI (gs://...)", "Raw Audio Bytes (Base64)"] + }, + { + "name": "Language Code", + "type": "string", + "value": "en-US" + }, + { + "name": "Model", + "type": "option", + "value": ["latest_long", "latest_short", "telephony", "medical_dictation", "default"] + }, + { + "name": "Output Destination", + "type": "option", + "value": ["Return to CyberChef", "Write to GCS"] + }, + { + "name": "Output GCS Bucket", + "type": "string", + "value": "cyber-chef-cloud-examples" + }, + { + "name": "Max Poll Minutes", + "type": "number", + "value": 30 + }, + ...GCP_AUTH_ARGS + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + async run(input, args) { + const [ + inputMode, languageCode, model, outputDest, outputBucket, maxPollMinutes, + authType, authStringObj, quotaProject + ] = args; + + const uri = input.trim(); + if (!uri) throw new OperationError("Please provide a GCS URI or Base64 audio input."); + + const authString = typeof authStringObj === "string" ? authStringObj : (authStringObj.string || ""); + if (!authString) throw new OperationError("Please provide a valid GCP Auth String (API Key or OAuth Token)."); + + const maxMs = maxPollMinutes * 60 * 1000; + let transcript; + + if (inputMode === "GCS URI (gs://...)") { + if (!uri.startsWith("gs://")) { + throw new OperationError("Input Mode is set to GCS URI but input does not start with gs://"); + } + transcript = await this._transcribeGcsUri(uri, languageCode, model, authType, authStringObj, quotaProject, maxMs); + } else { + // Raw audio bytes (Base64) + transcript = await this._transcribeRawAudio(uri, languageCode, model, authType, authStringObj, quotaProject); + } + + if (outputDest === "Write to GCS") { + // Derive source filename from GCS URI (or use a default for raw audio) + const sourceFilename = inputMode === "GCS URI (gs://...)" + ? uri.split("/").pop() + : "raw_audio"; + + const objectPath = `output/audio/${sourceFilename}/speech-to-text/text.txt`; + const destUri = await writeGCSFile(outputBucket, objectPath, transcript, authType, authStringObj, quotaProject); + return destUri; + } + + return transcript; + } + + /** + * Transcribes audio from a GCS URI using the longrunningrecognize API. + * + * @param {string} gcsUri + * @param {string} languageCode + * @param {string} model + * @param {string} authType + * @param {Object|string} authStringObj + * @param {string} quotaProject + * @param {number} maxMs + * @returns {Promise} + */ + async _transcribeGcsUri(gcsUri, languageCode, model, authType, authStringObj, quotaProject, maxMs) { + let url = "https://speech.googleapis.com/v1/speech:longrunningrecognize"; + const headers = new Headers(); + headers.set("Content-Type", "application/json; charset=utf-8"); + const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject); + + const body = JSON.stringify({ + config: { + languageCode, + model, + enableAutomaticPunctuation: true, + }, + audio: { uri: gcsUri } + }); + + const response = await fetch(authed.url, { + method: "POST", + headers: authed.headers, + body, + mode: "cors", + cache: "no-cache" + }); + + let responseData; + try { responseData = await response.json(); } catch (e) { + throw new OperationError("GCloud Speech to Text: Failed to parse API response."); + } + if (!response.ok) { + const msg = responseData?.error?.message || response.statusText; + throw new OperationError(`GCloud Speech to Text: API Error (${response.status}): ${msg}`); + } + + const operationName = responseData.name; + if (!operationName) throw new OperationError("GCloud Speech to Text: No operation name returned from API."); + + // Poll for completion + const POLL_URL = "https://speech.googleapis.com/v1/operations/"; + const completed = await pollLongRunningOperation( + operationName, + POLL_URL, + authType, + authStringObj, + quotaProject, + maxMs, + 10000, + (elapsedSec) => { + // onProgress — not easily surfaced in CyberChef output mid-bake, + // but available for future UI integration + void elapsedSec; + } + ); + + return this._extractTranscript(completed); + } + + /** + * Transcribes audio from raw Base64 bytes using the synchronous recognize API. + * + * @param {string} base64Audio + * @param {string} languageCode + * @param {string} model + * @param {string} authType + * @param {Object|string} authStringObj + * @param {string} quotaProject + * @returns {Promise} + */ + async _transcribeRawAudio(base64Audio, languageCode, model, authType, authStringObj, quotaProject) { + let url = "https://speech.googleapis.com/v1/speech:recognize"; + const headers = new Headers(); + headers.set("Content-Type", "application/json; charset=utf-8"); + const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject); + + const body = JSON.stringify({ + config: { + languageCode, + model, + enableAutomaticPunctuation: true, + }, + audio: { content: base64Audio } + }); + + const response = await fetch(authed.url, { + method: "POST", + headers: authed.headers, + body, + mode: "cors", + cache: "no-cache" + }); + + let responseData; + try { responseData = await response.json(); } catch (e) { + throw new OperationError("GCloud Speech to Text: Failed to parse API response."); + } + if (!response.ok) { + const msg = responseData?.error?.message || response.statusText; + throw new OperationError(`GCloud Speech to Text: API Error (${response.status}): ${msg}`); + } + + return this._extractTranscript({ response: responseData }); + } + + /** + * Extracts a joined transcript string from a completed LRO response or synchronous response. + * + * @param {Object} completed - The operation response object. + * @returns {string} Joined transcript text. + */ + _extractTranscript(completed) { + const results = completed?.response?.results; + if (!results || results.length === 0) { + return "(No speech detected)"; + } + return results + .map(r => r.alternatives?.[0]?.transcript || "") + .filter(t => t.length > 0) + .join(" ") + .trim(); + } + +} + +export default GCloudSpeechToText; diff --git a/tests/browser/03_cloud_ops.js b/tests/browser/03_cloud_ops.js index 93808dd4..c5cdbcd0 100644 --- a/tests/browser/03_cloud_ops.js +++ b/tests/browser/03_cloud_ops.js @@ -100,7 +100,145 @@ module.exports = { }); }, + // ─── GCloud List Bucket ──────────────────────────────────────────────────── + + "GCloud List Bucket: Missing Key Validation": function (browser) { + browserUtils.loadRecipe(browser, "GCloud List Bucket", "cyber-chef-cloud-examples", [ + "audio/", + "GCS URIs (one per line)", + "API Key", + { option: "UTF8", string: "" }, + "" + ]); + + browser.waitForElementNotVisible("#snackbar-container", 6000); + browserUtils.bake(browser); + browser.pause(2000); + browser.saveScreenshot("tests/browser/output/list_bucket_no_key.png"); + browser.execute(function () { + return window.app.manager.output.outputEditorView.state.doc.toString(); + }, [], function ({ value }) { + browser.assert.ok(value.includes("Please provide a valid GCP Auth String")); + }); + }, + + "GCloud List Bucket: Lists audio/ files from cyber-chef-cloud-examples": function (browser) { + let testToken = process.env.CYBERCHEF_GCP_TEST_TOKEN; + + if (!testToken || testToken === "YOUR_OAUTH_TOKEN_HERE") { + try { + testToken = require("child_process").execSync("gcloud auth print-access-token", { stdio: "pipe", encoding: "utf-8" }).trim(); + } catch (e) { + console.log("No valid token found and gcloud failed. Skipping GCloud List Bucket live test."); + return; + } + } + + browserUtils.loadRecipe(browser, "GCloud List Bucket", "cyber-chef-cloud-examples", [ + "audio/", + "GCS URIs (one per line)", + "OAuth Token", + { option: "UTF8", string: testToken }, + "cyberchefcloud" + ]); + + browser.waitForElementNotVisible("#snackbar-container", 6000); + browserUtils.bake(browser); + browser.pause(5000); + browser.saveScreenshot("tests/browser/output/list_bucket_live.png"); + browser.execute(function () { + return window.app.manager.output.outputEditorView.state.doc.toString(); + }, [], function ({ value }) { + browser.assert.ok(value.includes("gs://cyber-chef-cloud-examples/audio/"), `Expected gs:// URIs, got: ${value}`); + browser.assert.ok(value.includes("she_achieves_great_results_f55548.mp3"), `Expected audio filename in output, got: ${value}`); + }); + }, + + // ─── GCloud Speech to Text ───────────────────────────────────────────────── + + "GCloud Speech to Text: GCS URI mode returns transcription to browser": function (browser) { + let testToken = process.env.CYBERCHEF_GCP_TEST_TOKEN; + + if (!testToken || testToken === "YOUR_OAUTH_TOKEN_HERE") { + try { + testToken = require("child_process").execSync("gcloud auth print-access-token", { stdio: "pipe", encoding: "utf-8" }).trim(); + } catch (e) { + console.log("No valid token found and gcloud failed. Skipping GCloud Speech to Text live test."); + return; + } + } + + const gcsUri = "gs://cyber-chef-cloud-examples/audio/she_achieves_great_results_f55548.mp3"; + + browserUtils.loadRecipe(browser, "GCloud Speech to Text", gcsUri, [ + "GCS URI (gs://...)", + "en-US", + "latest_long", + "Return to CyberChef", + "cyber-chef-cloud-examples", + 30, + "OAuth Token", + { option: "UTF8", string: testToken }, + "cyberchefcloud" + ]); + + browser.waitForElementNotVisible("#snackbar-container", 6000); + browserUtils.bake(browser); + // LRO jobs on short files typically complete in ~5 seconds; allow up to 30s here + browser.pause(30000); + browser.saveScreenshot("tests/browser/output/speech_to_text_browser.png"); + browser.execute(function () { + return window.app.manager.output.outputEditorView.state.doc.toString(); + }, [], function ({ value }) { + browser.assert.ok( + value.toLowerCase().includes("she achieves great results"), + `Expected transcript, got: ${value}` + ); + }); + }, + + "GCloud Speech to Text: GCS URI mode writes transcript to GCS output/ bucket": function (browser) { + let testToken = process.env.CYBERCHEF_GCP_TEST_TOKEN; + + if (!testToken || testToken === "YOUR_OAUTH_TOKEN_HERE") { + try { + testToken = require("child_process").execSync("gcloud auth print-access-token", { stdio: "pipe", encoding: "utf-8" }).trim(); + } catch (e) { + console.log("No valid token found and gcloud failed. Skipping GCloud Speech to Text GCS write test."); + return; + } + } + + const gcsUri = "gs://cyber-chef-cloud-examples/audio/she_achieves_great_results_f55548.mp3"; + + browserUtils.loadRecipe(browser, "GCloud Speech to Text", gcsUri, [ + "GCS URI (gs://...)", + "en-US", + "latest_long", + "Write to GCS", + "cyber-chef-cloud-examples", + 30, + "OAuth Token", + { option: "UTF8", string: testToken }, + "cyberchefcloud" + ]); + + browser.waitForElementNotVisible("#snackbar-container", 6000); + browserUtils.bake(browser); + browser.pause(30000); + browser.saveScreenshot("tests/browser/output/speech_to_text_gcs_write.png"); + browser.execute(function () { + return window.app.manager.output.outputEditorView.state.doc.toString(); + }, [], function ({ value }) { + browser.assert.ok( + value.includes("gs://cyber-chef-cloud-examples/output/audio/she_achieves_great_results_f55548.mp3/speech-to-text/text.txt"), + `Expected GCS output URI, got: ${value}` + ); + }); + }, + after: function (browser) { browser.end(); } }; +