ClientUtils

An ExUnit formatter with JSON output and distributed test coordination. Designed for editor integrations and CI/CD pipelines that need machine-readable test results and the ability to handle concurrent test requests.

Features

Installation

def deps do
  [{:client_utils, "~> 0.5.0"}]
end

Basic Usage

Configure the formatter in test/test_helper.exs:

ExUnit.start(formatters: [ClientUtils.TestFormatter])

File Output

ExUnit.start(formatters: [{ClientUtils.TestFormatter, output_file: "test-results.json"}])

Or via environment variable:

EXUNIT_JSON_OUTPUT_FILE=test-results.json mix test

Streaming Mode

Stream test events in real-time while writing the final summary to a file:

ExUnit.start(formatters: [{ClientUtils.TestFormatter, streaming: true, output_file: "test-results.json"}])

JSON Output Format

Final Summary

{
  "stats": {
    "duration": 1234.56,
    "start": "2024-01-15T10:30:00.000000",
    "end": "2024-01-15T10:30:01.234000",
    "passes": 40,
    "failures": 2,
    "pending": 3,
    "tests": 45,
    "suites": 5
  },
  "tests": [
    {"title": "test name", "fullTitle": "ModuleName: test name"}
  ],
  "failures": [
    {
      "title": "failing test",
      "fullTitle": "ModuleName: failing test",
      "error": {
        "file": "test/my_test.exs",
        "line": 42,
        "message": "Assertion failed"
      }
    }
  ],
  "pending": [
    {"title": "skipped test", "fullTitle": "ModuleName: skipped test", "pending": true}
  ]
}

Streaming Events

{"type":"suite:start","start":"2024-01-15T10:30:00.000000"}
{"type":"test:pass","test":{"title":"works","fullTitle":"MyModule: works"}}
{"type":"test:fail","test":{"title":"breaks","fullTitle":"MyModule: breaks","error":{...}}}
{"type":"suite:end","stats":{...}}

Configuration

Environment Variable Description
EXUNIT_JSON_OUTPUT_FILE Path for JSON output file
EXUNIT_JSON_STREAMING Enable streaming mode
CMS_HARNESS_NODE The harness node a run reports to; set, the run joins it and outlives it
CMS_RUN_OUTPUT File the linked run writes its own stdout and stderr to
CMS_RUN_RESULT File a linked run writes its unacknowledged events to if no harness takes them

License

Apache 2.0

Shared support widget

The widget implementation lives in this library. Host applications provide only an authentication adapter and configuration. Chat and issue reports go directly from the host backend to CodeMySpec; there is no offline store.

{:client_utils, github: "Code-My-Spec/client_utils", ref: "REVIEWED_COMMIT_SHA"}

Use the reviewed commit selected by your installer and commit mix.lock. Update the explicit ref deliberately, run mix deps.update client_utils, and test before deploying.

Add {ClientUtils.Widget, otp_app: :my_app} before the endpoint in your supervision tree (it returns :ignore until both configuration values are set), and configure :codemyspec_server_url and :codemyspec_credentials_path on :my_app at runtime. Authorize each host once as the project owner:

mix client_utils.authorize --server https://codemyspec.com --project PROJECT_UUID --credentials /private/state/codemyspec.json

Open the printed URL and approve the code. The command saves the OAuth client secret and project-scoped refresh credential with mode 0600; mount that file as a private, persistent runtime secret and set CODEMYSPEC_CREDENTIALS_PATH to its path. Never commit it or place it under priv/static. Visitors never approve a device or receive this credential. The host obtains short-lived identity-bound socket tokens and renews them while connected. No deployment key is used to connect the widget. Revoke an installation at /oauth/installations; existing socket tokens expire within 15 minutes, and renewal and reconnect are refused immediately after revocation. Reauthorize to replace it.

ClientUtils.CodeMySpecClient owns device authorization, private credential storage, renewal, and authenticated HTTP requests. It works independently of the widget's supervisor. Configure CODEMYSPEC_SERVER_URL as the HTTPS server origin and map it to :codemyspec_server_url; map CODEMYSPEC_CREDENTIALS_PATH to :codemyspec_credentials_path in runtime config.

client = ClientUtils.CodeMySpecClient.new(:my_app)
ClientUtils.CodeMySpecClient.upsert_user(client, %{
  external_id: to_string(user.id), email: user.email, name: user.name
})

Call this after registration or profile changes. It returns {:ok, response} or {:error, reason}; updates are keyed by email within the approved project. The host cannot override that project in its payload. The setup command requests widget permission; use --scope "widget users" to explicitly request both, or --scope users for user-only installation. The owner sees the permissions before approving. Existing widget-only grants do not gain user access: reauthorize to approve both. Content is not included. The widget itself uses the same client's widget_socket_token/3.

defmodule MyAppWeb.SupportWidgetLive do
  use ClientUtils.SupportWidgetLive
  on_mount {MyAppWeb.UserAuth, :mount_current_scope}
end

Omit the on_mount hook for anonymous-only applications. Identity comes from the server-authenticated current_scope.user; anonymous sessions use their signed session identity. Never assign a current user from browser event parameters. Render the adapter with live_render in the root layout (sticky, with a stable id). Import CmsSupportWidget and CmsChatScroll from ../../deps/client_utils/priv/static/widget.js into your LiveSocket hooks, and add @source "../../deps/client_utils/lib"; to Tailwind CSS. The widget uses the host's existing Heroicons CSS and daisyUI theme, matching Phoenix's default styling. mix cms_gen.support_widget installs this wiring for generated applications.

For existing generated widgets, replace the LiveView with the adapter above, remove the generated Widget and WidgetClient modules and their old Registry / DynamicSupervisor children, and delegate the verify controller's create/2 to ClientUtils.WidgetVerifyController. Keep the /_cms/verify/widget route and its existing deploy-key authentication only if using CodeMySpec's separate inbound provisioning probe. That probe does not authorize outgoing widget connections. Authorize the host with device flow and replace the outgoing widget's deploy-key config with the private credential path. Existing server deploy-key socket support remains for older installations; new shared clients never fall back to it.

Browser assets (including screenshot capture) ship prebuilt. To change them: npm ci --prefix assets && npm run build --prefix assets; commit the source, lockfile, and priv/static/widget.js. Consumers do not install npm packages.

Installation authorization uses the standard /oauth/token refresh grant. The private credential file is updated with the returned refresh token before making an API request. Installations created with the earlier custom widget credential must run mix client_utils.authorize again after the server upgrade.