Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .devcontainer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,11 @@ NaCl の隔離ガイドラインでは「非公開認証情報が到達する隔
| AWS | `ip-ranges.json` のうち **デプロイリージョン + us-east-1 + GLOBAL**、加えて SSO 系 (`oidc/portal.sso/sso.<region>.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 も追従)。
Expand Down
3 changes: 2 additions & 1 deletion .devcontainer/init-firewall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/maintenance/smalruby-markers-gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
36 changes: 36 additions & 0 deletions docs/ruby-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,42 @@ 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` を**対で**変更する
- `@monaco-editor/loader` の既定 `paths.vs` は CDN のままなので、`monaco-i18n-helper.js`
の副作用インポートより先に `<Editor>` がレンダーされると静かに CDN へ戻る。
`@monaco-editor/react` を使うモジュールを増やすときは、`<Editor>` のレンダー前に
`monaco-i18n-helper.js` を import すること

###### `file://` で開いたときの editor worker(統合テストの注意点)

Monaco は editor worker を **blob + `importScripts(<worker の URL>)`** で生成する。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

Expand Down
1 change: 1 addition & 0 deletions packages/scratch-gui/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 46 additions & 3 deletions packages/scratch-gui/src/lib/monaco-i18n-helper.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,53 @@
/* 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 に自動的に揃う。
//
// ⚠️ `@monaco-editor/loader` の既定 `paths.vs` は CDN (cdn.jsdelivr.net) のままで、
// この副作用インポートより先に `loader.init()`(= `<Editor>` の初回レンダー)が走ると
// 静かに CDN へフォールバックする。`@monaco-editor/react` を使うモジュールを新しく
// 追加するときは、`<Editor>` をレンダーする前にこのモジュールを import すること。
loader.config({
paths: {
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs',
vs: resolveMonacoVsPath(webpackPublicPath()),
},
});

Expand All @@ -23,4 +66,4 @@ const loadMonacoLocale = async (locale) => {
}
};

export { loadMonacoLocale };
export { loadMonacoLocale, resolveMonacoVsPath };
37 changes: 37 additions & 0 deletions packages/scratch-gui/test/helpers/ignorable-severe-logs.js
Original file line number Diff line number Diff line change
@@ -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(<worker の URL>)`」で生成する。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<object>} 無視できない SEVERE ログ。
*/
const unexpectedSevereLogs = (logs) =>
logs.filter((l) => l.level.name === 'SEVERE').filter((l) => !isIgnorableSevereLog(l.message));

export { isIgnorableSevereLog, unexpectedSevereLogs };
22 changes: 6 additions & 16 deletions packages/scratch-gui/test/integration/smalruby-tutorials.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down Expand Up @@ -41,29 +43,17 @@ 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 () => {
await loadUri(`${uriWithTutorial('getStarted')}`);
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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading
Loading