From 83fe04ea070910be197f14881d15eb5cfdc25a28 Mon Sep 17 00:00:00 2001 From: secretpray Date: Thu, 16 Apr 2026 10:13:48 +0300 Subject: [PATCH] Align SDK and playground with live API drift --- CHANGELOG.md | 30 ++++- PUBLISHING.md | 35 ++++-- README.md | 18 +-- Rakefile | 2 +- lib/octaspace.rb | 1 + lib/octaspace/errors.rb | 26 ++++ lib/octaspace/payload_helpers.rb | 53 +++++++++ lib/octaspace/playground/payload_presets.yml | 8 ++ .../resources/services/machine_rental.rb | 34 +++++- .../resources/services/session_proxy.rb | 9 +- lib/octaspace/resources/services/vpn.rb | 2 +- lib/octaspace/version.rb | 2 +- .../app/controllers/application_controller.rb | 8 ++ .../playground/diagnostics_controller.rb | 29 +++-- .../playground/services_controller.rb | 6 +- .../app/views/layouts/application.html.erb | 6 +- .../app/views/playground/apps/show.html.erb | 19 ++- .../playground/diagnostics/show.html.erb | 8 +- .../views/playground/services/show.html.erb | 78 ++++++++++-- .../views/playground/sessions/index.html.erb | 6 + test/dummy/public/playground.css | 28 +++++ test/dummy/public/playground.js | 111 ++++++++++++++++++ test/dummy_diagnostics_test.rb | 30 ++++- test/dummy_resources_test.rb | 20 ++++ test/fixtures/apps/index.json | 8 +- test/fixtures/services/mr/index.json | 24 ++-- test/fixtures/services/render/index.json | 13 ++ test/fixtures/services/session/logs.json | 18 ++- test/fixtures/services/vpn/index.json | 13 +- test/fixtures/sessions/recent.json | 13 ++ test/octaspace/payload_helpers_test.rb | 33 ++++++ .../playground/payload_presets_test.rb | 7 ++ .../octaspace/playground/smoke_runner_test.rb | 4 +- test/octaspace/resources/apps_test.rb | 2 + test/octaspace/resources/services_test.rb | 55 ++++++++- test/octaspace/resources/sessions_test.rb | 4 +- test/test_helper.rb | 1 + 37 files changed, 689 insertions(+), 75 deletions(-) create mode 100644 lib/octaspace/payload_helpers.rb create mode 100644 test/fixtures/services/render/index.json create mode 100644 test/fixtures/sessions/recent.json create mode 100644 test/octaspace/payload_helpers_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index d95c385..1dffaf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-04-16 + +### Added + +- Support for `client.services.session(uuid).logs(recent: true)` for finished-session log retrieval. +- `OctaSpace::ProvisionRejectedError` for MR create requests that are transport-successful but rejected by the API payload contract. +- `OctaSpace::PayloadHelpers` with targeted helpers for stringified app port lists and marketplace bandwidth normalization. +- Diagnostics preset and manual runner support for recent session logs. +- Live-shaped fixtures for recent sessions and render marketplace responses. + +### Changed + +- `services.mr.list`, `services.render.list`, and `services.vpn.list` are now documented and tested as marketplace catalog endpoints rather than session collections. +- `services.mr.create` now forwards optional `organization_id` and `project_id`. +- Playground Services now presents marketplace-oriented summaries with normalized bandwidth display. +- Playground and README copy now reflect live API quirks for app ports, recent sessions, and finished-session logs. +- Publishing guidance now uses an explicit manual release flow, and the local `rake release` helper is disabled to avoid accidental RubyGems publication. + +### Fixed + +- Corrected the Ruby public API gap for recent finished-session logs. +- Corrected MR create handling for `HTTP 200` batch-style rejection responses. +- Corrected fixtures and tests that modeled `/services/mr` and `/services/vpn` as session lists. +- Corrected logs fixtures and tests to match the live `{system, container}` payload shape. +- Corrected the playground Apps page so port counts work when the live API returns stringified JSON arrays. +- Corrected test coverage for `sessions?recent=true` by using live-shaped string telemetry fields. + ## [0.1.0] - 2026-04-12 ### Added @@ -19,4 +46,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Rails integration with automatic client sharing and graceful shutdown. - Interactive Playground app for manual testing and diagnostics. -[0.1.0]: https://github.com/octaspace/ruby-sdk/compare/v0.1.0...HEAD +[0.2.0]: https://github.com/octaspace/ruby-sdk/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/octaspace/ruby-sdk/releases/tag/v0.1.0 diff --git a/PUBLISHING.md b/PUBLISHING.md index 8ef39aa..7b419ec 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -2,9 +2,29 @@ How to validate, build, and publish the `octaspace` gem. +Important release policy for this repository: + +- there is no GitHub Actions workflow that publishes to RubyGems automatically +- publish only after the feature PR is reviewed, polished, and merged into `main` +- publish from a clean local checkout of `main` +- do not use `rake release` in this repository; build, push, tag, and release explicitly as separate manual steps + +## 0. Release Sequence + +1. Finish the feature branch work and merge the PR into `main`. +2. Switch to `main` locally and pull the merged commit. +3. Update `CHANGELOG.md` and bump `lib/octaspace/version.rb` if that was not already done in the merged branch. +4. Run verification locally. +5. Build the gem. +6. Push the gem to RubyGems manually. +7. Tag the exact published commit and push the tag. +8. Create the GitHub Release manually from that tag. + ## 1. Pre-release Checklist ```bash +git switch main +git pull --ff-only bundle exec rake test # all tests pass bundle exec standardrb # zero linting violations gem build octaspace.gemspec # builds without warnings @@ -15,7 +35,7 @@ gem build octaspace.gemspec # builds without warnings Install the built gem outside the project and confirm it loads correctly: ```bash -gem install ./octaspace-0.1.0.gem +gem install ./octaspace-0.2.0.gem ruby -e "require 'octaspace'; p OctaSpace::Client.new" ``` @@ -35,24 +55,24 @@ Use these alternatives to verify the gem before publishing: - **Inspect gem contents:** ```bash - gem unpack octaspace-0.1.0.gem --target=/tmp/octaspace-check + gem unpack octaspace-0.2.0.gem --target=/tmp/octaspace-check find /tmp/octaspace-check -type f | sort rm -rf /tmp/octaspace-check ``` - **Check metadata that RubyGems.org will display:** ```bash - gem specification octaspace-0.1.0.gem + gem specification octaspace-0.2.0.gem ``` - **Install and test in a clean environment:** ```bash - gem install ./octaspace-0.1.0.gem + gem install ./octaspace-0.2.0.gem ruby -e "require 'octaspace'; p OctaSpace::Client.new; puts OctaSpace::VERSION" ``` ## 4. Publish to RubyGems.org ```bash -gem push octaspace-0.1.0.gem +gem push octaspace-0.2.0.gem ``` You will be prompted for credentials on first push. The API key is saved to `~/.gem/credentials`. @@ -60,5 +80,6 @@ You will be prompted for credentials on first push. The API key is saved to `~/. ## 5. Post-publish - Verify the gem page at [rubygems.org/gems/octaspace](https://rubygems.org/gems/octaspace). -- Tag the release: `git tag v0.1.0 && git push --tags`. -- Bump `lib/octaspace/version.rb` for the next development cycle. +- Tag the exact published commit: `git tag v0.2.0 && git push origin v0.2.0`. +- Create the GitHub Release manually from tag `v0.2.0`. +- Bump `lib/octaspace/version.rb` for the next development cycle in a follow-up commit. diff --git a/README.md b/README.md index e137db4..2b73517 100644 --- a/README.md +++ b/README.md @@ -62,17 +62,18 @@ client.sessions.list # GET /sessions session = client.services.session("uuid-here") session.info # GET /services/:uuid/info session.logs # GET /services/:uuid/logs +session.logs(recent: true) # GET /services/:uuid/logs?recent=true for finished sessions session.stop(score: 5) # POST /services/:uuid/stop # Services -client.services.mr.list # GET /services/mr +client.services.mr.list # GET /services/mr (marketplace machine catalog) client.services.mr.create( node_id: 1, disk_size: 10, image: "ubuntu:24.04", app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c" ) # POST /services/mr -client.services.vpn.list # GET /services/vpn +client.services.vpn.list # GET /services/vpn (VPN relay catalog) client.services.vpn.create(node_id: 1, subkind: "wg") # POST /services/vpn client.services.render.list # GET /services/render client.services.render.create(node_id: 1, disk_size: 100) # POST /services/render @@ -80,6 +81,9 @@ client.services.render.create(node_id: 1, disk_size: 100) # POST /services/rende # Apps client.apps.list # GET /apps +# Note: live API may serialize app port lists as JSON strings +# (for example "[]"). The raw response is preserved by the SDK. + # Network client.network.info # GET /network @@ -269,9 +273,9 @@ Pages: |---|---| | `/playground/account` | Profile + balance | | `/playground/nodes` | Node list with state badges | -| `/playground/sessions` | Active sessions | -| `/playground/services` | Machine Rentals + VPN sessions | -| `/playground/diagnostics` | Transport mode, pool stats, URL rotator state (auto-refresh every 5s) | +| `/playground/sessions` | Current + recent sessions, including live-format quirks | +| `/playground/services` | Marketplace catalogs for MR, Render, and VPN | +| `/playground/diagnostics` | Direct SDK method runner for contracts, payloads, transport mode, and pool stats | ## Development @@ -288,7 +292,7 @@ bundle exec rake test # tests only You can verify the gem in a clean Ruby environment without Rails: 1. Build the gem: `gem build octaspace.gemspec` -2. Install it locally: `gem install ./octaspace-0.1.0.gem` +2. Install it locally: `gem install ./octaspace-0.2.0.gem` 3. Test in IRB: ```ruby @@ -310,7 +314,7 @@ bundle exec appraisal rails-8-0 rake test ### Dummy Application (Playground) -The repository includes a Rails "Dummy" application for manual testing and UI prototyping. It is located in `test/dummy`.`. +The repository includes a Rails "Dummy" application for manual testing and UI prototyping. It is located in `test/dummy`. To run the dummy app: diff --git a/Rakefile b/Rakefile index 8100069..ba4d6e3 100644 --- a/Rakefile +++ b/Rakefile @@ -20,7 +20,7 @@ if (requested_tasks & %w[rdoc clobber_rdoc]).any? end end -if (requested_tasks & %w[build install release]).any? +if (requested_tasks & %w[build install]).any? require "bundler/gem_tasks" end diff --git a/lib/octaspace.rb b/lib/octaspace.rb index f045f3b..ea61c7d 100644 --- a/lib/octaspace.rb +++ b/lib/octaspace.rb @@ -2,6 +2,7 @@ require "octaspace/version" require "octaspace/errors" +require "octaspace/payload_helpers" require "octaspace/response" require "octaspace/configuration" require "octaspace/middleware/url_rotator" diff --git a/lib/octaspace/errors.rb b/lib/octaspace/errors.rb index 0eee380..ca8826e 100644 --- a/lib/octaspace/errors.rb +++ b/lib/octaspace/errors.rb @@ -24,6 +24,32 @@ class TimeoutError < NetworkError; end # API-level errors (HTTP response received, but indicates failure) class ApiError < Error; end + # Domain-level rejection where transport succeeded but the provision request + # was rejected by the API payload contract. + class ProvisionRejectedError < Error + attr_reader :rejections + + def initialize(message = nil, response: nil, rejections: []) + @rejections = Array(rejections) + super(message || build_message(@rejections), response: response) + end + + private + + def build_message(rejections) + first_reason = + rejections.filter_map do |item| + next unless item.is_a?(Hash) + + item["reason"] || item[:reason] + end.first + + return "Provision request rejected" if first_reason.to_s.empty? + + "Provision request rejected: #{first_reason}" + end + end + # 401 Unauthorized class AuthenticationError < ApiError; end diff --git a/lib/octaspace/payload_helpers.rb b/lib/octaspace/payload_helpers.rb new file mode 100644 index 0000000..7d120bf --- /dev/null +++ b/lib/octaspace/payload_helpers.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "json" + +module OctaSpace + module PayloadHelpers + module_function + + def parse_port_list(value) + case value + when nil + [] + when Array + value + when String + parse_stringified_port_list(value) + else + Array(value) + end + end + + def normalize_marketplace_bandwidth(value) + numeric = + case value + when nil + return nil + when Numeric + value.to_f + when String + Float(value) + else + return value + end + + return numeric unless numeric > 100_000 + + numeric / 125_000.0 + rescue ArgumentError, TypeError + value + end + + def parse_stringified_port_list(value) + stripped = value.strip + return [] if stripped.empty? + + parsed = JSON.parse(stripped) + parsed.is_a?(Array) ? parsed : [] + rescue JSON::ParserError + [] + end + private_class_method :parse_stringified_port_list + end +end diff --git a/lib/octaspace/playground/payload_presets.yml b/lib/octaspace/playground/payload_presets.yml index 3f24278..f32387b 100644 --- a/lib/octaspace/playground/payload_presets.yml +++ b/lib/octaspace/playground/payload_presets.yml @@ -33,6 +33,14 @@ presets: uuid: sess-abc-123 score: 5 + services.session.logs: + source: + live_api: + note: Uses the finished-session recent logs branch confirmed against the live API. + payload: + uuid: sess-abc-123 + recent: true + idle_jobs.find: source: fixture: test/fixtures/idle_jobs/show.json diff --git a/lib/octaspace/resources/services/machine_rental.rb b/lib/octaspace/resources/services/machine_rental.rb index d2f1b17..261a6cd 100644 --- a/lib/octaspace/resources/services/machine_rental.rb +++ b/lib/octaspace/resources/services/machine_rental.rb @@ -14,7 +14,7 @@ class Services # app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c" # ) class MachineRental < Base - # List available / active machine rentals + # List available marketplace machines for rent # GET /services/mr # @param params [Hash] optional filter params # @return [OctaSpace::Response] @@ -40,7 +40,37 @@ def create(**attrs) entrypoint: attrs[:entrypoint].to_s } - post("/services/mr", body: [item]) + item[:organization_id] = attrs[:organization_id] if attrs.key?(:organization_id) + item[:project_id] = attrs[:project_id] if attrs.key?(:project_id) + + response = post("/services/mr", body: [item]) + raise_if_rejected!(response) + response + end + + private + + def raise_if_rejected!(response) + rejections = extract_rejections(response.data) + return if rejections.empty? + + raise OctaSpace::ProvisionRejectedError.new(response: response, rejections: rejections) + end + + def extract_rejections(data) + return [] unless data.is_a?(Array) + + data.filter_map do |item| + next unless item.is_a?(Hash) + + reason = item["reason"] || item[:reason] + status = item["status"] || item[:status] + uuid = item["uuid"] || item[:uuid] + next if uuid + next unless reason || status.to_i.positive? + + item + end end end end diff --git a/lib/octaspace/resources/services/session_proxy.rb b/lib/octaspace/resources/services/session_proxy.rb index add9c89..e4c9670 100644 --- a/lib/octaspace/resources/services/session_proxy.rb +++ b/lib/octaspace/resources/services/session_proxy.rb @@ -31,9 +31,14 @@ def info # Fetch session logs # GET /services/:uuid/logs + # @param recent [Boolean, nil] use the recent logs branch for finished sessions # @return [OctaSpace::Response] - def logs - @transport.get("/services/#{@uuid}/logs") + def logs(recent: nil) + params = {} + params[:recent] = true if recent + return @transport.get("/services/#{@uuid}/logs") if params.empty? + + @transport.get("/services/#{@uuid}/logs", params:) end # Stop the session diff --git a/lib/octaspace/resources/services/vpn.rb b/lib/octaspace/resources/services/vpn.rb index 587b234..00d114c 100644 --- a/lib/octaspace/resources/services/vpn.rb +++ b/lib/octaspace/resources/services/vpn.rb @@ -9,7 +9,7 @@ class Services # client.services.vpn.list # client.services.vpn.create(node_id: 123) class Vpn < Base - # List active VPN sessions + # List available VPN relay nodes # GET /services/vpn # @param params [Hash] optional filter params # @return [OctaSpace::Response] diff --git a/lib/octaspace/version.rb b/lib/octaspace/version.rb index 44347b4..475e29d 100644 --- a/lib/octaspace/version.rb +++ b/lib/octaspace/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module OctaSpace - VERSION = "0.1.0" + VERSION = "0.2.0" end diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb index fad62ce..b9495db 100644 --- a/test/dummy/app/controllers/application_controller.rb +++ b/test/dummy/app/controllers/application_controller.rb @@ -19,6 +19,7 @@ class ApplicationController < ActionController::Base :playground_format_bytes, :playground_format_datetime, :playground_format_duration, + :playground_format_mbps, :playground_format_short_time, :playground_json, :playground_json_html, @@ -190,6 +191,13 @@ def playground_format_bytes(value) format("%.1f %s", bytes, units[index]) end + def playground_format_mbps(value) + normalized = OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(value) + return "—" if normalized.blank? + + format("%.1f Mbps", normalized) + end + def playground_format_datetime(value) return "—" if value.blank? diff --git a/test/dummy/app/controllers/playground/diagnostics_controller.rb b/test/dummy/app/controllers/playground/diagnostics_controller.rb index 1afc299..31185e0 100644 --- a/test/dummy/app/controllers/playground/diagnostics_controller.rb +++ b/test/dummy/app/controllers/playground/diagnostics_controller.rb @@ -30,7 +30,7 @@ class DiagnosticsController < BaseController { id: "apps.list", label: "octa_client.apps.list", - description: "GET /apps — requires API key", + description: "GET /apps — requires API key; live API may serialize ports/http_ports as JSON strings", method: "GET", path: "/apps", requires_auth: true @@ -72,15 +72,24 @@ class DiagnosticsController < BaseController { id: "sessions.list.recent", label: "octa_client.sessions.list(recent: true)", - description: "GET /sessions?recent=true — requires API key", + description: "GET /sessions?recent=true — requires API key; live telemetry fields may be serialized as strings", method: "GET", path: "/sessions?recent=true", requires_auth: true }, + { + id: "services.session.logs", + label: "octa_client.services.session(uuid).logs", + description: "GET /services/:uuid/logs — for finished sessions use recent=true", + method: "GET", + path: "/services/:uuid/logs", + requires_auth: true, + payload: true + }, { id: "services.mr.list", label: "octa_client.services.mr.list", - description: "GET /services/mr — requires API key", + description: "GET /services/mr — requires API key; returns marketplace machine catalog", method: "GET", path: "/services/mr", requires_auth: true @@ -88,7 +97,7 @@ class DiagnosticsController < BaseController { id: "services.render.list", label: "octa_client.services.render.list", - description: "GET /services/render — requires API key", + description: "GET /services/render — requires API key; returns render marketplace catalog", method: "GET", path: "/services/render", requires_auth: true @@ -96,7 +105,7 @@ class DiagnosticsController < BaseController { id: "services.vpn.list", label: "octa_client.services.vpn.list", - description: "GET /services/vpn — requires API key", + description: "GET /services/vpn — requires API key; returns VPN relay catalog", method: "GET", path: "/services/vpn", requires_auth: true @@ -104,7 +113,7 @@ class DiagnosticsController < BaseController { id: "services.mr.create", label: "octa_client.services.mr.create", - description: "POST /services/mr — requires API key", + description: "POST /services/mr — requires API key; live API uses array payloads and may reject item-level with HTTP 200", method: "POST", path: "/services/mr", requires_auth: true, @@ -218,6 +227,9 @@ def execute_call(call) octa_client.sessions.list when "sessions.list.recent" octa_client.sessions.list(recent: true) + when "services.session.logs" + uuid = payload.fetch(:uuid).to_s + octa_client.services.session(uuid).logs(recent: payload[:recent]) when "services.mr.list" octa_client.services.mr.list when "services.render.list" @@ -274,7 +286,7 @@ def parse_payload!(call, payload_json) def validate_mutation_payload!(call, payload) case call[:id] - when "services.session.stop" + when "services.session.logs", "services.session.stop" raise OctaSpace::ValidationError.new("Payload must include a non-empty uuid") if payload[:uuid].to_s.strip.empty? when "idle_jobs.find", "idle_jobs.logs" raise OctaSpace::ValidationError.new("Payload must include node_id") if payload[:node_id].to_s.strip.empty? @@ -284,6 +296,9 @@ def validate_mutation_payload!(call, payload) def resolved_path(call, payload) case call[:id] + when "services.session.logs" + path = "/services/#{payload.fetch(:uuid)}/logs" + payload[:recent] ? "#{path}?recent=true" : path when "services.session.stop" "/services/#{payload.fetch(:uuid)}/stop" when "idle_jobs.find" diff --git a/test/dummy/app/controllers/playground/services_controller.rb b/test/dummy/app/controllers/playground/services_controller.rb index 5325747..f324800 100644 --- a/test/dummy/app/controllers/playground/services_controller.rb +++ b/test/dummy/app/controllers/playground/services_controller.rb @@ -4,9 +4,9 @@ module Playground class ServicesController < BaseController def show @active_tab = params[:tab].presence_in(%w[mr render vpn]) || "mr" - @mr_sessions = capture_api_call(label: "octa_client.services.mr.list", path: "/services/mr") { octa_client.services.mr.list } - @render_sessions = capture_api_call(label: "octa_client.services.render.list", path: "/services/render") { octa_client.services.render.list } - @vpn_sessions = capture_api_call(label: "octa_client.services.vpn.list", path: "/services/vpn") { octa_client.services.vpn.list } + @mr_catalog = capture_api_call(label: "octa_client.services.mr.list", path: "/services/mr") { octa_client.services.mr.list } + @render_catalog = capture_api_call(label: "octa_client.services.render.list", path: "/services/render") { octa_client.services.render.list } + @vpn_catalog = capture_api_call(label: "octa_client.services.vpn.list", path: "/services/vpn") { octa_client.services.vpn.list } end end end diff --git a/test/dummy/app/views/layouts/application.html.erb b/test/dummy/app/views/layouts/application.html.erb index 9bf1611..62eb156 100644 --- a/test/dummy/app/views/layouts/application.html.erb +++ b/test/dummy/app/views/layouts/application.html.erb @@ -116,7 +116,11 @@
-
+
+
<%= yield %>
diff --git a/test/dummy/app/views/playground/apps/show.html.erb b/test/dummy/app/views/playground/apps/show.html.erb index d1c5eea..1b1a496 100644 --- a/test/dummy/app/views/playground/apps/show.html.erb +++ b/test/dummy/app/views/playground/apps/show.html.erb @@ -8,8 +8,18 @@

octa_client.apps.list — available application catalog for service sessions.

+

+ Port counts below normalize live payloads even when the API serializes ports and http_ports as JSON strings. +

+ <% if apps_data.any? %> + + <% end %> <% if playground_mock_active? %> Mock: <%= playground_mock_scenario %> <% end %> @@ -52,7 +62,10 @@ "><%= app["uuid"].presence || "—" %> <%= app["image"] || app["docker_image"] || "—" %> - <%= [Array(app["ports"]).length, Array(app["http_ports"]).length].sum %> + <%= [ + OctaSpace::PayloadHelpers.parse_port_list(app["ports"]).length, + OctaSpace::PayloadHelpers.parse_port_list(app["http_ports"]).length + ].sum %> <% if app_uuid.present? %> @@ -70,7 +83,9 @@
-
+
Raw JSON
<%= render "playground/shared/json_viewer", data: apps_data, max_height: "max-h-80" %>
diff --git a/test/dummy/app/views/playground/diagnostics/show.html.erb b/test/dummy/app/views/playground/diagnostics/show.html.erb index 1d71016..4169332 100644 --- a/test/dummy/app/views/playground/diagnostics/show.html.erb +++ b/test/dummy/app/views/playground/diagnostics/show.html.erb @@ -28,10 +28,12 @@

SDK Method

-
+
<% @calls.each do |call| %> <% selected = call[:id] == @selected_call_id %> -
+
<%= link_to(playground_diagnostics_path(call: call[:id]), class: "block") do %>
@@ -59,7 +61,7 @@ rows: 8, spellcheck: false, class: "w-full rounded-lg border border-slate-800 bg-slate-950/80 px-3 py-2 font-mono text-[11px] leading-5 text-slate-200 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500" %> -

Top-level JSON keys are forwarded as Ruby keyword arguments. For services.session.stop, include uuid. For idle_jobs.*, include node_id and job_id.

+

Top-level JSON keys are forwarded as Ruby keyword arguments. For services.session.stop and services.session.logs, include uuid. For services.session.logs, set recent: true when checking finished sessions. For services.mr.create, optional organization_id and project_id are forwarded. For idle_jobs.*, include node_id and job_id.

<% end %> <%= button_tag "Run Method", type: "submit", class: "w-full rounded border border-cyan-500/20 bg-cyan-500/10 py-1.5 text-xs font-medium text-cyan-400 transition-colors hover:bg-cyan-500/20 hover:text-cyan-300" %> diff --git a/test/dummy/app/views/playground/services/show.html.erb b/test/dummy/app/views/playground/services/show.html.erb index d8f63ea..7e91cf3 100644 --- a/test/dummy/app/views/playground/services/show.html.erb +++ b/test/dummy/app/views/playground/services/show.html.erb @@ -1,20 +1,49 @@ -<% mr_data = @mr_sessions.respond_to?(:data) ? Array(@mr_sessions.data) : [] %> -<% render_data = @render_sessions.respond_to?(:data) ? Array(@render_sessions.data) : [] %> -<% vpn_data = @vpn_sessions.respond_to?(:data) ? Array(@vpn_sessions.data) : [] %> +<% mr_data = @mr_catalog.respond_to?(:data) ? Array(@mr_catalog.data) : [] %> +<% render_data = @render_catalog.respond_to?(:data) ? Array(@render_catalog.data) : [] %> +<% vpn_data = @vpn_catalog.respond_to?(:data) ? Array(@vpn_catalog.data) : [] %> <% tabs = [ - {id: "mr", label: "Machine Rental", hook: "octa_client.services.mr.list", path: "/services/mr", data: mr_data, error: (@mr_sessions if @mr_sessions.is_a?(OctaSpace::Error))}, - {id: "render", label: "Render", hook: "octa_client.services.render.list", path: "/services/render", data: render_data, error: (@render_sessions if @render_sessions.is_a?(OctaSpace::Error))}, - {id: "vpn", label: "VPN", hook: "octa_client.services.vpn.list", path: "/services/vpn", data: vpn_data, error: (@vpn_sessions if @vpn_sessions.is_a?(OctaSpace::Error))} + {id: "mr", label: "Machine Rental Marketplace", hook: "octa_client.services.mr.list", path: "/services/mr", data: mr_data, error: (@mr_catalog if @mr_catalog.is_a?(OctaSpace::Error))}, + {id: "render", label: "Render Marketplace", hook: "octa_client.services.render.list", path: "/services/render", data: render_data, error: (@render_catalog if @render_catalog.is_a?(OctaSpace::Error))}, + {id: "vpn", label: "VPN Relay Marketplace", hook: "octa_client.services.vpn.list", path: "/services/vpn", data: vpn_data, error: (@vpn_catalog if @vpn_catalog.is_a?(OctaSpace::Error))} ] %> <% active = tabs.find { |tab| tab[:id] == @active_tab } || tabs.first %> <% active_status = active[:error] ? "error" : "success" %> +<% + offer_label = lambda do |tab_id, item| + case tab_id + when "vpn" + item["residential"] ? "Residential" : "Datacenter" + else + item["gpu"].presence || item["gpus"]&.first&.dig("model") || "CPU" + end + end + + pricing_label = lambda do |tab_id, item| + case tab_id + when "vpn" + usd = item["traffic_price_usd"] + ether = item["traffic_price_ether"] + parts = [] + parts << "$#{usd}/GB" if usd.present? + parts << "#{ether} ETH/GB" if ether.present? + parts.presence&.join(" · ") || "—" + else + base = item["base_usd"] || item["base"] + traffic = item["traffic_usd"] || item["traffic"] + parts = [] + parts << "$#{base}/hr" if base.present? + parts << "$#{traffic}/GB" if traffic.present? + parts.presence&.join(" · ") || "—" + end + end +%>

Services

- Available resources for each service type. Uses the Ruby SDK client against the currently selected transport. + Marketplace catalog endpoints for MR, Render, and VPN. Uses the Ruby SDK client against the currently selected transport.

@@ -29,6 +58,10 @@ Start mutations (mr.create, render.create, vpn.create) exist on octa_client.services.* but are intentionally not wired up here — use Diagnostics for manual request inspection.
+
+ Bandwidth below is normalized for display with OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth when live payload values look like raw bytes-per-second. Raw JSON remains unchanged below. +
+
<% tabs.each do |tab| %> @@ -55,6 +88,37 @@
<%= active[:data].is_a?(Array) ? "#{active[:data].length} items" : "#{active[:data].to_h.keys.length} keys" %>
+ <% if active[:data].is_a?(Array) && active[:data].any? %> +
+
Marketplace Summary
+
+ + + + + + + + + + + + <% active[:data].each do |item| %> + + + + + + + + <% end %> + +
NodeLocationOfferBandwidth ↓/↑Pricing
#<%= item["node_id"] || "—" %><%= [item["city"], item["country"]].compact_blank.join(", ").presence || "—" %><%= offer_label.call(active[:id], item) %> + <%= playground_format_mbps(item["net_down_mbits"]) %> / <%= playground_format_mbps(item["net_up_mbits"]) %> + <%= pricing_label.call(active[:id], item) %>
+
+
+ <% end %>
<%= render "playground/shared/json_viewer", data: active[:data], max_height: "max-h-[32rem]" %>
diff --git a/test/dummy/app/views/playground/sessions/index.html.erb b/test/dummy/app/views/playground/sessions/index.html.erb index 38399d7..b0e64cf 100644 --- a/test/dummy/app/views/playground/sessions/index.html.erb +++ b/test/dummy/app/views/playground/sessions/index.html.erb @@ -59,6 +59,12 @@
<% end %> + <% if active_recent %> +
+ Recent session payloads may serialize telemetry like duration, rx, and tx as strings. For finished-session logs, use Diagnostics with services.session.logs and recent: true. +
+ <% end %> +
<% tabs.each do |tab| %> diff --git a/test/dummy/public/playground.css b/test/dummy/public/playground.css index a662644..5b28ece 100644 --- a/test/dummy/public/playground.css +++ b/test/dummy/public/playground.css @@ -8,3 +8,31 @@ ::-webkit-scrollbar-track { background: rgb(15 23 42); } ::-webkit-scrollbar-thumb { background: rgb(51 65 85); border-radius: 9999px; } ::-webkit-scrollbar-thumb:hover { background: rgb(71 85 105); } + +/* Page loader ---------------------------------------------------------------- + Visibility is controlled from JS after 0.4 s, so fast responses never + produce a visible flash. */ +#page-loader { + display: none; + position: absolute; + inset: 0; + z-index: 20; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(2, 6, 23, 0.78); +} +#page-loader.loader-active { + display: flex; + opacity: 1; +} + +.loader-spinner { + width: 28px; + height: 28px; + border: 2.5px solid rgba(148, 163, 184, 0.18); + border-top-color: rgb(34, 211, 238); + border-radius: 50%; + animation: loader-spin 0.7s linear infinite; +} +@keyframes loader-spin { to { transform: rotate(360deg); } } diff --git a/test/dummy/public/playground.js b/test/dummy/public/playground.js index 52c41b1..c5133ce 100644 --- a/test/dummy/public/playground.js +++ b/test/dummy/public/playground.js @@ -32,3 +32,114 @@ document.addEventListener("change", (event) => { field.form.requestSubmit() }) + +// Page loader: appears only after 400 ms of waiting, so fast responses do not flash. +const LOADER_SKIP = ["/playground/settings", "/playground/request-log"] +const LOADER_DELAY_MS = 400 +let loaderTimer = null + +const activateLoader = () => { + const element = document.getElementById("page-loader") + if (!element) return + + element.classList.add("loader-active") + element.setAttribute("aria-hidden", "false") +} + +const scheduleLoader = () => { + if (loaderTimer !== null) return + + loaderTimer = window.setTimeout(() => { + loaderTimer = null + activateLoader() + }, LOADER_DELAY_MS) +} + +const hideLoader = () => { + if (loaderTimer !== null) { + window.clearTimeout(loaderTimer) + loaderTimer = null + } + + const element = document.getElementById("page-loader") + if (!element) return + + element.classList.remove("loader-active") + element.setAttribute("aria-hidden", "true") +} + +document.addEventListener("submit", (event) => { + const form = event.target + if (!form || !form.action) return + + try { + const pathname = new URL(form.action, window.location.href).pathname + if (LOADER_SKIP.some((value) => pathname === value)) return + } catch (_error) { + return + } + + scheduleLoader() +}) + +document.addEventListener("click", (event) => { + if (!event.target.closest("[data-scroll-target]")) { + if (!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) { + const link = event.target.closest("a[href]") + + if (link) { + const href = link.getAttribute("href") + + if (href && !href.startsWith("#") && !href.startsWith("mailto:") && link.target !== "_blank") { + try { + const url = new URL(href, window.location.href) + if (url.origin === window.location.origin) { + scheduleLoader() + } + } catch (_error) { + } + } + } + } + } + + const trigger = event.target.closest("[data-scroll-target]") + if (!trigger) return + + const targetId = trigger.dataset.scrollTarget + if (!targetId) return + + const target = document.getElementById(targetId) + if (!target) return + + event.preventDefault() + target.scrollIntoView({behavior: "smooth", block: "start"}) + + if (typeof target.focus === "function") { + target.focus({preventScroll: true}) + } +}) + +const syncDiagnosticsSelection = () => { + const list = document.querySelector("[data-diagnostics-list]") + const selected = document.querySelector("[data-diagnostics-item][data-selected='true']") + if (!list || !selected) return + + window.requestAnimationFrame(() => { + selected.scrollIntoView({block: "nearest", inline: "nearest"}) + }) +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", syncDiagnosticsSelection, {once: true}) +} else { + syncDiagnosticsSelection() +} + +window.addEventListener("pageshow", (event) => { + if (event.persisted) { + hideLoader() + } +}) + +window.addEventListener("pagehide", hideLoader) diff --git a/test/dummy_diagnostics_test.rb b/test/dummy_diagnostics_test.rb index 0d12b16..912db61 100644 --- a/test/dummy_diagnostics_test.rb +++ b/test/dummy_diagnostics_test.rb @@ -50,6 +50,22 @@ def test_diagnostics_run_executes_session_stop_mutation assert_includes @session.response.body, "stopped" end + def test_diagnostics_run_executes_recent_session_logs_call + payload = OctaSpace::Playground::PayloadPresets.payload_for("services.session.logs") + stub_request(:get, "https://api.octa.space/services/sess-abc-123/logs") + .with(query: {"recent" => "true"}) + .to_return(status: 200, body: fixture("services/session/logs.json"), headers: {"Content-Type" => "application/json"}) + + @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) + @session.post("/playground/diagnostics/run", params: {call: "services.session.logs", payload_json: JSON.pretty_generate(payload)}) + + assert_equal 200, @session.response.status + assert_includes @session.response.body, "Success" + assert_includes @session.response.body, "octa_client.services.session(uuid).logs" + assert_includes @session.response.body, "services/sess-abc-123/logs?recent=true" + assert_includes @session.response.body, "Container started" + end + def test_diagnostics_run_reports_invalid_payload_json @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) @session.post("/playground/diagnostics/run", params: {call: "services.vpn.create", payload_json: "{not json}"}) @@ -73,14 +89,14 @@ def test_diagnostics_smoke_runs_read_only_sdk_smoke_and_renders_json stub_request(:get, "https://api.octa.space/services/mr") .to_return(status: 200, body: fixture("services/mr/index.json"), headers: {"Content-Type" => "application/json"}) stub_request(:get, "https://api.octa.space/services/render") - .to_return(status: 200, body: fixture("services/mr/index.json"), headers: {"Content-Type" => "application/json"}) + .to_return(status: 200, body: fixture("services/render/index.json"), headers: {"Content-Type" => "application/json"}) stub_request(:get, "https://api.octa.space/services/vpn") .to_return(status: 200, body: fixture("services/vpn/index.json"), headers: {"Content-Type" => "application/json"}) stub_request(:get, "https://api.octa.space/sessions") .to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"}) stub_request(:get, "https://api.octa.space/sessions") .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"}) + .to_return(status: 200, body: fixture("sessions/recent.json"), headers: {"Content-Type" => "application/json"}) @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) @session.post("/playground/diagnostics/smoke", params: {call: "network.info"}) @@ -117,4 +133,14 @@ def test_diagnostics_show_prefills_payload_from_query_string assert_includes @session.response.body, "249b4cb3-3db1-4c06-98a4-772ba88cd81c" assert_includes @session.response.body, "ubuntu:24.04" end + + def test_diagnostics_show_marks_selected_card_for_scroll_restoration + @session.get("/playground/diagnostics", params: {call: "services.session.logs"}) + + assert_equal 200, @session.response.status + assert_includes @session.response.body, "data-diagnostics-list" + assert_includes @session.response.body, "data-diagnostics-item" + assert_includes @session.response.body, 'data-selected="true"' + assert_includes @session.response.body, "octa_client.services.session(uuid).logs" + end end diff --git a/test/dummy_resources_test.rb b/test/dummy_resources_test.rb index cfc4323..04330b3 100644 --- a/test/dummy_resources_test.rb +++ b/test/dummy_resources_test.rb @@ -27,6 +27,8 @@ def test_apps_page_renders_app_catalog assert_includes @session.response.body, "Use in MR Create" assert_includes @session.response.body, CGI.escape("249b4cb3-3db1-4c06-98a4-772ba88cd81c") assert_includes @session.response.body, CGI.escape("ubuntu:24.04") + assert_includes @session.response.body, 'data-scroll-target="apps-raw-json"' + assert_includes @session.response.body, 'id="apps-raw-json"' end def test_layout_brand_link_points_to_root @@ -38,6 +40,8 @@ def test_layout_brand_link_points_to_root assert_equal 200, @session.response.status assert_includes @session.response.body, 'href="/"' assert_includes @session.response.body, "SDK Playground" + assert_includes @session.response.body, 'id="page-loader"' + assert_includes @session.response.body, "Loading…" end def test_idle_jobs_page_renders_status_and_logs @@ -53,4 +57,20 @@ def test_idle_jobs_page_renders_status_and_logs assert_includes @session.response.body, "Training complete." assert_includes @session.response.body, "completed" end + + def test_services_page_renders_marketplace_summary + stub_request(:get, "https://api.octa.space/services/mr") + .to_return(status: 200, body: fixture("services/mr/index.json"), headers: {"Content-Type" => "application/json"}) + stub_request(:get, "https://api.octa.space/services/render") + .to_return(status: 200, body: fixture("services/render/index.json"), headers: {"Content-Type" => "application/json"}) + stub_request(:get, "https://api.octa.space/services/vpn") + .to_return(status: 200, body: fixture("services/vpn/index.json"), headers: {"Content-Type" => "application/json"}) + + @session.get("/playground/services") + + assert_equal 200, @session.response.status + assert_includes @session.response.body, "Machine Rental Marketplace" + assert_includes @session.response.body, "Marketplace Summary" + assert_includes @session.response.body, "6977.8 Mbps" + end end diff --git a/test/fixtures/apps/index.json b/test/fixtures/apps/index.json index 76382d9..ab250f0 100644 --- a/test/fixtures/apps/index.json +++ b/test/fixtures/apps/index.json @@ -7,7 +7,9 @@ "category": "AI", "image": "ubuntu:24.04", "docker_image": "octaspace/stable-diffusion:2.1", - "min_vram_gb": 8 + "min_vram_gb": 8, + "ports": "[5000,6000]", + "http_ports": "[8080]" }, { "id": 2, @@ -17,6 +19,8 @@ "category": "AI", "image": "ubuntu:22.04", "docker_image": "octaspace/comfyui:latest", - "min_vram_gb": 6 + "min_vram_gb": 6, + "ports": "[]", + "http_ports": "[]" } ] diff --git a/test/fixtures/services/mr/index.json b/test/fixtures/services/mr/index.json index dc5d335..f129952 100644 --- a/test/fixtures/services/mr/index.json +++ b/test/fixtures/services/mr/index.json @@ -1,11 +1,21 @@ [ { - "uuid": "mr-sess-111", - "node_id": 5, - "app_id": 1, - "app_name": "Stable Diffusion", - "status": "running", - "started_at": "2026-04-11T10:00:00Z", - "urls": {"jupyter": "https://mr-sess-111.octa.space"} + "node_id": 9010, + "country": "France", + "city": "Paris", + "net_down_mbits": 872229553, + "net_up_mbits": 62488613, + "gpu": "NVIDIA GeForce RTX 5090", + "base_usd": 3400, + "traffic_usd": 1, + "reliability": { + "uptime": 100, + "rating": {"count": 0, "score": 0}, + "s_avg_d": 10117, + "s_failed": 1, + "s_max_d": 366353, + "s_normal": 381, + "stability": 99.74 + } } ] diff --git a/test/fixtures/services/render/index.json b/test/fixtures/services/render/index.json new file mode 100644 index 0000000..012e2a8 --- /dev/null +++ b/test/fixtures/services/render/index.json @@ -0,0 +1,13 @@ +[ + { + "node_id": 9042, + "country": "Germany", + "city": "Frankfurt", + "gpu": "NVIDIA RTX A6000", + "net_down_mbits": 125000000, + "net_up_mbits": 62500000, + "base_usd": 1200, + "traffic_usd": 1, + "reliability": 99.5 + } +] diff --git a/test/fixtures/services/session/logs.json b/test/fixtures/services/session/logs.json index 7f0777e..a7c87b9 100644 --- a/test/fixtures/services/session/logs.json +++ b/test/fixtures/services/session/logs.json @@ -1,5 +1,13 @@ -[ - {"timestamp": "2026-04-11T08:00:01Z", "level": "info", "message": "Container started"}, - {"timestamp": "2026-04-11T08:00:05Z", "level": "info", "message": "Model loaded"}, - {"timestamp": "2026-04-11T08:01:00Z", "level": "debug", "message": "First inference complete"} -] +{ + "system": [ + { + "ts": 1776161866508, + "msg": "24.04: Pulling from library/ubuntu\nDigest: sha256:84e77dee7d1bc93fb029a45e3c6cb9d8aa4831ccfcc7103d36e876938d28895b\nStatus: Image is up to date for ubuntu:24.04\n" + }, + { + "ts": 1776161866701, + "msg": "Container started\n" + } + ], + "container": "" +} diff --git a/test/fixtures/services/vpn/index.json b/test/fixtures/services/vpn/index.json index 197b0de..d946171 100644 --- a/test/fixtures/services/vpn/index.json +++ b/test/fixtures/services/vpn/index.json @@ -1,9 +1,12 @@ [ { - "uuid": "vpn-sess-222", - "node_id": 7, - "status": "active", - "ip": "10.8.0.2", - "started_at": "2026-04-11T09:00:00Z" + "node_id": 9539, + "country": "Poland", + "city": "Zakrzew", + "residential": false, + "net_down_mbits": 0, + "net_up_mbits": 0, + "traffic_price_usd": 0.005366743801677206, + "traffic_price_ether": 0.05 } ] diff --git a/test/fixtures/sessions/recent.json b/test/fixtures/sessions/recent.json new file mode 100644 index 0000000..9fcdfcf --- /dev/null +++ b/test/fixtures/sessions/recent.json @@ -0,0 +1,13 @@ +[ + { + "uuid": "5dd2d308-db91-4bda-86ff-e512d711b363", + "service": "mr", + "duration": "28", + "rx": "0", + "tx": "0", + "started_at": 1776161866303, + "finished_at": 1776161893995, + "charge_amount": 0, + "urls": {"8080": "https://sess-abc-123.octa.space"} + } +] diff --git a/test/octaspace/payload_helpers_test.rb b/test/octaspace/payload_helpers_test.rb new file mode 100644 index 0000000..9699653 --- /dev/null +++ b/test/octaspace/payload_helpers_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "test_helper" + +class OctaSpace::PayloadHelpersTest < Minitest::Test + def test_parse_port_list_accepts_arrays + assert_equal [5000, 6000], OctaSpace::PayloadHelpers.parse_port_list([5000, 6000]) + end + + def test_parse_port_list_parses_json_strings + assert_equal [8080, 8888], OctaSpace::PayloadHelpers.parse_port_list("[8080,8888]") + end + + def test_parse_port_list_returns_empty_array_for_invalid_string + assert_equal [], OctaSpace::PayloadHelpers.parse_port_list("not-json") + end + + def test_parse_port_list_returns_empty_array_for_nil + assert_equal [], OctaSpace::PayloadHelpers.parse_port_list(nil) + end + + def test_normalize_marketplace_bandwidth_preserves_small_values + assert_equal 250.0, OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(250) + end + + def test_normalize_marketplace_bandwidth_converts_large_values + assert_in_delta 6977.836424, OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(872_229_553), 0.000001 + end + + def test_normalize_marketplace_bandwidth_returns_nil_for_nil + assert_nil OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(nil) + end +end diff --git a/test/octaspace/playground/payload_presets_test.rb b/test/octaspace/playground/payload_presets_test.rb index d476a9b..1753cdb 100644 --- a/test/octaspace/playground/payload_presets_test.rb +++ b/test/octaspace/playground/payload_presets_test.rb @@ -23,4 +23,11 @@ def test_payload_json_for_returns_pretty_json_object assert_includes json, "\"uuid\"" assert_equal({"uuid" => "sess-abc-123", "score" => 5}, JSON.parse(json)) end + + def test_logs_preset_defaults_to_recent_finished_session_flow + payload = OctaSpace::Playground::PayloadPresets.payload_for("services.session.logs") + + assert_equal "sess-abc-123", payload[:uuid] + assert_equal true, payload[:recent] + end end diff --git a/test/octaspace/playground/smoke_runner_test.rb b/test/octaspace/playground/smoke_runner_test.rb index 004c62b..344f223 100644 --- a/test/octaspace/playground/smoke_runner_test.rb +++ b/test/octaspace/playground/smoke_runner_test.rb @@ -16,12 +16,12 @@ def test_run_returns_passed_summary_for_read_only_suites stub_get("/apps", fixture_path: "apps/index.json") stub_get("/nodes", fixture_path: "nodes/index.json") stub_get("/services/mr", fixture_path: "services/mr/index.json") - stub_get("/services/render", fixture_path: "services/mr/index.json") + stub_get("/services/render", fixture_path: "services/render/index.json") stub_get("/services/vpn", fixture_path: "services/vpn/index.json") stub_get("/sessions", fixture_path: "sessions/index.json") stub_request(:get, "#{StubHelpers::BASE_URL}/sessions") .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("sessions/index.json"), headers: json_headers) + .to_return(status: 200, body: fixture("sessions/recent.json"), headers: json_headers) result = OctaSpace::Playground::SmokeRunner.new(client: @client).run diff --git a/test/octaspace/resources/apps_test.rb b/test/octaspace/resources/apps_test.rb index cb4de5d..9ed104a 100644 --- a/test/octaspace/resources/apps_test.rb +++ b/test/octaspace/resources/apps_test.rb @@ -15,6 +15,8 @@ def test_list_returns_array assert_kind_of Array, response.data assert_equal 2, response.data.size assert_equal "Stable Diffusion", response.data.first["name"] + assert_equal "[5000,6000]", response.data.first["ports"] + assert_equal "[8080]", response.data.first["http_ports"] end def test_list_passes_params diff --git a/test/octaspace/resources/services_test.rb b/test/octaspace/resources/services_test.rb index 70fb4ee..45fa89e 100644 --- a/test/octaspace/resources/services_test.rb +++ b/test/octaspace/resources/services_test.rb @@ -28,11 +28,16 @@ def test_mr_list_returns_array response = @client.services.mr.list assert response.success? assert_kind_of Array, response.data - assert_equal "mr-sess-111", response.data.first["uuid"] + assert_equal 9010, response.data.first["node_id"] + assert_equal "France", response.data.first["country"] + assert_kind_of Hash, response.data.first["reliability"] end def test_mr_create_returns_success - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create") + payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create").merge( + organization_id: 77, + project_id: 88 + ) stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr") .with(body: [ { @@ -45,13 +50,38 @@ def test_mr_create_returns_success ports: [], http_ports: [], start_command: "", - entrypoint: "" + entrypoint: "", + organization_id: 77, + project_id: 88 } ].to_json) .to_return(status: 201, body: '{"uuid":"new-sess","status":"starting"}', headers: json_headers) response = @client.services.mr.create(**payload) assert response.success? + assert_equal "new-sess", response.data["uuid"] + end + + def test_mr_create_raises_api_error_on_400_object_rejection + payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create") + stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr") + .to_return(status: 400, body: '{"message":"Node not found"}', headers: json_headers) + + assert_raises(OctaSpace::ApiError) { @client.services.mr.create(**payload) } + end + + def test_mr_create_raises_provision_rejected_error_on_batch_rejection + payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create") + stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr") + .to_return( + status: 200, + body: '[{"id":0,"reason":"Node not found","status":1}]', + headers: json_headers + ) + + error = assert_raises(OctaSpace::ProvisionRejectedError) { @client.services.mr.create(**payload) } + assert_includes error.message, "Node not found" + assert_equal 1, error.rejections.length end def test_mr_list_raises_not_found_on_404 @@ -66,7 +96,8 @@ def test_vpn_list_returns_array response = @client.services.vpn.list assert response.success? assert_kind_of Array, response.data - assert_equal "vpn-sess-222", response.data.first["uuid"] + assert_equal 9539, response.data.first["node_id"] + refute response.data.first["residential"] end def test_vpn_create_returns_success @@ -79,9 +110,10 @@ def test_vpn_create_returns_success # --- Render --- def test_render_list_returns_success - stub_get("/services/render", status: 200, fixture_path: "services/mr/index.json") + stub_get("/services/render", status: 200, fixture_path: "services/render/index.json") response = @client.services.render.list assert response.success? + assert_equal "NVIDIA RTX A6000", response.data.first["gpu"] end def test_render_create_returns_success @@ -105,7 +137,18 @@ def test_session_proxy_logs stub_get("/services/sess-abc-123/logs", fixture_path: "services/session/logs.json") response = @client.services.session("sess-abc-123").logs assert response.success? - assert_kind_of Array, response.data + assert_kind_of Hash, response.data + assert_kind_of Array, response.data["system"] + end + + def test_session_proxy_logs_with_recent_flag + stub_request(:get, "#{StubHelpers::BASE_URL}/services/sess-abc-123/logs") + .with(query: {"recent" => "true"}) + .to_return(status: 200, body: fixture("services/session/logs.json"), headers: json_headers) + + response = @client.services.session("sess-abc-123").logs(recent: true) + assert response.success? + assert_equal "", response.data["container"] end def test_session_proxy_stop diff --git a/test/octaspace/resources/sessions_test.rb b/test/octaspace/resources/sessions_test.rb index cd5a32c..426c850 100644 --- a/test/octaspace/resources/sessions_test.rb +++ b/test/octaspace/resources/sessions_test.rb @@ -19,9 +19,11 @@ def test_list_returns_array def test_list_passes_params stub_request(:get, "#{StubHelpers::BASE_URL}/sessions") .with(query: {"recent" => "true"}) - .to_return(status: 200, body: "[]", headers: json_headers) + .to_return(status: 200, body: fixture("sessions/recent.json"), headers: json_headers) response = @client.sessions.list(recent: true) assert response.success? + assert_equal "28", response.data.first["duration"] + assert_equal "0", response.data.first["rx"] end def test_list_raises_authentication_error_on_401 diff --git a/test/test_helper.rb b/test/test_helper.rb index cf3b0dc..bf1c5be 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,6 +4,7 @@ require "octaspace" require "octaspace/transport/mock_transport" +require "octaspace/playground/payload_presets" require "minitest/autorun" require "minitest/mock" require "webmock/minitest"