studio-engine 0.115.1.rc1 → 0.115.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +17 -0
- data/app/javascript/studio/studio_confetti.js +4 -3
- data/app/views/layouts/studio/_head.html.erb +20 -8
- data/lib/studio/node_tooling/README.md +27 -0
- data/lib/studio/node_tooling/e2e_helpers.js +74 -0
- data/lib/studio/node_tooling/node_test/importmap_hooks.mjs +48 -0
- data/lib/studio/node_tooling/node_test/register.mjs +20 -0
- data/lib/studio/node_tooling/playwright_base.js +63 -0
- data/lib/studio/node_tooling/versions.json +5 -0
- data/lib/studio/node_tooling.rb +96 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +1 -0
- data/lib/tasks/studio_node.rake +24 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34882e3c979d460aab938a9bd07a2a520222068592dd7b2bbc5e6d7b114cbd4f
|
|
4
|
+
data.tar.gz: e3a4a039830a9c071526bc9410c11f62f7ff2ed70a3c68d842f2ec0502daf93f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 94324fdda382908f9b3630cf6f7a3866f75e111b879888e65aff870d2e5e9dccc985315cbe14935197e091f284fa6b454f9f9341a341eb5cb2bd1dd8bdf3b4b5
|
|
7
|
+
data.tar.gz: 6dc861ab2e2d4837549d77b43cf03e751c35051e06981df58cee66aa9c20a89ca395a8c23397b939aacbde8f96e4abaaca927faf3775f8db31c19f78bd1f1f16
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,40 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **One Node tooling package, shipped in the gem** (`lib/studio/node_tooling/`,
|
|
10
|
+
see its `README.md`). `versions.json` pins Node 22, `@playwright/test`
|
|
11
|
+
1.62.1 and `axe-core` 4.11.0. `playwright_base.js` holds the browser lane's
|
|
12
|
+
shared defaults; `e2e_helpers.js` holds `watchPageErrors` and
|
|
13
|
+
`blockOffsiteRequests`; `node_test/register.mjs` lets `node --test` import a
|
|
14
|
+
module by its importmap name. `bin/rails studio:node:link` links it at
|
|
15
|
+
`node_modules/.studio-engine` (run it after `npm ci`), and
|
|
16
|
+
`bin/rails studio:node:check` exits 1, naming each pin a host drifts from. No
|
|
17
|
+
host change is needed until a host adopts it.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **The engine runs Node 22.** `.tool-versions` reads `nodejs 22`, both
|
|
22
|
+
`engine-ci.yml` Node steps read it through `node-version-file`, and
|
|
23
|
+
`package.json` pins `@playwright/test` at exactly 1.62.1. The engine's
|
|
24
|
+
Playwright config and e2e helpers build on the shared package, and resolve to
|
|
25
|
+
the same config as before.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **`studio/studio_confetti` resolves `"canvas-confetti"` on every page.** In
|
|
30
|
+
0.115.1.rc1 the head loaded it with `javascript_include_tag ..., type:
|
|
31
|
+
"module"`, which also sends a `Link: rel=modulepreload` response header. The
|
|
32
|
+
browser acts on that header before it parses the import map, so on a page
|
|
33
|
+
whose head parsed slowly it resolved the module's bare import against no map
|
|
34
|
+
and threw `TypeError: Failed to resolve module specifier "canvas-confetti"`
|
|
35
|
+
(11 hub e2e specs: the boards and `/dreams`). The head now imports it with
|
|
36
|
+
`javascript_import_module_tag` below the map, which sends no header. No host
|
|
37
|
+
change is needed. A module the head loads by URL may import no bare specifier
|
|
38
|
+
(`test/views/head_url_modules_import_no_bare_specifier_test.rb`), and the e2e
|
|
39
|
+
lab now sends the Link header Rails sends, which its stub had dropped.
|
|
40
|
+
|
|
7
41
|
## 0.115.1 — 2026-10-27
|
|
8
42
|
|
|
9
43
|
### Changed
|
data/README.md
CHANGED
|
@@ -127,6 +127,23 @@ A migrating host also moves every other reader of the preset, not only the
|
|
|
127
127
|
build: any production code or test that `require`s or reads the preset's text
|
|
128
128
|
reads `theme.css` instead.
|
|
129
129
|
|
|
130
|
+
## Node tooling
|
|
131
|
+
|
|
132
|
+
The gem ships the one Node tooling package every app shares, in
|
|
133
|
+
`lib/studio/node_tooling/` (its `README.md` lists the files): the pins
|
|
134
|
+
(`versions.json`: Node 22, `@playwright/test` 1.62.1, `axe-core` 4.11.0), a
|
|
135
|
+
Playwright config base, the shared e2e helpers, and the `node --test` importmap
|
|
136
|
+
hooks.
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
bin/rails studio:node:link # node_modules/.studio-engine -> the package; run after npm ci
|
|
140
|
+
bin/rails studio:node:check # exits 1, naming each pin this app drifts from
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
An app pins Node in `.tool-versions` (`nodejs 22`), the file mise and
|
|
144
|
+
`actions/setup-node`'s `node-version-file` both read.
|
|
145
|
+
`docs/FRONT_END_STANDARD.md`, "Node tooling", has the rest.
|
|
146
|
+
|
|
130
147
|
## Configuration
|
|
131
148
|
|
|
132
149
|
Each consuming app configures the engine in `config/initializers/studio.rb`:
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
vendored canvas-confetti (app/assets/javascripts/studio/canvas_confetti.js,
|
|
4
4
|
pinned as "canvas-confetti", no CDN).
|
|
5
5
|
|
|
6
|
-
An ES module (the head
|
|
7
|
-
map
|
|
8
|
-
|
|
6
|
+
An ES module (the head imports it with an inline module tag after the import
|
|
7
|
+
map, never by URL: a URL tag's Link modulepreload header would resolve the
|
|
8
|
+
bare import below before the map exists). It imports canvas-confetti for its
|
|
9
|
+
side effect, which defines the library's global `confetti`, and exports:
|
|
9
10
|
|
|
10
11
|
import { burst, cannons, confetti } from "studio/studio_confetti"
|
|
11
12
|
burst(cardEl) // radial burst behind a card/modal
|
|
@@ -195,7 +195,13 @@
|
|
|
195
195
|
take every bare specifier on the page down with it. The ones below that load by
|
|
196
196
|
URL (type="module" with a src) are external files, not inline scripts: they
|
|
197
197
|
carry no body and need no nonce under a policy that allows 'self'. The session
|
|
198
|
-
store (studio/_session_stamp, above) is the one classic script, and says why.
|
|
198
|
+
store (studio/_session_stamp, above) is the one classic script, and says why.
|
|
199
|
+
A MODULE LOADED BY URL MUST IMPORT NO BARE SPECIFIER. javascript_include_tag
|
|
200
|
+
also sends a `Link: rel=modulepreload` response header, which the browser
|
|
201
|
+
acts on before it parses this map, so a bare import in that module can
|
|
202
|
+
resolve against no map at all and fail for good (0.115.1.rc1, task
|
|
203
|
+
confetti-resolves-on-hub). A module with bare imports takes a
|
|
204
|
+
javascript_import_module_tag, which sends no header. %>
|
|
199
205
|
<%# THE ENGINE'S BROWSER BOOT (studio/application): its every-page Stimulus
|
|
200
206
|
controllers, the pinned stack, and the Alpine shims. A module tag, so it
|
|
201
207
|
carries the nonce. %>
|
|
@@ -238,13 +244,19 @@
|
|
|
238
244
|
module that owns it. Its imports import nothing, and studio/alpine_shims
|
|
239
245
|
imports it, so a failed boot leaves a page its uploads. %>
|
|
240
246
|
<%= javascript_import_module_tag "studio/alpine_scopes" %>
|
|
241
|
-
<%# CONFETTI, BY URL. studio/studio_confetti imports the
|
|
242
|
-
(
|
|
243
|
-
no CDN, same-origin) and
|
|
244
|
-
|
|
245
|
-
studio/head_chrome's
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
<%# CONFETTI, BY ITS OWN DOOR, AND NOT BY URL. studio/studio_confetti imports the
|
|
248
|
+
vendored canvas-confetti by its bare pin ("canvas-confetti",
|
|
249
|
+
app/assets/javascripts/studio/canvas_confetti.js: no CDN, same-origin) and
|
|
250
|
+
exports burst, cannons and confetti. It also keeps the deprecated globals
|
|
251
|
+
window.studioConfetti and window.confetti, which studio/head_chrome's
|
|
252
|
+
fireSuccessConfetti and host classic code still call. Before Alpine, so both
|
|
253
|
+
exist by the time an x-init can fire one.
|
|
254
|
+
An inline module tag, never javascript_include_tag: that helper's Link
|
|
255
|
+
modulepreload header had the browser resolve "canvas-confetti" before the
|
|
256
|
+
map above existed, so on a page whose head parsed slowly (the hub's boards
|
|
257
|
+
and /dreams) the module failed with "Failed to resolve module specifier"
|
|
258
|
+
(e2e/confetti_resolves_under_a_slow_head.spec.js). %>
|
|
259
|
+
<%= javascript_import_module_tag "studio/studio_confetti" %>
|
|
248
260
|
<%# ALPINE LOADS AFTER THE MODULE TAGS, and the order is the contract. Module
|
|
249
261
|
scripts (and deferred classic ones) execute in document order, so by the time
|
|
250
262
|
Alpine starts, studio/application has defined the shims it evaluates
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# studio-engine Node tooling
|
|
2
|
+
|
|
3
|
+
The one Node tooling package every host shares. It ships inside the gem, so a
|
|
4
|
+
host gets it with the engine it already bundles: no npm registry, no token.
|
|
5
|
+
|
|
6
|
+
- `versions.json`: the pin set. Node's major, and the exact version of every
|
|
7
|
+
shared npm package. A host that declares one of these packages declares it at
|
|
8
|
+
exactly this version.
|
|
9
|
+
- `playwright_base.js`: the browser lane's shared defaults (one chromium
|
|
10
|
+
project, one worker, a Rails `webServer` on `/up`).
|
|
11
|
+
- `e2e_helpers.js`: `watchPageErrors` and `blockOffsiteRequests`.
|
|
12
|
+
- `node_test/register.mjs` and `node_test/importmap_hooks.mjs`: let `node --test`
|
|
13
|
+
import a module by the bare name the importmap pins it under.
|
|
14
|
+
|
|
15
|
+
Every file here requires only `node:` builtins and its own siblings, because it
|
|
16
|
+
is loaded from the gem's directory, which has no `node_modules`.
|
|
17
|
+
|
|
18
|
+
A host links the package into its own tree and checks itself against the pins:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bin/rails studio:node:link # node_modules/.studio-engine -> this directory
|
|
22
|
+
bin/rails studio:node:check # exits 1, naming each pin the host drifts from
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Run `link` after `npm ci`, because `npm ci` deletes `node_modules`. Then
|
|
26
|
+
`require("./node_modules/.studio-engine/playwright_base.js")`. The adoption
|
|
27
|
+
steps are in the engine repository's `docs/FRONT_END_STANDARD.md`, "Node tooling".
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// studio-engine's shared browser-lane helpers. Requires only node: builtins, so it
|
|
2
|
+
// loads from the gem's directory (see README.md).
|
|
3
|
+
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
|
|
6
|
+
const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost"]);
|
|
7
|
+
|
|
8
|
+
function isOffOrigin(url) {
|
|
9
|
+
if (!url || !/^https?:/.test(url)) return false;
|
|
10
|
+
return !LOCAL_HOSTS.has(new URL(url).hostname);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Collect everything the page THREW or logged as an error.
|
|
14
|
+
//
|
|
15
|
+
// `pageerror` is the cheapest detector there is for a script that never ran: the
|
|
16
|
+
// phantom-element defect threw a SyntaxError on every load while the response
|
|
17
|
+
// bytes were valid and the server-side guard passed.
|
|
18
|
+
//
|
|
19
|
+
// It hears every frame on the page, a third party's included: Playwright delivers a
|
|
20
|
+
// cross-origin frame's console to the page's `console` event. `ownOriginOnly: true`
|
|
21
|
+
// scopes that out BY ORIGIN, never by message text. A console error is dropped only
|
|
22
|
+
// when the script that logged it was served from another host, and a thrown error
|
|
23
|
+
// only when every frame of its stack was. An error with no location or no stack is
|
|
24
|
+
// kept, because it cannot be shown to be someone else's. The default counts
|
|
25
|
+
// everything.
|
|
26
|
+
function watchPageErrors(page, { ownOriginOnly = false } = {}) {
|
|
27
|
+
const errors = [];
|
|
28
|
+
page.on("pageerror", (error) => {
|
|
29
|
+
if (ownOriginOnly) {
|
|
30
|
+
const sources = (error.stack || "").match(/https?:\/\/[^\s)]+/g) || [];
|
|
31
|
+
if (sources.length > 0 && sources.every(isOffOrigin)) return;
|
|
32
|
+
}
|
|
33
|
+
errors.push(`pageerror: ${error.message}`);
|
|
34
|
+
});
|
|
35
|
+
page.on("console", (message) => {
|
|
36
|
+
if (message.type() !== "error") return;
|
|
37
|
+
if (ownOriginOnly && isOffOrigin(message.location().url)) return;
|
|
38
|
+
errors.push(`console.error: ${message.text()}`);
|
|
39
|
+
});
|
|
40
|
+
return errors;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Answer every request that leaves this origin, so the lane cannot depend on the
|
|
44
|
+
// public internet.
|
|
45
|
+
//
|
|
46
|
+
// ANSWERED, NOT ABORTED. route.abort() makes Chromium log `Failed to load resource:
|
|
47
|
+
// net::ERR_FAILED`, which watchPageErrors counts. An empty 200 logs nothing, and
|
|
48
|
+
// the page falls back as it would offline.
|
|
49
|
+
//
|
|
50
|
+
// `pinned` maps an off-origin URL to `{ file, contentType }`: a file loaded with
|
|
51
|
+
// Subresource Integrity cannot be answered empty (the digest fails and Chromium
|
|
52
|
+
// logs it), so it is answered with a byte-exact local copy. The response carries
|
|
53
|
+
// `access-control-allow-origin: *`, because a `crossorigin` tag refuses a CORS
|
|
54
|
+
// response without it before checking the digest.
|
|
55
|
+
async function blockOffsiteRequests(page, { pinned = {} } = {}) {
|
|
56
|
+
await page.route("**/*", (route) => {
|
|
57
|
+
const url = new URL(route.request().url());
|
|
58
|
+
if (LOCAL_HOSTS.has(url.hostname)) return route.continue();
|
|
59
|
+
|
|
60
|
+
const copy = pinned[url.href];
|
|
61
|
+
if (copy) {
|
|
62
|
+
return route.fulfill({
|
|
63
|
+
status: 200,
|
|
64
|
+
contentType: copy.contentType,
|
|
65
|
+
headers: { "access-control-allow-origin": "*" },
|
|
66
|
+
body: fs.readFileSync(copy.file),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return route.fulfill({ status: 200, body: "" });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { isOffOrigin, watchPageErrors, blockOffsiteRequests };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Resolve importmap specifiers under `node --test` the way the browser does.
|
|
2
|
+
//
|
|
3
|
+
// In the browser, config/importmap.rb pins a directory under a prefix (`board`,
|
|
4
|
+
// `cyvasse`, `studio`), and the modules import each other as "board/colors".
|
|
5
|
+
// Node has no import map, so this hook maps each prefix onto the same files. A
|
|
6
|
+
// bare top-level name with a file of its own in `topLevelDir`
|
|
7
|
+
// (`import … from "chat_follow"`, pinned by name) maps the same way.
|
|
8
|
+
//
|
|
9
|
+
// A Rails app has no package.json "type", so Node has to guess each .js file's
|
|
10
|
+
// format. Files under `esmDirs` are ES modules (that is what the browser loads),
|
|
11
|
+
// so this hook says so instead.
|
|
12
|
+
//
|
|
13
|
+
// Configured by register.mjs through `initialize(data)`; it holds no app's names.
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
let prefixes = [];
|
|
17
|
+
let topLevel = null;
|
|
18
|
+
let esmDirs = [];
|
|
19
|
+
|
|
20
|
+
const asDir = (url) => (url.endsWith("/") ? url : `${url}/`);
|
|
21
|
+
|
|
22
|
+
export function initialize({ root, prefixes: map = {}, topLevelDir = null, esmDirs: dirs = [] } = {}) {
|
|
23
|
+
const base = asDir(String(root));
|
|
24
|
+
prefixes = Object.entries(map).map(([prefix, dir]) => [prefix, asDir(new URL(dir, base).href)]);
|
|
25
|
+
topLevel = topLevelDir ? asDir(new URL(topLevelDir, base).href) : null;
|
|
26
|
+
esmDirs = dirs.map((dir) => asDir(new URL(dir, base).href));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mapped(specifier) {
|
|
30
|
+
for (const [prefix, dir] of prefixes) {
|
|
31
|
+
const match = specifier.startsWith(`${prefix}/`) && /^[\w/-]+$/.test(specifier.slice(prefix.length + 1));
|
|
32
|
+
if (match) return new URL(`${specifier.slice(prefix.length + 1)}.js`, dir).href;
|
|
33
|
+
}
|
|
34
|
+
if (topLevel && /^[a-z_]+$/.test(specifier)) {
|
|
35
|
+
const file = new URL(`${specifier}.js`, topLevel);
|
|
36
|
+
if (existsSync(file)) return file.href;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
42
|
+
const target = mapped(specifier);
|
|
43
|
+
const resolved = await nextResolve(target || specifier, context);
|
|
44
|
+
if (resolved.url.endsWith(".js") && esmDirs.some((dir) => resolved.url.startsWith(dir))) {
|
|
45
|
+
return { ...resolved, format: "module" };
|
|
46
|
+
}
|
|
47
|
+
return resolved;
|
|
48
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Installs importmap_hooks.mjs for `node --test`. A host's own register file
|
|
2
|
+
// (loaded with `node --import`) names its prefixes and calls this once:
|
|
3
|
+
//
|
|
4
|
+
// import { registerImportmapHooks } from "../../../node_modules/.studio-engine/node_test/register.mjs";
|
|
5
|
+
// registerImportmapHooks({
|
|
6
|
+
// root: new URL("../../../", import.meta.url),
|
|
7
|
+
// prefixes: { board: "app/javascript/board/" },
|
|
8
|
+
// topLevelDir: "app/javascript/",
|
|
9
|
+
// esmDirs: ["app/javascript/", "test/javascript/"],
|
|
10
|
+
// });
|
|
11
|
+
//
|
|
12
|
+
// Paths are relative to `root`, the host's Rails root.
|
|
13
|
+
import { register } from "node:module";
|
|
14
|
+
|
|
15
|
+
export function registerImportmapHooks({ root, prefixes = {}, topLevelDir = null, esmDirs = [] }) {
|
|
16
|
+
if (!root) throw new Error("registerImportmapHooks: root is required");
|
|
17
|
+
register("./importmap_hooks.mjs", import.meta.url, {
|
|
18
|
+
data: { root: String(root), prefixes, topLevelDir, esmDirs },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// studio-engine's shared Playwright defaults.
|
|
2
|
+
//
|
|
3
|
+
// A plain object, not a defineConfig() call: this file is loaded from the gem's
|
|
4
|
+
// directory, which has no node_modules, so it cannot require @playwright/test.
|
|
5
|
+
// The host requires its own copy and wraps the result:
|
|
6
|
+
//
|
|
7
|
+
// const { defineConfig } = require("@playwright/test");
|
|
8
|
+
// const { studioPlaywrightConfig } = require("./node_modules/.studio-engine/playwright_base.js");
|
|
9
|
+
// module.exports = defineConfig(studioPlaywrightConfig({
|
|
10
|
+
// defaultPort: "3000",
|
|
11
|
+
// webServer: { command: "bin/rails server -p $E2E_PORT" },
|
|
12
|
+
// }));
|
|
13
|
+
//
|
|
14
|
+
// Every key of `overrides` replaces the default of the same name; `use` merges
|
|
15
|
+
// one level deep, so a host can add `reducedMotion` and keep `baseURL`.
|
|
16
|
+
|
|
17
|
+
const DEFAULTS = Object.freeze({
|
|
18
|
+
testDir: "./e2e",
|
|
19
|
+
timeout: 30_000,
|
|
20
|
+
retries: 0,
|
|
21
|
+
workers: 1,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// The base URL, from the environment first: PW_BASE_URL (or BASE_URL) points the
|
|
25
|
+
// lane at a stack someone else booted, and then no webServer is started.
|
|
26
|
+
function studioBaseURL({ defaultPort, env = process.env } = {}) {
|
|
27
|
+
const port = env.E2E_PORT || String(defaultPort);
|
|
28
|
+
const external = env.PW_BASE_URL || env.BASE_URL || null;
|
|
29
|
+
return { port, external, baseURL: external || `http://127.0.0.1:${port}` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 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 }) {
|
|
35
|
+
if (!command) throw new Error("railsWebServer: a command is required");
|
|
36
|
+
return {
|
|
37
|
+
command,
|
|
38
|
+
url: `http://127.0.0.1:${port}/up`,
|
|
39
|
+
reuseExistingServer: !ci,
|
|
40
|
+
timeout,
|
|
41
|
+
stdout: "pipe",
|
|
42
|
+
stderr: "pipe",
|
|
43
|
+
env: { RAILS_ENV: "test", E2E_PORT: String(port), ...env },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function studioPlaywrightConfig({ defaultPort, webServer, env = process.env, use = {}, ...overrides } = {}) {
|
|
48
|
+
if (!defaultPort) throw new Error("studioPlaywrightConfig: defaultPort is required");
|
|
49
|
+
const { port, external, baseURL } = studioBaseURL({ defaultPort, env });
|
|
50
|
+
const config = {
|
|
51
|
+
...DEFAULTS,
|
|
52
|
+
forbidOnly: !!env.CI,
|
|
53
|
+
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
|
|
54
|
+
...overrides,
|
|
55
|
+
use: { baseURL, headless: true, ...use },
|
|
56
|
+
};
|
|
57
|
+
if (!external && webServer) {
|
|
58
|
+
config.webServer = railsWebServer({ ci: !!env.CI, ...webServer, port });
|
|
59
|
+
}
|
|
60
|
+
return config;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { DEFAULTS, studioBaseURL, railsWebServer, studioPlaywrightConfig };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Studio
|
|
8
|
+
# The engine's Node tooling package (lib/studio/node_tooling/README.md): the
|
|
9
|
+
# one pin set, the shared Playwright base and helpers, and the node:test hooks.
|
|
10
|
+
#
|
|
11
|
+
# The gem's path is resolved here in Ruby, never by Node running bundler (the
|
|
12
|
+
# same rule Studio::TailwindEntry keeps). `link!` puts a symlink at a fixed
|
|
13
|
+
# path in the host, so a host's playwright.config.js or test register file
|
|
14
|
+
# requires `./node_modules/.studio-engine/...` with no shell-out.
|
|
15
|
+
#
|
|
16
|
+
# `drift` holds a host to the pins: Node's major in `.tool-versions` (what
|
|
17
|
+
# mise and actions/setup-node's `node-version-file` read) and package.json
|
|
18
|
+
# `engines`, and the exact version of every pinned package the host declares,
|
|
19
|
+
# in package.json and in package-lock.json. A pinned package the host does not
|
|
20
|
+
# declare is not drift: no host is made to install what it does not use.
|
|
21
|
+
module NodeTooling
|
|
22
|
+
ROOT = Pathname(__dir__).join("node_tooling").freeze
|
|
23
|
+
LINK = "node_modules/.studio-engine"
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def root = ROOT
|
|
28
|
+
|
|
29
|
+
# The host the rake tasks act on. A method of its own so a test can point
|
|
30
|
+
# the tasks at a temporary host without defining a Rails constant.
|
|
31
|
+
def host_root = ::Rails.root
|
|
32
|
+
|
|
33
|
+
def versions = JSON.parse(ROOT.join("versions.json").read)
|
|
34
|
+
|
|
35
|
+
def link_path(app_root) = Pathname(app_root).join(LINK)
|
|
36
|
+
|
|
37
|
+
# Idempotent. Replaces a stale link (an older gem's directory); refuses a
|
|
38
|
+
# real directory or file at the path rather than deleting someone's work.
|
|
39
|
+
def link!(app_root)
|
|
40
|
+
path = link_path(app_root)
|
|
41
|
+
if path.symlink?
|
|
42
|
+
return path if path.readlink == ROOT
|
|
43
|
+
|
|
44
|
+
path.delete
|
|
45
|
+
elsif path.exist?
|
|
46
|
+
raise ArgumentError, "#{path} exists and is not a symlink; move it aside and link again"
|
|
47
|
+
end
|
|
48
|
+
FileUtils.mkdir_p(path.dirname)
|
|
49
|
+
File.symlink(ROOT, path)
|
|
50
|
+
path
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Every way the host at `app_root` differs from versions.json, one sentence
|
|
54
|
+
# each. Empty when it matches.
|
|
55
|
+
def drift(app_root)
|
|
56
|
+
app_root = Pathname(app_root)
|
|
57
|
+
pins = versions
|
|
58
|
+
major = pins.fetch("node")
|
|
59
|
+
problems = node_version_drift(app_root, major)
|
|
60
|
+
|
|
61
|
+
package = read_json(app_root.join("package.json"))
|
|
62
|
+
return problems unless package
|
|
63
|
+
|
|
64
|
+
engines = package.dig("engines", "node")
|
|
65
|
+
unless engines == "#{major}.x"
|
|
66
|
+
problems << "package.json engines.node is #{engines.inspect}; the pin is \"#{major}.x\""
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
lock = read_json(app_root.join("package-lock.json"))
|
|
70
|
+
pins.except("node").each do |name, version|
|
|
71
|
+
declared = package.dig("devDependencies", name) || package.dig("dependencies", name)
|
|
72
|
+
next unless declared
|
|
73
|
+
|
|
74
|
+
problems << "package.json declares #{name} #{declared.inspect}; the pin is \"#{version}\"" unless declared == version
|
|
75
|
+
locked = lock&.dig("packages", "node_modules/#{name}", "version")
|
|
76
|
+
problems << "package-lock.json locks #{name} at #{locked.inspect}; the pin is \"#{version}\"" unless locked == version
|
|
77
|
+
end
|
|
78
|
+
problems
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def node_version_drift(app_root, major)
|
|
82
|
+
file = app_root.join(".tool-versions")
|
|
83
|
+
return ["no .tool-versions; it should read \"nodejs #{major}\""] unless file.file?
|
|
84
|
+
|
|
85
|
+
line = file.read.lines.map(&:split).find { |tool, *| %w[nodejs node].include?(tool) }
|
|
86
|
+
return [".tool-versions names no nodejs; it should read \"nodejs #{major}\""] unless line
|
|
87
|
+
return [] if line[1] == major
|
|
88
|
+
|
|
89
|
+
[".tool-versions reads \"#{line.join(" ")}\"; it should read \"nodejs #{major}\""]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def read_json(path)
|
|
93
|
+
path.file? ? JSON.parse(path.read) : nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The engine's Node tooling package (lib/studio/node_tooling/README.md). Neither
|
|
4
|
+
# task loads :environment; both need only Rails.root (Studio::NodeTooling.host_root).
|
|
5
|
+
namespace :studio do
|
|
6
|
+
namespace :node do
|
|
7
|
+
desc "Link studio-engine's Node tooling package at node_modules/.studio-engine (run after npm ci)"
|
|
8
|
+
task :link do
|
|
9
|
+
root = Studio::NodeTooling.host_root
|
|
10
|
+
path = Studio::NodeTooling.link!(root)
|
|
11
|
+
puts "studio-engine: #{path.relative_path_from(root)} -> #{Studio::NodeTooling.root}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
desc "Fail when this app's Node pins drift from studio-engine's versions.json"
|
|
15
|
+
task :check do
|
|
16
|
+
problems = Studio::NodeTooling.drift(Studio::NodeTooling.host_root)
|
|
17
|
+
if problems.empty?
|
|
18
|
+
puts "studio-engine: Node tooling matches the pins #{Studio::NodeTooling.versions.to_json}"
|
|
19
|
+
else
|
|
20
|
+
abort(["studio-engine: Node tooling drifts from the pins:", *problems.map { |p| " - #{p}" }].join("\n"))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.115.1
|
|
4
|
+
version: 0.115.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -774,6 +774,13 @@ files:
|
|
|
774
774
|
- lib/studio/navbar_slots.rb
|
|
775
775
|
- lib/studio/navbar_wordmark.rb
|
|
776
776
|
- lib/studio/newsletter.rb
|
|
777
|
+
- lib/studio/node_tooling.rb
|
|
778
|
+
- lib/studio/node_tooling/README.md
|
|
779
|
+
- lib/studio/node_tooling/e2e_helpers.js
|
|
780
|
+
- lib/studio/node_tooling/node_test/importmap_hooks.mjs
|
|
781
|
+
- lib/studio/node_tooling/node_test/register.mjs
|
|
782
|
+
- lib/studio/node_tooling/playwright_base.js
|
|
783
|
+
- lib/studio/node_tooling/versions.json
|
|
777
784
|
- lib/studio/oauth_identity.rb
|
|
778
785
|
- lib/studio/partial_block.rb
|
|
779
786
|
- lib/studio/profile_image.rb
|
|
@@ -799,6 +806,7 @@ files:
|
|
|
799
806
|
- lib/studio/version.rb
|
|
800
807
|
- lib/tasks/studio_email.rake
|
|
801
808
|
- lib/tasks/studio_knowledge.rake
|
|
809
|
+
- lib/tasks/studio_node.rake
|
|
802
810
|
- lib/tasks/studio_trash.rake
|
|
803
811
|
- studio-engine.gemspec
|
|
804
812
|
homepage: https://github.com/McRitchie-Studio/studio-engine
|