studio-engine 0.137.0 → 0.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +27 -1
  4. data/app/assets/tailwind/studio_engine/engine.css +228 -0
  5. data/app/controllers/concerns/studio/connection_flow.rb +1 -1
  6. data/app/controllers/sessions_controller.rb +12 -3
  7. data/app/controllers/studio/integrations_controller.rb +43 -0
  8. data/app/controllers/studio/local_reviews_controller.rb +12 -0
  9. data/app/javascript/studio/carousel.js +66 -0
  10. data/app/javascript/studio/controllers/carousel_controller.js +203 -1
  11. data/app/models/concerns/studio/account.rb +143 -0
  12. data/app/models/concerns/studio/omniauth_user.rb +9 -17
  13. data/app/models/error_log.rb +14 -5
  14. data/app/views/studio/_carousel.html.erb +58 -14
  15. data/app/views/studio/integrations/_card.html.erb +50 -0
  16. data/app/views/studio/integrations/index.html.erb +22 -0
  17. data/app/views/studio/integrations/show.html.erb +24 -0
  18. data/app/views/style/_tricks.html.erb +15 -0
  19. data/config/studio_tricks.yml +7 -0
  20. data/db/migrate/20261006120001_ensure_error_logs_table.rb +3 -1
  21. data/db/migrate/20261006120002_ensure_theme_settings_table.rb +3 -2
  22. data/db/migrate/20261108160000_require_slugs_on_engine_tables.rb +119 -0
  23. data/db/migrate/20261108160001_add_engine_state_checks.rb +94 -0
  24. data/db/migrate/20261108170000_add_users_role_known_check.rb +69 -0
  25. data/lib/generators/studio/connections/connections_generator.rb +10 -0
  26. data/lib/studio/connections.rb +53 -12
  27. data/lib/studio/integrations/egnyte.rb +69 -0
  28. data/lib/studio/integrations/microsoft_365.rb +63 -0
  29. data/lib/studio/integrations/quickbooks.rb +86 -0
  30. data/lib/studio/integrations.rb +265 -0
  31. data/lib/studio/node_tooling/README.md +9 -1
  32. data/lib/studio/node_tooling/e2e_helpers.js +93 -1
  33. data/lib/studio/node_tooling/playwright_base.js +51 -4
  34. data/lib/studio/roles.rb +17 -0
  35. data/lib/studio/version.rb +1 -1
  36. data/lib/studio.rb +59 -1
  37. metadata +15 -2
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ module Integrations
5
+ # MICROSOFT 365 (Microsoft Graph, app-only), the connection only. A host's
6
+ # readers (users, teams, sites) are its own; they call
7
+ # Studio.integration(:microsoft_365).api_base(connection) with a token from
8
+ # Studio.connection(:microsoft_365).access_token(connection).
9
+ #
10
+ # * A tenant administrator approves the app at Microsoft's admin-consent
11
+ # page (common: the tenant is not known until they sign in). Microsoft
12
+ # comes back with ?tenant=<GUID>&admin_consent=True; anything but an
13
+ # approval refuses the callback.
14
+ # * The tenant id is private: stored only encrypted, the row keyed by a
15
+ # digest, the callback's tenant param kept out of the logs by exact
16
+ # key. It is lower-cased, so one tenant never makes two rows.
17
+ # * CLIENT CREDENTIALS on the tenant's own token endpoint, with the Graph
18
+ # .default scope (the application permissions the app registration
19
+ # lists). No refresh token exists: the access token lives in this
20
+ # process's memory only, per tenant.
21
+ # * unauthorized_client means the app is not found in, or not enabled
22
+ # for, the tenant: the grant is gone.
23
+ # * NO REVOKE endpoint: consent is removed in the tenant's admin center.
24
+ #
25
+ # Checked against the hub's Msgraph::OAuthClient and Msgraph::Reader
26
+ # (mcritchie-studio), which cite Microsoft's identity-platform docs.
27
+ module Microsoft365
28
+ LOGIN_HOST = "https://login.microsoftonline.com"
29
+ AUTHORIZE_URL = "#{LOGIN_HOST}/common/adminconsent"
30
+ SCOPE = "https://graph.microsoft.com/.default"
31
+ API_URL = "https://graph.microsoft.com/v1.0"
32
+ TENANT_ID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
33
+
34
+ Integrations.register :microsoft_365,
35
+ label: "Microsoft 365",
36
+ summary: "A Microsoft 365 tenant, read app-only after an administrator approves the app.",
37
+ connection: {
38
+ grant: :client_credentials,
39
+ authorize_url: AUTHORIZE_URL,
40
+ token_url: ->(tenant) { "#{LOGIN_HOST}/#{tenant}/oauth2/v2.0/token" },
41
+ scopes: [SCOPE],
42
+ account: :callback, account_param: "tenant", account_format: TENANT_ID,
43
+ normalize_account: ->(value) { value.to_s.strip.downcase },
44
+ private_account: true, filter_keys: %w[tenant],
45
+ revoked_errors: %w[unauthorized_client],
46
+ declined_errors: %w[access_denied permission_denied consent_required],
47
+ callback_check: lambda { |params|
48
+ "Microsoft did not report an approval. Nothing was connected." unless params[:admin_consent].to_s.casecmp?("true")
49
+ },
50
+ refusal_hint: "A tenant bought through a reseller may block the approval; otherwise check the app registration's redirect URI, then start again.",
51
+ revoke_note: "Remove the app in the tenant's Microsoft Entra admin center (Enterprise applications) to revoke its access there."
52
+ },
53
+ describe: lambda { |integration, grant:, account:, get:|
54
+ token = integration.provider.access_token(account)
55
+ { label: get.call("#{API_URL}/organization/#{account}", token)["displayName"] }
56
+ },
57
+ default_label: ->(_tenant) { "Microsoft 365 tenant" },
58
+ api_base: ->(_integration, _connection) { API_URL },
59
+ stand_in: { callback_params: { tenant: "00000000-0000-0000-0000-000000000000", admin_consent: "True" },
60
+ answer: { "displayName" => "Stand-in Tenant" } }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ module Integrations
5
+ # QUICKBOOKS ONLINE (Intuit), the connection only. A host's readers (company
6
+ # info, reports) are its own; they call Studio.integration(:quickbooks).api_base(connection).
7
+ #
8
+ # * Sign-in at Intuit's app center. The endpoints come from Intuit's
9
+ # discovery document
10
+ # (https://developer.api.intuit.com/.well-known/openid_configuration),
11
+ # the same for sandbox and production.
12
+ # * The token and revoke calls authenticate with HTTP Basic; the revoke
13
+ # body is JSON.
14
+ # * CACHED access token, refreshed when it expires: Intuit's access token
15
+ # lives about an hour (expires_in 3600). The row keeps it, encrypted,
16
+ # with its expiry, and reuses it until 5 minutes before that expiry, or
17
+ # until the API answers 401 (fresh_access_token(stale: token)). Then it
18
+ # refreshes ONCE under the row's lock, reloading the row first, so a
19
+ # racing caller waits and reads the new token instead of spending the
20
+ # refresh token twice. A reader that still gets a 401 after that one
21
+ # refresh asks the admin to connect again; it never loops.
22
+ # * The refresh token ROTATES and the newest must be kept: every refresh
23
+ # answer must carry one (rotation: :required, else nothing is stored and
24
+ # the refresh refuses), and it is stored, with the access token and
25
+ # x_refresh_token_expires_in, before the access token is used.
26
+ # * invalid_grant on a refresh is a revoked grant: connect again.
27
+ # * intuit_tid: Intuit names every answer with this response header, and
28
+ # asks apps to keep it for support. It is read from every token
29
+ # exchange, refresh, revoke and describe answer, logged with the call
30
+ # (no token), kept as provider.last_trace_id, and carried by every error
31
+ # raised (Error#trace_id, and in its message, so the error log has it).
32
+ # Host readers call provider.record_trace(response, call:, status:).
33
+ # * The company is named on the callback (?realmId=), a number, kept out
34
+ # of the logs by exact key.
35
+ # * The environment setting (sandbox, the default, or production) says
36
+ # which Intuit keys these are, so which API host a new connection reads;
37
+ # anything but "production" is the sandbox, so a typo never points
38
+ # sandbox keys at live books. It is stored on the connection.
39
+ #
40
+ # Checked against the hub's Quickbooks::OAuthClient and Quickbooks::Reader
41
+ # (mcritchie-studio), which cite Intuit's documents.
42
+ module Quickbooks
43
+ AUTHORIZE_URL = "https://appcenter.intuit.com/connect/oauth2"
44
+ TOKEN_URL = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
45
+ REVOKE_URL = "https://developer.api.intuit.com/v2/oauth2/tokens/revoke"
46
+ SCOPE = "com.intuit.quickbooks.accounting"
47
+ API_URLS = { "sandbox" => "https://sandbox-quickbooks.api.intuit.com",
48
+ "production" => "https://quickbooks.api.intuit.com" }.freeze
49
+ # Intuit retired minor versions 1-74 in August 2025: 75 is the floor.
50
+ MINOR_VERSION = 75
51
+ REALM_ID = /\A\d{1,20}\z/
52
+
53
+ def self.api_url(environment) = API_URLS.fetch(environment.to_s, API_URLS.fetch("sandbox"))
54
+
55
+ Integrations.register :quickbooks,
56
+ label: "QuickBooks",
57
+ summary: "A QuickBooks Online company, read with its owner's sign-in.",
58
+ connection: {
59
+ authorize_url: AUTHORIZE_URL, token_url: TOKEN_URL, revoke_url: REVOKE_URL,
60
+ scopes: [SCOPE],
61
+ client_auth: :basic, revoke_format: :json,
62
+ refresh: :cached, refresh_margin: 5.minutes, rotation: :required,
63
+ refresh_expires_in_key: "x_refresh_token_expires_in",
64
+ account: :callback, account_param: "realmId", account_format: REALM_ID,
65
+ filter_keys: %w[realmId], trace_header: "intuit_tid",
66
+ refusal_hint: "Check the Intuit app's redirect URI, then start again."
67
+ },
68
+ settings: { environment: { values: %w[sandbox production], default: "sandbox" } },
69
+ metadata: ->(integration) { { "environment" => integration.setting(:environment) } },
70
+ describe: lambda { |integration, grant:, account:, get:|
71
+ token = grant["access_token"].to_s
72
+ next {} if token.empty?
73
+
74
+ base = "#{Quickbooks.api_url(integration.setting(:environment))}/v3/company/#{account}"
75
+ info = get.call("#{base}/companyinfo/#{account}?minorversion=#{MINOR_VERSION}", token)["CompanyInfo"]
76
+ { label: info.is_a?(Hash) ? info["CompanyName"] : nil }
77
+ },
78
+ default_label: ->(_account) { "QuickBooks company" },
79
+ api_base: lambda { |_integration, connection|
80
+ "#{Quickbooks.api_url(connection.metadata.to_h['environment'])}/v3/company/#{connection.account_id}"
81
+ },
82
+ stand_in: { callback_params: { realmId: "1000000001" },
83
+ answer: { "CompanyInfo" => { "CompanyName" => "Stand-in Company" } } }
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+ require "json"
7
+
8
+ module Studio
9
+ # INTEGRATIONS: the engine's registry of integration CONNECTION definitions
10
+ # (docs/INTEGRATIONS.md), on top of the generic connection tooling
11
+ # (Studio::Connections, docs/CONNECTIONS.md).
12
+ #
13
+ # The engine ships one definition per integration it knows (QuickBooks,
14
+ # Egnyte, Microsoft 365). A definition holds the connection only: the URLs,
15
+ # the scopes, the token behaviour, and a minimal describe that names the
16
+ # account just connected (the company, the user, the organization). It holds
17
+ # NO reader and makes no business-data call: a host writes its own readers
18
+ # and plugs them into the integration's page with a panel.
19
+ #
20
+ # A host turns one on with one line in an initializer, naming its own env vars:
21
+ #
22
+ # Studio.enable_integration :quickbooks,
23
+ # env: { client_id: "INTUIT_CLIENT_ID", client_secret: "INTUIT_CLIENT_SECRET",
24
+ # environment: "INTUIT_ENVIRONMENT" },
25
+ # panel: "admin/integrations/quickbooks_panel"
26
+ #
27
+ # and mounts the admin page with Studio.draw_integrations_routes. An enabled
28
+ # integration stays off (Connect says which env vars are missing) until its
29
+ # client id and secret are set.
30
+ module Integrations
31
+ # Seconds to open the describe call's connection, and to read its answer.
32
+ OPEN_TIMEOUT = 10
33
+ READ_TIMEOUT = 20
34
+
35
+ # One integration the engine knows: the connection only.
36
+ #
37
+ # key the integration's key, and its connection's
38
+ # label the name on the page and every message
39
+ # summary one line on the page: what connecting it gives the host
40
+ # connection the Studio::Connections::Provider options, every one but
41
+ # env:, label: and describe:
42
+ # settings extra env vars a host may name besides the client id and
43
+ # secret: { name: { values: [...], default: "..." } }; a
44
+ # value outside `values` reads as the default
45
+ # account_label the Connect form's field label (account: :connect only)
46
+ # account_hint its placeholder
47
+ # metadata ->(integration) { Hash }: stored on every new connection,
48
+ # with no network call (the provider environment)
49
+ # describe ->(integration, grant:, account:, get:) { { label:, metadata: } }:
50
+ # names the account just connected with ONE call through
51
+ # `get` (->(url, token) { JSON Hash }); a Connections::Error
52
+ # there stores the connection under default_label
53
+ # default_label ->(account) { String }: the label when describe cannot say
54
+ # api_base ->(integration, connection) { String }: where the host's
55
+ # own readers send their calls for this connection
56
+ # stand_in { callback_params: {...}, answer: {...} }: what the
57
+ # stand-in's sign-in page sends back, and what its describe
58
+ # call answers (development and test only)
59
+ Definition = Struct.new(:key, :label, :summary, :connection, :settings, :account_label, :account_hint,
60
+ :metadata, :describe, :default_label, :api_base, :stand_in, keyword_init: true)
61
+
62
+ # The statuses an enabled integration can be in, in order.
63
+ STATUSES = {
64
+ not_set_up: "Not set up",
65
+ ready: "Ready to connect",
66
+ connected: "Connected"
67
+ }.freeze
68
+
69
+ class << self
70
+ # Adds a definition to the engine's catalog. The engine's own definitions
71
+ # register themselves when lib/studio/integrations/*.rb loads.
72
+ def register(key, **fields)
73
+ definition = Definition.new(key: key.to_s, settings: {}, metadata: ->(_integration) { {} },
74
+ default_label: nil, stand_in: {}, **fields)
75
+ definition.settings = definition.settings.to_h.transform_keys(&:to_sym)
76
+ lock.synchronize { catalog_map[definition.key] = definition.freeze }
77
+ end
78
+
79
+ # Every integration the engine knows, enabled or not.
80
+ def catalog = lock.synchronize { catalog_map.values }
81
+
82
+ def definition(key)
83
+ lock.synchronize { catalog_map[key.to_s] } or
84
+ raise ArgumentError, "the engine has no integration #{key.to_s.inspect}; it knows #{catalog.map(&:key).join(', ')}"
85
+ end
86
+
87
+ # Turns an integration on: defines its connection (Studio.define_connection)
88
+ # with the host's env names, and adds it to the admin page.
89
+ def enable(key, env:, panel: nil, label: nil, scopes: nil)
90
+ integration = Integration.new(definition(key), env:, panel:, label:, scopes:)
91
+ lock.synchronize { enabled_map[integration.key] = integration }
92
+ integration
93
+ end
94
+
95
+ # The enabled integrations, in the order the host enabled them.
96
+ def all = lock.synchronize { enabled_map.values }
97
+
98
+ def enabled?(key) = lock.synchronize { enabled_map.key?(key.to_s) }
99
+
100
+ def fetch(key)
101
+ lock.synchronize { enabled_map[key.to_s] } or raise ArgumentError, "the integration #{key.to_s.inspect} is not enabled"
102
+ end
103
+
104
+ # Where draw_integrations_routes mounted the page ("admin/integrations").
105
+ attr_accessor :mount_path
106
+
107
+ # Forgets every enabled integration (not the catalog). Tests only.
108
+ def reset!
109
+ lock.synchronize do
110
+ enabled_map.clear
111
+ @mount_path = nil
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def catalog_map = (@catalog_map ||= {})
118
+ def enabled_map = (@enabled_map ||= {})
119
+ def lock = (@lock ||= Monitor.new)
120
+ end
121
+
122
+ # One integration a host enabled: its definition, the host's env names, its
123
+ # connection provider and the host's panel.
124
+ class Integration
125
+ attr_reader :definition, :env, :panel, :provider
126
+
127
+ # Stand-in for the describe call: (url, headers) -> [status, body]. nil
128
+ # means the provider itself. Refused outside development and test.
129
+ attr_reader :api_http
130
+
131
+ def initialize(definition, env:, panel: nil, label: nil, scopes: nil)
132
+ @definition = definition
133
+ @env = env.to_h.transform_keys(&:to_sym)
134
+ unknown = @env.keys - %i[client_id client_secret] - definition.settings.keys
135
+ raise ArgumentError, "#{key}: env names client_id, client_secret#{definition.settings.keys.map { ", #{_1}" }.join}; not #{unknown.join(', ')}" if unknown.any?
136
+ raise ArgumentError, "#{key}: env values are env var names (Strings)" unless @env.values.all?(String)
137
+
138
+ @panel = panel&.to_s
139
+ options = definition.connection.merge(
140
+ label: label || definition.label,
141
+ env: @env.slice(:client_id, :client_secret),
142
+ describe: ->(grant:, account:, provider:) { describe_account(grant:, account:) }
143
+ )
144
+ options[:scopes] = Array(scopes) if scopes
145
+ @provider = Studio.define_connection(definition.key, **options)
146
+ end
147
+
148
+ def key = definition.key
149
+ def label = provider.label
150
+ def summary = definition.summary
151
+
152
+ # A setting's value from the host's env var: one of its values, else its default.
153
+ def setting(name)
154
+ spec = definition.settings.fetch(name.to_sym)
155
+ value = @env[name.to_sym] && ENV[@env[name.to_sym]].to_s.strip.downcase
156
+ spec[:values].include?(value) ? value : spec[:default]
157
+ end
158
+
159
+ # Every connection of this integration this server can read, newest first.
160
+ def connections = Studio::Connection.readable(key)
161
+
162
+ # What keeps a Connect from starting here, as sentences (empty when none).
163
+ def missing
164
+ reasons = []
165
+ unless provider.connectable?
166
+ unset = provider.env_names.reject { |name| ENV[name].to_s.strip != "" }
167
+ reasons << "#{unset.join(' and ')} #{unset.one? ? 'is' : 'are'} not set."
168
+ end
169
+ reasons << "The app's Active Record encryption keys are not set." unless Studio::Connections.encryption_ready?
170
+ reasons
171
+ end
172
+
173
+ # :not_set_up, :ready or :connected (STATUSES).
174
+ def status
175
+ return :not_set_up if missing.any?
176
+
177
+ connections.any? ? :connected : :ready
178
+ end
179
+
180
+ def status_label = STATUSES.fetch(status)
181
+
182
+ # Where the host's readers send their calls for `connection`.
183
+ def api_base(connection) = definition.api_base.call(self, connection)
184
+
185
+ def api_http=(callable)
186
+ unless callable.nil? || Studio::Connections.seams_allowed?
187
+ raise Studio::Connections::Error, "#{label}: the describe stand-in is for tests, the e2e lane and a local demo (development and test only)"
188
+ end
189
+
190
+ @api_http = callable
191
+ end
192
+
193
+ # Stands the whole integration in: the provider's sign-in page and token
194
+ # endpoints (Studio::Connections::StandIn) and the describe call.
195
+ # Development and test only; refused anywhere else.
196
+ def install_stand_in!(access_lifetime: 3600)
197
+ answer = definition.stand_in.fetch(:answer, {}).to_json
198
+ self.api_http = ->(_url, _headers) { [200, answer] }
199
+ Studio::Connections::StandIn.install!(provider, callback_params: definition.stand_in.fetch(:callback_params, {}), access_lifetime:)
200
+ self
201
+ end
202
+
203
+ def uninstall_stand_in!
204
+ self.api_http = nil
205
+ Studio::Connections::StandIn.uninstall!(provider)
206
+ self
207
+ end
208
+
209
+ # The provider's describe hook: the definition's metadata, then its one
210
+ # describe call. A refusal there keeps the metadata and the default label.
211
+ def describe_account(grant:, account:)
212
+ metadata = definition.metadata.call(self).to_h
213
+ described = {}
214
+ if definition.describe
215
+ begin
216
+ described = definition.describe.call(self, grant:, account:, get: method(:describe_get)).to_h.symbolize_keys
217
+ rescue Studio::Connections::Error => e
218
+ # Kept in the error log (with the provider's trace id, when it names
219
+ # one), as a refused exchange is; the connection still stores.
220
+ begin
221
+ ::ErrorLog.capture!(e) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
222
+ rescue StandardError => log_error
223
+ Rails.logger.error("[studio_integration] describe #{key} refused (#{e.message}); ErrorLog write failed: #{log_error.class}")
224
+ end
225
+ end
226
+ end
227
+ label = described[:label].to_s.strip.presence || definition.default_label&.call(account)
228
+ { label:, metadata: metadata.merge(described[:metadata].to_h) }.compact
229
+ end
230
+
231
+ private
232
+
233
+ # The describe call's one GET: a bearer token, JSON back. Raises
234
+ # Studio::Connections::Error (RateLimited on a 429) on anything but a 200.
235
+ def describe_get(url, token)
236
+ headers = { "Authorization" => "Bearer #{token}", "Accept" => "application/json" }
237
+ status, body, answered = api_http ? api_http.call(url, headers) : request(url, headers)
238
+ trace = provider.record_trace(answered, call: "describe", status:)
239
+ traced = { trace_header: provider.trace_header, trace_id: trace }
240
+ json = begin
241
+ JSON.parse(body.to_s)
242
+ rescue JSON::ParserError
243
+ {}
244
+ end
245
+ json = {} unless json.is_a?(Hash)
246
+ raise Studio::Connections::RateLimited.new("#{label} is throttling this app (429)", **traced) if status == 429
247
+ raise Studio::Connections::Error.new("#{label} answered #{status} to the describe call", **traced) unless status == 200
248
+
249
+ json
250
+ end
251
+
252
+ def request(url, headers)
253
+ uri = URI(url)
254
+ raise Studio::Connections::Error, "#{label}: a describe call must be https" unless uri.scheme == "https"
255
+
256
+ get = Net::HTTP::Get.new(uri)
257
+ headers.each { |name, value| get[name] = value }
258
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) { _1.request(get) }
259
+ [response.code.to_i, response.body, response]
260
+ rescue *Studio::Connections::NETWORK_ERRORS => e
261
+ raise Studio::Connections::Error, "#{label} could not be reached: #{e.class}"
262
+ end
263
+ end
264
+ end
265
+ end
@@ -8,7 +8,15 @@ host gets it with the engine it already bundles: no npm registry, no token.
8
8
  exactly this version.
9
9
  - `playwright_base.js`: the browser lane's shared defaults (one chromium
10
10
  project, one worker, a Rails `webServer` on `/up`).
11
- - `e2e_helpers.js`: `watchPageErrors` and `blockOffsiteRequests`.
11
+ - `e2e_helpers.js`: `watchPageErrors` and `blockOffsiteRequests`; for a page
12
+ transition spec, the reveal waits (`watchReveal`, `revealed`, `settled`,
13
+ `arrivalSettled`, `frames`) and the log-on-red hook (`sessionLog`, `logOnRed`).
14
+ The waits read events the browser fires, never a clock.
15
+
16
+ A local run reuses a server already on the e2e port only when that server's
17
+ working directory is inside this tree (`mayReuseServer` in `playwright_base.js`,
18
+ read with `lsof`). Any other server there is refused, with a warning that names
19
+ its root: give the run its own port with `E2E_PORT=<free port>`.
12
20
  - `node_test/register.mjs` and `node_test/importmap_hooks.mjs`: let `node --test`
13
21
  import a module by the bare name the importmap pins it under.
14
22
 
@@ -71,4 +71,96 @@ async function blockOffsiteRequests(page, { pinned = {} } = {}) {
71
71
  });
72
72
  }
73
73
 
74
- module.exports = { isOffOrigin, watchPageErrors, blockOffsiteRequests };
74
+ // ---- Full-load page transitions (docs/PAGE_TRANSITIONS.md, "Full page loads") ----
75
+ //
76
+ // These wait on events the browser fires, never on a clock. Nothing here sleeps.
77
+
78
+ // Marks each document's pagereveal, for revealed(), settled() and arrivalSettled() below.
79
+ // Call it before the first navigation: it is an init script, so every new document gets it,
80
+ // and it must be bound before the first render to hear pagereveal at all.
81
+ //
82
+ // `window.__studioReveal` holds { revealed, transition, done }: `done` is true once the
83
+ // arrival's view transition has finished or been skipped, at once when there is none.
84
+ function watchReveal(target) {
85
+ return target.addInitScript(() => {
86
+ const state = { revealed: false, transition: false, done: false };
87
+ window.__studioReveal = state;
88
+ window.addEventListener("pagereveal", (event) => {
89
+ state.revealed = true;
90
+ const transition = event.viewTransition;
91
+ state.transition = !!transition;
92
+ if (!transition) { state.done = true; return; }
93
+ const done = () => { state.done = true; };
94
+ transition.finished.then(done, done);
95
+ });
96
+ });
97
+ }
98
+
99
+ // The new page has revealed. VISIBLE IS NOT REVEALED: its DOM parses, and Playwright sees it
100
+ // as visible, before its first render. A render-blocking sheet, or the reveal script's own
101
+ // hold to the end of parse, keeps pagereveal back, and until then no mark is set and no
102
+ // pseudo runs (engine CI run 42503970890 read an empty log at 312 ms).
103
+ async function revealed(page, selector) {
104
+ if (selector) await page.locator(selector).first().waitFor({ state: "visible" });
105
+ await page.waitForFunction(() => window.__studioReveal && window.__studioReveal.revealed === true);
106
+ }
107
+
108
+ // Past the whole motion: revealed, and the document's own marks and every
109
+ // ::view-transition animation are gone when this returns.
110
+ async function settled(page, selector) {
111
+ await revealed(page, selector);
112
+ await page.waitForFunction(() => document.documentElement.dataset.pageTransition === undefined
113
+ && !document.getAnimations().some((a) => ((a.effect && a.effect.pseudoElement) || "").startsWith("::view-transition")));
114
+ }
115
+
116
+ // Two rendered frames. A recorder that samples document.getAnimations() on every
117
+ // requestAnimationFrame has run at least once past anything that was live before this call.
118
+ function frames(page, count = 2) {
119
+ return page.evaluate((n) => new Promise((done) => {
120
+ const step = (left) => (left === 0 ? done() : requestAnimationFrame(() => step(left - 1)));
121
+ step(n);
122
+ }), count);
123
+ }
124
+
125
+ // For a spec that asserts NOTHING PLAYED: the arrival's view transition has finished or been
126
+ // skipped, and two frames have rendered since. A transition that wrongly plays keeps `done`
127
+ // false until its last frame, and every frame it draws comes before this returns, so a
128
+ // per-frame recorder has logged it. A fixed pause only hoped for that.
129
+ async function arrivalSettled(page, selector) {
130
+ await revealed(page, selector);
131
+ await page.waitForFunction(() => window.__studioReveal.done === true);
132
+ await frames(page);
133
+ }
134
+
135
+ // A log kept in sessionStorage, so it outlives each document of a full load.
136
+ function sessionLog(page, key = "pt-log") {
137
+ return page.evaluate((name) => JSON.parse(sessionStorage.getItem(name) || "[]"), key);
138
+ }
139
+
140
+ // A red carries its evidence: registers an afterEach that, on a failed test only, prints the
141
+ // log `read(page)` returns and attaches it to the report the CI uploads. A red with no log
142
+ // once left its cause unprovable. `test` is the host's own @playwright/test `test`, since
143
+ // this file cannot require Playwright (see README.md).
144
+ function logOnRed(test, read = (page) => sessionLog(page), { name = "pt-log" } = {}) {
145
+ test.afterEach(async ({ page }, testInfo) => {
146
+ if (testInfo.status === testInfo.expectedStatus) return;
147
+ const lines = await Promise.resolve()
148
+ .then(() => read(page))
149
+ .catch((error) => [`(the log could not be read: ${error.message})`]);
150
+ console.log(`${name} for "${testInfo.title}":\n${lines.join("\n")}`);
151
+ await testInfo.attach(name, { body: lines.join("\n"), contentType: "text/plain" });
152
+ });
153
+ }
154
+
155
+ module.exports = {
156
+ isOffOrigin,
157
+ watchPageErrors,
158
+ blockOffsiteRequests,
159
+ watchReveal,
160
+ revealed,
161
+ settled,
162
+ frames,
163
+ arrivalSettled,
164
+ sessionLog,
165
+ logOnRed,
166
+ };
@@ -14,6 +14,10 @@
14
14
  // Every key of `overrides` replaces the default of the same name; `use` merges
15
15
  // one level deep, so a host can add `reducedMotion` and keep `baseURL`.
16
16
 
17
+ const { execFileSync } = require("node:child_process");
18
+ const fs = require("node:fs");
19
+ const path = require("node:path");
20
+
17
21
  const DEFAULTS = Object.freeze({
18
22
  testDir: "./e2e",
19
23
  timeout: 30_000,
@@ -29,14 +33,57 @@ function studioBaseURL({ defaultPort, env = process.env } = {}) {
29
33
  return { port, external, baseURL: external || `http://127.0.0.1:${port}` };
30
34
  }
31
35
 
36
+ // The working directory of whatever listens on `port`, read with lsof: { root } for a
37
+ // listener, { root: null } for none, { unknown: true } when it cannot be told (no lsof, a
38
+ // process lsof may not read).
39
+ function listenerRoot(port) {
40
+ const run = (args) => execFileSync("lsof", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
41
+ let pids;
42
+ try {
43
+ pids = run(["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]).split("\n").filter(Boolean);
44
+ } catch (error) {
45
+ // lsof exits 1, printing nothing, when nothing listens.
46
+ if (error.status === 1 && !error.stdout) return { root: null };
47
+ return { unknown: true };
48
+ }
49
+ if (pids.length === 0) return { root: null };
50
+ try {
51
+ const line = run(["-a", "-p", pids[0], "-d", "cwd", "-Fn"]).split("\n").find((entry) => entry.startsWith("n"));
52
+ return line ? { root: line.slice(1) } : { unknown: true };
53
+ } catch (_error) {
54
+ return { unknown: true };
55
+ }
56
+ }
57
+
58
+ function realpath(path) {
59
+ try { return fs.realpathSync(path); } catch (_error) { return path; }
60
+ }
61
+
62
+ // May this run reuse the server already on `port`? Only one this tree started. Playwright's
63
+ // own check is "something answers /up", so on a shared default port one desk's run graded
64
+ // another desk's server, and its code. A server rooted outside `root`, or one whose root
65
+ // cannot be read, is refused: Playwright then stops with "is already used", and the warning
66
+ // names the server's root and the way out. Nothing listening reuses nothing either way.
67
+ function mayReuseServer({ port, root, lookup = listenerRoot, warn = console.warn }) {
68
+ const found = lookup(port);
69
+ if (found.root === null) return true;
70
+ const mine = realpath(root);
71
+ const theirs = found.unknown ? null : realpath(found.root);
72
+ if (theirs && (theirs === mine || theirs.startsWith(`${mine}${path.sep}`))) return true;
73
+ warn(`[studio playwright] port ${port} is held by a server ${theirs ? `rooted at ${theirs}` : "whose root cannot be read"}, ` +
74
+ `not by this tree (${mine}), so it is not reused. Stop it if it is yours, or give this run its own port: E2E_PORT=<free port>.`);
75
+ return false;
76
+ }
77
+
32
78
  // A Rails server Playwright boots and waits on. `/up` is the Rails health check
33
- // every app in the ecosystem keeps.
34
- function railsWebServer({ command, port, env = {}, timeout = 120_000, ci = !!process.env.CI }) {
79
+ // every app in the ecosystem keeps. Locally a running server is reused only when this
80
+ // tree started it (mayReuseServer); CI never reuses one.
81
+ function railsWebServer({ command, port, env = {}, timeout = 120_000, ci = !!process.env.CI, root = process.cwd(), lookup }) {
35
82
  if (!command) throw new Error("railsWebServer: a command is required");
36
83
  return {
37
84
  command,
38
85
  url: `http://127.0.0.1:${port}/up`,
39
- reuseExistingServer: !ci,
86
+ reuseExistingServer: !ci && mayReuseServer({ port, root, lookup }),
40
87
  timeout,
41
88
  stdout: "pipe",
42
89
  stderr: "pipe",
@@ -60,4 +107,4 @@ function studioPlaywrightConfig({ defaultPort, webServer, env = process.env, use
60
107
  return config;
61
108
  }
62
109
 
63
- module.exports = { DEFAULTS, studioBaseURL, railsWebServer, studioPlaywrightConfig };
110
+ module.exports = { DEFAULTS, studioBaseURL, listenerRoot, mayReuseServer, railsWebServer, studioPlaywrightConfig };
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The one list of account roles every Studio app draws from (database audit
5
+ # 2026-11-07, section 3). `users.role` holds one of these, guarded twice: by
6
+ # Studio::Account's inclusion validation and by CHECK users_role_known, which
7
+ # db/migrate/20261108170000_add_users_role_known_check.rb installs.
8
+ #
9
+ # A host may NARROW the list (`studio_roles %w[viewer admin]` in its User),
10
+ # never widen it: a role the engine does not know means nothing to the
11
+ # engine's admin checks, and a new one is added here, for every app at once.
12
+ # Turf's old "user" spelling meant viewer and moved there.
13
+ Roles = %w[viewer member admin].freeze
14
+
15
+ # The role a new account takes when nothing says otherwise.
16
+ DEFAULT_ROLE = "viewer"
17
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.137.0"
2
+ VERSION = "0.138.0"
3
3
  end