From 466304f125fe7812a6c8de3d1b66178e64e52360 Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:58:52 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(gui):=20monaco=20editor=20=E3=82=92?= =?UTF-8?q?=E8=87=AA=E5=89=8D=E3=83=9B=E3=82=B9=E3=83=88=E3=81=97=E3=81=A6?= =?UTF-8?q?=20CDN=20=E4=BE=9D=E5=AD=98=E3=82=92=E7=84=A1=E3=81=8F=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ルビータブの Monaco 本体を cdn.jsdelivr.net から読むのをやめ、node_modules の min/vs をビルド成果物 static/monaco/vs へコピーして自前配信する。CDN が塞がれた 学校ネットワークやオフライン (PWA) でもルビータブが動き、バージョンは package.json の monaco-editor に自動的に揃う (URL 直書きが消える)。 - resolveMonacoVsPath() が webpack の publicPath を前置するので、publicPath:'auto' (dist) と GitHub Pages サブディレクトリ / ブランチプレビューの全てに対応する - min/vs は約 16MB あるため PWA の precache から除外し、Workbox の runtimeCaching (StaleWhileRevalidate) で扱う - init-firewall.sh の cdn.jsdelivr.net は顔認識拡張 (@mediapipe/face_detection) が まだ CDN を使うため残し、Monaco 由来の理由づけだけを外した Closes #1171 Co-Authored-By: Claude Opus 4.8 (1M context) --- .devcontainer/README.md | 9 +-- .devcontainer/init-firewall.sh | 3 +- docs/maintenance/smalruby-markers-gui.md | 1 + docs/ruby-editor/README.md | 20 ++++++ .../scratch-gui/src/lib/monaco-i18n-helper.js | 44 ++++++++++++- .../test/unit/lib/monaco-i18n-helper.test.js | 61 +++++++++++++++++++ packages/scratch-gui/webpack.config.js | 42 ++++++++++++- 7 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 packages/scratch-gui/test/unit/lib/monaco-i18n-helper.test.js diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 51fde6d3152..d19534d2126 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -120,10 +120,11 @@ NaCl の隔離ガイドラインでは「非公開認証情報が到達する隔 | AWS | `ip-ranges.json` のうち **デプロイリージョン + us-east-1 + GLOBAL**、加えて SSO 系 (`oidc/portal.sso/sso..amazonaws.com`, SSO ポータル) | `cdk deploy` / `aws sso login` | | DNS | `/etc/resolv.conf` のリゾルバ + docker DNS (127.0.0.11) の 53 番のみ | 名前解決 (任意 DNS への exfil を遮断) | -- 製品がランタイムで取りに行く先も許可している: **`cdn.jsdelivr.net`** (Monaco Editor 本体。 - `src/lib/monaco-i18n-helper.js` が CDN から読む)、**`accounts.google.com` / - `apis.google.com`** (Google ログイン)。これが無いと **コンテナ内のブラウザで GUI を開いても - 読み込みが完了せず**、検証そのものができない。解析用の `www.googletagmanager.com` は +- 製品がランタイムで取りに行く先も許可している: **`cdn.jsdelivr.net`** (顔認識拡張が + `@mediapipe/face_detection` を CDN から読む。Monaco Editor 本体は自前配信に切り替えたので + この許可には依存しない → `docs/ruby-editor/README.md`)、**`accounts.google.com` / + `apis.google.com`** (Google ログイン)。これが無いと **コンテナ内のブラウザで該当機能を + 使えず**、検証そのものができない。解析用の `www.googletagmanager.com` は 開発に不要なので入れていない (拒否されても即失敗するだけで先に進む)。 - AWS のリージョンと SSO ポータルは **`infra/aws-sso.env`** から自動取得する (fork 時はそのファイルだけ書き換えれば firewall も追従)。 diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh index cb7cc9e78cd..0518ea955d5 100755 --- a/.devcontainer/init-firewall.sh +++ b/.devcontainer/init-firewall.sh @@ -54,7 +54,8 @@ EXTRA_HOSTS=( deb.debian.org security.debian.org # 製品がランタイムで取りに行く先。落とすとコンテナ内でのブラウザ検証が成立しない (#1169) - # cdn.jsdelivr.net … Monaco Editor 本体 (src/lib/monaco-i18n-helper.js) + # cdn.jsdelivr.net … 顔認識拡張 (scratch3_face_sensing) が @mediapipe/face_detection を + # CDN から読む。Monaco Editor は自前配信に切り替えたので不要 (#1171) # accounts/apis.google.com … Google ログイン (クラス管理 / Drive) # www.googletagmanager.com は解析用で開発に不要なので入れない (REJECT で即失敗する)。 cdn.jsdelivr.net diff --git a/docs/maintenance/smalruby-markers-gui.md b/docs/maintenance/smalruby-markers-gui.md index 2575fae1cf7..01c4ed9106f 100644 --- a/docs/maintenance/smalruby-markers-gui.md +++ b/docs/maintenance/smalruby-markers-gui.md @@ -68,6 +68,7 @@ upstream ファイルに追加した Smalruby 固有コードのマーカー一 | `src/reducers/menus.js` | display mode menu | `PreferenceMenu` サブメニューの開閉用 Redux state (`displayModeMenu`)。定数/rootMenu への登録/initialState/open・close・selector の追加 (Issue #865) | | `webpack.config.js` | classroom API | CLASSROOM_API_ENDPOINT 環境変数注入 | | `webpack.config.js` | scratch api proxy endpoint | SCRATCH_API_PROXY_ENDPOINT 環境変数注入 | +| `webpack.config.js` | self-hosted Monaco Editor (#1171) | Monaco 本体 (`min/vs`) を `static/monaco/vs` へコピーする定数・CopyWebpackPlugin パターンと、PWA precache 除外 + runtime caching 設定。マーカーは 2 箇所の `GenerateSW` にも単一行で入る | | `eslint.config.mjs` | react lifecycle typo detection | `react/no-typos` を error にして getDerivedStateFromProps/Error の static 抜け等を lint で検出 | | `eslint.config.mjs` | prettier integration | eslintConfigPrettier を最後に置いて prettier と競合する整形ルールを無効化 | | `src/lib/blocks.js` | gesture recovery import | ジェスチャー復旧モジュールの import | diff --git a/docs/ruby-editor/README.md b/docs/ruby-editor/README.md index b61e6de2538..06772885a9b 100644 --- a/docs/ruby-editor/README.md +++ b/docs/ruby-editor/README.md @@ -96,6 +96,26 @@ ruby-toolbar 上部のセグメントで切替: | `packages/scratch-gui/src/containers/ruby-tab/quick-fix-provider.js` | エラー時の Quick Fix | | `packages/scratch-gui/src/containers/ruby-tab/execution-highlighter.js` | 実行中の行ハイライト | | `packages/scratch-gui/src/containers/ruby-tab/visual-report-bubble.js` | ブロックの実行結果をエディタにバブル表示 | +| `packages/scratch-gui/src/lib/monaco-i18n-helper.js` | Monaco 本体 (`min/vs`) の読み込み先の解決と、日本語 NLS の読み込み | + +##### Monaco 本体の配信 (自前ホスト) + +Monaco 本体は **CDN からではなく自前で配信する**。学校ネットワークで汎用 CDN が塞がれている +環境でもルビータブが動くようにするため、また PWA としてオフラインでも使えるようにするため。 + +- `webpack.config.js` の `CopyWebpackPlugin` が `node_modules/monaco-editor/min/vs` を + ビルド成果物の **`static/monaco/vs`** へコピーする(`MONACO_VS_DEST`) +- `monaco-i18n-helper.js` の `resolveMonacoVsPath()` が webpack の `publicPath` を前置して + AMD ローダーへ渡す。`dist` の `publicPath: 'auto'`(scratch-desktop / scratch-android の + 相対解決)と、smalruby.app / GitHub Pages サブディレクトリ / ブランチプレビューの + 3 通りの base パスすべてに対応するため、絶対パスを直書きしない +- バージョンは `packages/scratch-gui/package.json` の `monaco-editor` に自動的に揃う + (URL へのバージョン直書きは無い)。日本語 NLS も同じ npm パッケージから読むのでズレない +- `min/vs` は約 16MB あるため **PWA の precache からは除外**し、Workbox の + `runtimeCaching`(`StaleWhileRevalidate` / キャッシュ名 `smalruby-monaco-editor`)で扱う。 + 初回インストールを軽く保ちつつ、一度開けばオフラインでもルビータブが使える +- コピー先を変えるときは `webpack.config.js` の `MONACO_VS_DEST` と + `monaco-i18n-helper.js` の `MONACO_VS_SUBPATH` を**対で**変更する #### ruby-toolbar diff --git a/packages/scratch-gui/src/lib/monaco-i18n-helper.js b/packages/scratch-gui/src/lib/monaco-i18n-helper.js index b8df73dbc7c..833bca86891 100644 --- a/packages/scratch-gui/src/lib/monaco-i18n-helper.js +++ b/packages/scratch-gui/src/lib/monaco-i18n-helper.js @@ -1,10 +1,48 @@ /* eslint-disable no-console */ +/* global __webpack_public_path__ */ import { loader } from '@monaco-editor/react'; -// Configure loader to use a specific version from CDN to match our local NLS messages +/** + * `min/vs` の配信先。`webpack.config.js` の CopyWebpackPlugin が + * `node_modules/monaco-editor/min/vs` をここへコピーする (#1171)。 + * 場所を変えるときは webpack 側のコピー先も対で変えること。 + */ +const MONACO_VS_SUBPATH = 'static/monaco/vs'; + +/** + * Monaco の AMD ローダーに渡す `vs` パスを publicPath から組み立てる。 + * + * CI は smalruby.app / GitHub Pages のサブディレクトリ / ブランチプレビューの 3 通りの + * base パスでビルドし、`dist` は scratch-desktop / scratch-android のために + * `publicPath: 'auto'`(実行時解決)を使う。そのため絶対パスを直書きできず、 + * webpack の publicPath を前置して解決する。 + * @param {string} publicPath webpack の publicPath(空文字・末尾スラッシュ有無どちらも可)。 + * @returns {string} Monaco の `vs` ディレクトリを指すパス。 + */ +const resolveMonacoVsPath = (publicPath) => { + // `'auto'` は webpack が実行時の値へ置き換える前のリテラル。素通しすると + // `auto/static/...` という無効な URL になるので publicPath なし扱いにする。 + const base = typeof publicPath === 'string' && publicPath !== 'auto' ? publicPath : ''; + if (base === '') { + return MONACO_VS_SUBPATH; + } + return `${base.replace(/\/+$/, '')}/${MONACO_VS_SUBPATH}`; +}; + +/** + * webpack が注入する publicPath を読む。webpack 外(jest など)では未定義なので空文字にする。 + * @returns {string} publicPath(未定義なら空文字)。 + */ +const webpackPublicPath = () => + // eslint-disable-next-line camelcase + typeof __webpack_public_path__ === 'string' ? __webpack_public_path__ : ''; + +// Monaco 本体は CDN ではなく自前配信物から読み込む (#1171)。 +// これにより CDN が塞がれた学校ネットワークやオフライン (PWA) でもルビータブが動き、 +// バージョンは package.json の monaco-editor に自動的に揃う。 loader.config({ paths: { - vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs', + vs: resolveMonacoVsPath(webpackPublicPath()), }, }); @@ -23,4 +61,4 @@ const loadMonacoLocale = async (locale) => { } }; -export { loadMonacoLocale }; +export { loadMonacoLocale, resolveMonacoVsPath }; diff --git a/packages/scratch-gui/test/unit/lib/monaco-i18n-helper.test.js b/packages/scratch-gui/test/unit/lib/monaco-i18n-helper.test.js new file mode 100644 index 00000000000..ce97ffc8c70 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/monaco-i18n-helper.test.js @@ -0,0 +1,61 @@ +/** + * Monaco Editor のローダ設定が「自前ホスト」を指していることを担保するテスト (#1171)。 + * CDN (cdn.jsdelivr.net) へ戻すリグレッションをここで検出する。 + */ + +const configCalls = []; + +jest.mock('@monaco-editor/react', () => ({ + loader: { + config: (options) => configCalls.push(options), + }, +})); + +describe('monaco-i18n-helper', () => { + describe('resolveMonacoVsPath', () => { + const { resolveMonacoVsPath } = require('../../../src/lib/monaco-i18n-helper'); + + test('returns a relative path when publicPath is empty', () => { + expect(resolveMonacoVsPath('')).toBe('static/monaco/vs'); + }); + + test('joins a publicPath that ends with a slash', () => { + expect(resolveMonacoVsPath('/smalruby3-editor/')).toBe( + '/smalruby3-editor/static/monaco/vs', + ); + }); + + test('joins a publicPath without a trailing slash', () => { + expect(resolveMonacoVsPath('/smalruby3-editor')).toBe( + '/smalruby3-editor/static/monaco/vs', + ); + }); + + test('keeps absolute origins intact', () => { + expect(resolveMonacoVsPath('https://smalruby.app/')).toBe( + 'https://smalruby.app/static/monaco/vs', + ); + }); + + test('treats the literal "auto" and non-strings as no publicPath', () => { + expect(resolveMonacoVsPath('auto')).toBe('static/monaco/vs'); + expect(resolveMonacoVsPath(undefined)).toBe('static/monaco/vs'); + expect(resolveMonacoVsPath(null)).toBe('static/monaco/vs'); + }); + }); + + describe('loader configuration', () => { + test('configures the AMD loader with the self-hosted vs path', () => { + require('../../../src/lib/monaco-i18n-helper'); + + expect(configCalls).toHaveLength(1); + expect(configCalls[0].paths.vs).toBe('static/monaco/vs'); + }); + + test('does not reference any CDN', () => { + require('../../../src/lib/monaco-i18n-helper'); + + expect(JSON.stringify(configCalls)).not.toMatch(/jsdelivr|unpkg|cdn/i); + }); + }); +}); diff --git a/packages/scratch-gui/webpack.config.js b/packages/scratch-gui/webpack.config.js index ef194c5ef96..1afe5338375 100644 --- a/packages/scratch-gui/webpack.config.js +++ b/packages/scratch-gui/webpack.config.js @@ -29,6 +29,30 @@ const commonHtmlWebpackPluginOptions = { gtm_env_auth: process.env.GTM_ENV_AUTH || '' }; +// === Smalruby: Start of self-hosted Monaco Editor (#1171) === +// Monaco 本体 (`min/vs`) を CDN ではなく自前で配信する。コピー先は +// `src/lib/monaco-i18n-helper.js` の MONACO_VS_SUBPATH と対で維持すること。 +const MONACO_VS_DEST = 'static/monaco/vs'; + +// `min/vs` は約 16MB あり、PWA の precache に入れると初回インストールが極端に重くなる。 +// precache からは除外し、runtime caching (StaleWhileRevalidate) でオフライン対応する。 +const monacoPrecacheExclude = /^static[\\/]monaco[\\/]/; +const monacoRuntimeCaching = { + urlPattern: /\/static\/monaco\//, + handler: 'StaleWhileRevalidate', + options: { + cacheName: 'smalruby-monaco-editor', + expiration: { + maxEntries: 400, + maxAgeSeconds: 30 * 24 * 60 * 60 // 30 日 + }, + cacheableResponse: { + statuses: [0, 200] + } + } +}; +// === Smalruby: End of self-hosted Monaco Editor (#1171) === + const cssModuleExceptions = [ /\.raw\.css$/, // Allow for overriding CSS classes from libraries /[\\/]driver\.js[\\/].*\.css$/ // driver.js CSS @@ -181,7 +205,15 @@ const baseConfig = new ScratchWebpackConfigBuilder( { from: '../../node_modules/@mediapipe/face_detection', to: 'chunks/mediapipe/face_detection' + }, + // === Smalruby: Start of self-hosted Monaco Editor (#1171) === + { + from: '../../node_modules/monaco-editor/min/vs', + to: MONACO_VS_DEST, + // 既に minify 済みなので minimizer に通さない (ビルド時間の無駄を避ける) + info: {minimized: true} } + // === Smalruby: End of self-hosted Monaco Editor (#1171) === ] })); @@ -332,8 +364,11 @@ const buildWithPwaConfig = buildConfig.clone() additionalManifestEntries: assetsManifest, exclude: [ /\.DS_Store/, - /version\.json$/ // === Smalruby: exclude from precache for version update notification === + /version\.json$/, // === Smalruby: exclude from precache for version update notification === + monacoPrecacheExclude // === Smalruby: Monaco is runtime-cached instead (#1171) === ], + // === Smalruby: runtime caching for the self-hosted Monaco Editor (#1171) === + runtimeCaching: [monacoRuntimeCaching], maximumFileSizeToCacheInBytes: 64 * 1024 * 1024, // Don't add revision to files that already have hash in filename dontCacheBustURLsMatching: /\.[0-9a-f]{8,}\./ @@ -400,8 +435,11 @@ const distWithHtmlConfig = buildConfig.clone() additionalManifestEntries: assetsManifest, exclude: [ /\.DS_Store/, - /version\.json$/ // === Smalruby: exclude from precache for version update notification === + /version\.json$/, // === Smalruby: exclude from precache for version update notification === + monacoPrecacheExclude // === Smalruby: Monaco is runtime-cached instead (#1171) === ], + // === Smalruby: runtime caching for the self-hosted Monaco Editor (#1171) === + runtimeCaching: [monacoRuntimeCaching], maximumFileSizeToCacheInBytes: 64 * 1024 * 1024, // Don't add revision to files that already have hash in filename dontCacheBustURLsMatching: /\.[0-9a-f]{8,}\./ From 5d628b9a0cbf52ce708b4c2b9243c341c1ef1bbb Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:14:05 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(gui):=20monaco=20=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92=20prettier=20=E5=AF=BE=E8=B1=A1=E3=81=AB?= =?UTF-8?q?=E5=85=A5=E3=82=8C=E3=83=AD=E3=83=BC=E3=83=80=E9=A0=86=E3=81=AE?= =?UTF-8?q?=E6=B3=A8=E6=84=8F=E3=82=92=E6=98=8E=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test/unit/lib/monaco-i18n-helper.test.js を .prettierignore の ホワイトリストへ追加し prettier で整形(規約: 新規 Smalruby 固有ファイルは ホワイトリストが唯一の真実) - @monaco-editor/loader の既定 paths.vs は CDN のままなので、loader.config より 先に がレンダーされると静かに CDN へ戻る点をコードと docs に明記 Co-Authored-By: Claude Opus 5 --- docs/ruby-editor/README.md | 4 ++++ packages/scratch-gui/.prettierignore | 1 + packages/scratch-gui/src/lib/monaco-i18n-helper.js | 5 +++++ .../test/unit/lib/monaco-i18n-helper.test.js | 12 +++--------- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/ruby-editor/README.md b/docs/ruby-editor/README.md index 06772885a9b..0cb343bf665 100644 --- a/docs/ruby-editor/README.md +++ b/docs/ruby-editor/README.md @@ -116,6 +116,10 @@ Monaco 本体は **CDN からではなく自前で配信する**。学校ネッ 初回インストールを軽く保ちつつ、一度開けばオフラインでもルビータブが使える - コピー先を変えるときは `webpack.config.js` の `MONACO_VS_DEST` と `monaco-i18n-helper.js` の `MONACO_VS_SUBPATH` を**対で**変更する +- `@monaco-editor/loader` の既定 `paths.vs` は CDN のままなので、`monaco-i18n-helper.js` + の副作用インポートより先に `` がレンダーされると静かに CDN へ戻る。 + `@monaco-editor/react` を使うモジュールを増やすときは、`` のレンダー前に + `monaco-i18n-helper.js` を import すること #### ruby-toolbar diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore index a4f254b2578..0a95352c5f1 100644 --- a/packages/scratch-gui/.prettierignore +++ b/packages/scratch-gui/.prettierignore @@ -424,6 +424,7 @@ test/unit/lib/* !test/unit/lib/make-toolbox-xml.test.js !test/unit/lib/mesh-v2-classroom-binding.test.js !test/unit/lib/mesh-v2-sensor-collision.test.js +!test/unit/lib/monaco-i18n-helper.test.js !test/unit/lib/display-mode-persistence.test.js !test/unit/lib/responsive-gui.test.jsx !test/unit/lib/use-display-mode.test.js diff --git a/packages/scratch-gui/src/lib/monaco-i18n-helper.js b/packages/scratch-gui/src/lib/monaco-i18n-helper.js index 833bca86891..7f6f4d9996d 100644 --- a/packages/scratch-gui/src/lib/monaco-i18n-helper.js +++ b/packages/scratch-gui/src/lib/monaco-i18n-helper.js @@ -40,6 +40,11 @@ const webpackPublicPath = () => // Monaco 本体は CDN ではなく自前配信物から読み込む (#1171)。 // これにより CDN が塞がれた学校ネットワークやオフライン (PWA) でもルビータブが動き、 // バージョンは package.json の monaco-editor に自動的に揃う。 +// +// ⚠️ `@monaco-editor/loader` の既定 `paths.vs` は CDN (cdn.jsdelivr.net) のままで、 +// この副作用インポートより先に `loader.init()`(= `` の初回レンダー)が走ると +// 静かに CDN へフォールバックする。`@monaco-editor/react` を使うモジュールを新しく +// 追加するときは、`` をレンダーする前にこのモジュールを import すること。 loader.config({ paths: { vs: resolveMonacoVsPath(webpackPublicPath()), diff --git a/packages/scratch-gui/test/unit/lib/monaco-i18n-helper.test.js b/packages/scratch-gui/test/unit/lib/monaco-i18n-helper.test.js index ce97ffc8c70..01d78c5c424 100644 --- a/packages/scratch-gui/test/unit/lib/monaco-i18n-helper.test.js +++ b/packages/scratch-gui/test/unit/lib/monaco-i18n-helper.test.js @@ -20,21 +20,15 @@ describe('monaco-i18n-helper', () => { }); test('joins a publicPath that ends with a slash', () => { - expect(resolveMonacoVsPath('/smalruby3-editor/')).toBe( - '/smalruby3-editor/static/monaco/vs', - ); + expect(resolveMonacoVsPath('/smalruby3-editor/')).toBe('/smalruby3-editor/static/monaco/vs'); }); test('joins a publicPath without a trailing slash', () => { - expect(resolveMonacoVsPath('/smalruby3-editor')).toBe( - '/smalruby3-editor/static/monaco/vs', - ); + expect(resolveMonacoVsPath('/smalruby3-editor')).toBe('/smalruby3-editor/static/monaco/vs'); }); test('keeps absolute origins intact', () => { - expect(resolveMonacoVsPath('https://smalruby.app/')).toBe( - 'https://smalruby.app/static/monaco/vs', - ); + expect(resolveMonacoVsPath('https://smalruby.app/')).toBe('https://smalruby.app/static/monaco/vs'); }); test('treats the literal "auto" and non-strings as no publicPath', () => { From b1ecf883e7e3136cae278ea96b0d0790cc15dd28 Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:00:21 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(gui):=20file://=20=E3=81=A7=20monaco=20?= =?UTF-8?q?worker=20=E3=81=8C=E8=AA=AD=E3=82=81=E3=81=AA=E3=81=84=20CI=20?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E6=97=A2=E7=9F=A5=E3=83=AD=E3=82=B0?= =?UTF-8?q?=E3=81=A8=E3=81=97=E3=81=A6=E9=99=A4=E5=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 統合テストはビルド成果物を `file://` で開くが、自前ホストした Monaco (#1171) の editor worker は「blob + importScripts()」で生成されるため、 opaque origin (blob:null) から `file:` スクリプトを importScripts できず失敗する。 CDN 配信時代は worker の URL が https だったので起きなかった。本番配信 (http/https) は 同一オリジンなので影響せず、worker が無くてもエディタ本体は動作する (ルビータブの他の統合テストは通っている)。 - test/helpers/ignorable-severe-logs.js を追加し、無視できる SEVERE ログの判定を切り出した (upstream の ConfirmationPrompt React key 警告もここへ集約) - smalruby-tutorials.test.js の 2 箇所を helper 利用に置き換え - 判定は worker アセット (assets/*.worker-*.js) と中身の無い [object ErrorEvent] に限定。 loader.js / editor.main.js の取得失敗や CDN へのフォールバックは従来どおり失敗させる - CI が実際に出したメッセージを固定した unit test を追加 (8 tests) - docs/ruby-editor/README.md に file:// での worker 制約を追記 Co-Authored-By: Claude Opus 5 --- docs/ruby-editor/README.md | 12 +++++ .../test/helpers/ignorable-severe-logs.js | 37 +++++++++++++ .../integration/smalruby-tutorials.test.js | 22 +++----- .../helpers/ignorable-severe-logs.test.js | 54 +++++++++++++++++++ 4 files changed, 109 insertions(+), 16 deletions(-) create mode 100644 packages/scratch-gui/test/helpers/ignorable-severe-logs.js create mode 100644 packages/scratch-gui/test/unit/helpers/ignorable-severe-logs.test.js diff --git a/docs/ruby-editor/README.md b/docs/ruby-editor/README.md index 0cb343bf665..17595982f97 100644 --- a/docs/ruby-editor/README.md +++ b/docs/ruby-editor/README.md @@ -121,6 +121,18 @@ Monaco 本体は **CDN からではなく自前で配信する**。学校ネッ `@monaco-editor/react` を使うモジュールを増やすときは、`` のレンダー前に `monaco-i18n-helper.js` を import すること +###### `file://` で開いたときの editor worker(統合テストの注意点) + +Monaco は editor worker を **blob + `importScripts()`** で生成する。blob worker は +opaque origin (`blob:null`) なので、`file://` で開いたページでは新しめの Chrome が +`file:` スクリプトの importScripts を拒否し、worker の読み込みだけが失敗する +(CDN 配信時代は worker の URL が https だったため起きなかった)。 + +- **本番配信(http/https)は同一オリジンなので影響しない**。worker は通常どおり読める +- 影響を受けるのは **ビルド成果物を `file://` で開く統合テスト**のみ。エディタ本体は worker + 無しでも動作するため、`test/integration/smalruby-tutorials.test.js` はこの SEVERE ログを + 既知として除外している(`isIgnorableSevereLog`) + #### ruby-toolbar | ファイル | 役割 | diff --git a/packages/scratch-gui/test/helpers/ignorable-severe-logs.js b/packages/scratch-gui/test/helpers/ignorable-severe-logs.js new file mode 100644 index 00000000000..14d3813e01e --- /dev/null +++ b/packages/scratch-gui/test/helpers/ignorable-severe-logs.js @@ -0,0 +1,37 @@ +// === Smalruby: This file is Smalruby-specific (統合テストで無視できる SEVERE ログの判定) === + +/** + * 機能に影響しない既知の SEVERE ブラウザログか判定する。 + * + * 1. upstream の `ConfirmationPrompt` React key 警告 + * (StageHeaderComponent → ConfirmationPrompt)。Chrome は SEVERE で出すが既知の upstream 問題。 + * 2. 自前ホストした Monaco (#1171) の editor worker が `file://` から読めない件。 + * Monaco は worker を「blob + `importScripts()`」で生成する。blob worker は + * opaque origin (`blob:null`) なので、新しめの Chrome は `file:` スクリプトの importScripts を + * 拒否し、worker の読み込みだけが失敗する。統合テストはビルド成果物を `file://` で開くため + * この経路に当たる (CDN 配信時代は worker の URL が https だったので起きなかった)。 + * **本番配信は http(s) の同一オリジンなので worker は通常どおり読める**。worker が無くても + * エディタ本体は動作するため、ルビータブの他の統合テストは通っている。 + * @param {string} message ブラウザログのメッセージ。 + * @returns {boolean} 無視してよいログなら true。 + */ +const isIgnorableSevereLog = (message) => + /Each child in a list should have a unique .{1,3}key.{1,3} prop/.test(message) || + // worker アセットの取得失敗 (blob worker の importScripts / リソース取得エラー)。 + // `loader.js` や `editor.main.js` の取得失敗は Monaco 自体が読めていない状態なので + // ここには含めない(本当のリグレッションとして落としたい)。 + /static[\\/]monaco[\\/]vs[\\/]assets[\\/][\w.]+\.worker-[\w-]+\.js/.test(message) || + // 上記 worker エラーが Monaco のチャンクから uncaught として再送出されたもの。 + // 中身の無い `[object ErrorEvent]` に限定する(通常の例外はメッセージを持つ)。 + (/static[\\/]monaco[\\/]vs[\\/]/.test(message) && /\[object ErrorEvent\]/.test(message)); + +/** + * SEVERE ログのうち、無視できないものだけを返す。 + * @param {Array<{level: {name: string}, message: string}>} logs + * `getLogs({includeAllLevels: true})` の戻り値。 + * @returns {Array} 無視できない SEVERE ログ。 + */ +const unexpectedSevereLogs = (logs) => + logs.filter((l) => l.level.name === 'SEVERE').filter((l) => !isIgnorableSevereLog(l.message)); + +export { isIgnorableSevereLog, unexpectedSevereLogs }; diff --git a/packages/scratch-gui/test/integration/smalruby-tutorials.test.js b/packages/scratch-gui/test/integration/smalruby-tutorials.test.js index 95e5a36c87a..1a97561ebae 100644 --- a/packages/scratch-gui/test/integration/smalruby-tutorials.test.js +++ b/packages/scratch-gui/test/integration/smalruby-tutorials.test.js @@ -4,6 +4,8 @@ * to reduce cold-start overhead. */ import path from 'path'; +// === Smalruby: 既知の無視できる SEVERE ログの判定は helper に切り出してある(unit test 付き) === +import { unexpectedSevereLogs } from '../helpers/ignorable-severe-logs'; import SeleniumHelper from '../helpers/selenium-helper'; const { clickText, clickXpath, findByXpath, getDriver, getLogs, loadUri, scope, textExists, waitForLoadingFinished } = @@ -41,14 +43,8 @@ describe('Smalruby Tutorials', () => { await clickText('Code'); const logs = await getLogs({ includeAllLevels: true }); - // === Smalruby: ignore the upstream `ConfirmationPrompt` React key - // warning. It is logged at SEVERE level by Chrome but is a known - // upstream issue (StageHeaderComponent → ConfirmationPrompt) and - // does not affect functionality. === - const severeLogs = logs - .filter((l) => l.level.name === 'SEVERE') - .filter((l) => !/Each child in a list should have a unique .{1,3}key.{1,3} prop/.test(l.message)); - expect(severeLogs).toEqual([]); + // === Smalruby: 既知の無視できる SEVERE ログを除外する (isIgnorableSevereLog 参照) === + expect(unexpectedSevereLogs(logs)).toEqual([]); }); test('can open tutorials by url id', async () => { @@ -56,14 +52,8 @@ describe('Smalruby Tutorials', () => { await findByXpath('//div[contains(@class, "card_card_")]'); const logs = await getLogs({ includeAllLevels: true }); - // === Smalruby: ignore the upstream `ConfirmationPrompt` React key - // warning. It is logged at SEVERE level by Chrome but is a known - // upstream issue (StageHeaderComponent → ConfirmationPrompt) and - // does not affect functionality. === - const severeLogs = logs - .filter((l) => l.level.name === 'SEVERE') - .filter((l) => !/Each child in a list should have a unique .{1,3}key.{1,3} prop/.test(l.message)); - expect(severeLogs).toEqual([]); + // === Smalruby: 既知の無視できる SEVERE ログを除外する (isIgnorableSevereLog 参照) === + expect(unexpectedSevereLogs(logs)).toEqual([]); }); test('can close tutorial card', async () => { diff --git a/packages/scratch-gui/test/unit/helpers/ignorable-severe-logs.test.js b/packages/scratch-gui/test/unit/helpers/ignorable-severe-logs.test.js new file mode 100644 index 00000000000..c713e5e0861 --- /dev/null +++ b/packages/scratch-gui/test/unit/helpers/ignorable-severe-logs.test.js @@ -0,0 +1,54 @@ +import { isIgnorableSevereLog, unexpectedSevereLogs } from '../../helpers/ignorable-severe-logs'; + +const severe = (message) => ({ level: { name: 'SEVERE' }, message, timestamp: 0, type: '' }); + +// CI (Chrome stable) が実際に出したメッセージをそのまま貼っている。自前ホストした Monaco の +// editor worker を `file://` から読めないときの 2 件 (#1171)。 +const MONACO_WORKER_LOGS = [ + 'file:///home/runner/work/smalruby3-editor/smalruby3-editor/packages/scratch-gui/build/static/monaco/vs/editor.api-CalNCsUg.js 0:2337 Uncaught [object ErrorEvent]', + "blob:null/99a1285a-897a-4d63-9599-b2f22f7ea68d 0 Uncaught NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope': The script at 'file:///home/runner/work/smalruby3-editor/smalruby3-editor/packages/scratch-gui/build/static/monaco/vs/assets/editor.worker-Be8ye1pW.js' failed to load.", +]; + +describe('isIgnorableSevereLog', () => { + test.each(MONACO_WORKER_LOGS)('ignores the monaco editor worker failure under file:// (%#)', (message) => { + expect(isIgnorableSevereLog(message)).toBe(true); + }); + + test('ignores the upstream ConfirmationPrompt React key warning', () => { + const message = + 'Warning: Each child in a list should have a unique "key" prop. Check the render method of `ConfirmationPrompt`.'; + expect(isIgnorableSevereLog(message)).toBe(true); + }); + + test('does not ignore a genuine monaco loader 404', () => { + const message = + 'file:///app/packages/scratch-gui/build/static/monaco/vs/loader.js - Failed to load resource: net::ERR_FILE_NOT_FOUND'; + // loader.js の取得失敗は Monaco が全く読めない状態なので見逃してはいけない。 + expect(isIgnorableSevereLog(message)).toBe(false); + }); + + test('does not ignore a CDN fallback request', () => { + const message = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs/loader.js 0 Uncaught Error'; + expect(isIgnorableSevereLog(message)).toBe(false); + }); + + test('does not ignore unrelated application errors', () => { + expect(isIgnorableSevereLog('Uncaught TypeError: Cannot read properties of undefined')).toBe(false); + }); +}); + +describe('unexpectedSevereLogs', () => { + test('keeps only non-ignorable SEVERE entries', () => { + const logs = [ + { level: { name: 'WARNING' }, message: 'Uncaught TypeError: warning level is dropped' }, + severe(MONACO_WORKER_LOGS[0]), + severe(MONACO_WORKER_LOGS[1]), + severe('Uncaught TypeError: real failure'), + ]; + expect(unexpectedSevereLogs(logs)).toEqual([severe('Uncaught TypeError: real failure')]); + }); + + test('returns an empty array when everything is ignorable', () => { + expect(unexpectedSevereLogs(MONACO_WORKER_LOGS.map(severe))).toEqual([]); + }); +});