sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
+252
View File
@@ -0,0 +1,252 @@
/**
* 「コマンドの外部通信あり」の印を、幅を振って実測する(PR #1374 レビュー P2-B)。
*
* この印は**絶対に切り落とさない**——設定を開かない閲覧者に、いま外へ出られる状態か
* どうかを見せるためだけに出しているので、「🌐 コマンドの」で千切れたら意味が無い。
*
* それを守っているのが余白の算数だけだった時期がある。狭い画面で切れないのは
* **たまたま余白が足りていたから**で、名札が数十ピクセル伸びるだけで元の不具合が
* 戻った(直す前のコードを日本語・430px で測った値: +50px で 1.000、+60px で 0.935。
* 切れはじめるのはこの 2 点のあいだ。中間の 1 点は測っていないので書かない)。
* いまは組み方で守っている(`SpaceDetail` のタブ群=横に流れる箱、印=`shrink-0`)が、
* jsdom は幅を測れないので、**そこが効いていることは実機でしか確かめられない。**
*
* ここで見るのは 3 つ。
* 1. どの幅でも、上の帯の札が押し込まれない(この PR の存在理由)
* 2. どの幅でも、印は 1 ピクセルも切れない(見えている割合が 1.00)
* 3. 上の帯の札が伸びても切れない(翻訳が長くなる・札が増える、を先取りする)
*
* 1 が要る理由: この PR は「タブの位置が動かないこと」のために書かれている。その位置を
* 守っていた `shrink-0` を、印を守るために外してタブを縮む側へ回した。**守る相手を
* 取り替えたのに網が印しか見ていない**状態だったのを埋める(レビュー P2-3)。
*
* ついでに、選んでいるファイルの札の下地が**本当に塗られている**ことも見る。ここは
* 一度、ビルド後に 1 行も出ないクラス(不透明度つきの CSS 変数)で塗ったつもりに
* なっていた(レビュー P2-A)。透明かどうかは実機の計算値でしか分からない。
*/
import { test, expect, type Page } from '@playwright/test';
/** 印を必ず出す。実際の値は隔離環境の有無で変わるので、応答を差し替えて固定する。 */
async function forceNetworkBadge(page: Page): Promise<void> {
await page.route('**/api/local/spaces/*/tool-policy', async (route) => {
if (route.request().method() !== 'GET') return route.fallback();
const res = await route.fetch();
const body = await res.json();
await route.fulfill({
response: res,
json: {
...body,
sandboxNetwork: {
workspace: true,
global: true,
effective: true,
source: 'workspace',
sandboxMode: 'always',
bwrapAvailable: true,
bashUnrestricted: false,
},
},
});
});
}
/** 案件ワークスペースを 1 つ作って開く。 */
async function createAndOpenCaseSpace(page: Page): Promise<void> {
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
await page.getByTestId('create-space-btn').click();
const titleInput = page.getByTestId('space-title-input');
await expect(titleInput).toBeVisible();
const title = `帯の印-${Date.now()}`;
await titleInput.fill(title);
await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0);
const row = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: title });
await expect(row).toBeVisible();
await row.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
}
/**
* 印のうち、帯の中に見えている割合。1 = 全部見えている。
* 帯(ヘッダー行)の外へはみ出した分は、画面では切り取られて読めない。
*/
async function badgeVisibleRatio(page: Page): Promise<{ ratio: number; width: number }> {
return page.evaluate(() => {
const badge = document.querySelector('[data-testid="sandbox-network-badge"]');
if (!badge) throw new Error('印が出ていない');
const row = document.querySelector('[data-testid="space-tab-group"]')!.parentElement!;
const rr = row.getBoundingClientRect();
const br = badge.getBoundingClientRect();
const seen = Math.max(0, Math.min(br.right, rr.right) - Math.max(br.left, rr.left));
return { ratio: Number((seen / br.width).toFixed(3)), width: Number(br.width.toFixed(1)) };
});
}
/**
* 上の帯の札 3 枚の位置と、札を入れた箱がはみ出しているか。
*
* **画面の左端からの座標では見ない。**幅で切り替わる場所が 2 つあって、そこでは
* 座標が動くのが正しい(実測):
* - 640px(`sm`)でファイルの札に文字が出る → 札の幅 34 → 96、その右の
* 「その他」が 62px 右へ動く
* - 768px でチャット一覧の列が出る → 帯ごと右へ寄る(チャットの札 16 → 272)
* どちらも狭くなって削られたのではなく、**別の見た目に切り替わった**だけ。
*
* この PR が守るのは「札が押し込まれて動かないこと」なので、タブ群の左端からの
* 差で見る。差が変わる=札どうしが詰められたか、箱がはみ出して切り取られたか。
* `clipped` は箱がはみ出している状態そのもの(切り取られた分は横になぞらないと出ない)。
*/
async function tabOffsets(page: Page): Promise<Record<string, number | boolean>> {
return page.evaluate(() => {
const find = (testid: string) => {
const el = document.querySelector(`[data-testid="${testid}"]`);
if (!el) throw new Error(`${testid} が出ていない`);
return el;
};
const group = find('space-tab-group') as HTMLElement;
const box = group.firstElementChild as HTMLElement;
const left = (testid: string) => find(testid).getBoundingClientRect().left;
const chat = left('space-tab-chat');
const round = (n: number) => Number(n.toFixed(1));
return {
chat: round(chat - group.getBoundingClientRect().left),
files: round(left('space-tab-files') - chat),
more: round(left('space-tab-more') - chat),
clipped: box.scrollWidth > box.clientWidth,
};
});
}
/** 見た目が切り替わる境目。ここを跨ぐと札の並びが変わるのが正しい。 */
const FILES_LABEL_BREAKPOINT = 640;
/**
* 幅を変えたら、測る前に 1 フレーム置く。変えた直後に測ると、md の境目(768px)を
* 跨いだ瞬間の**崩れた 1 フレーム**を掴むことがある(レビュー担当の掃引で 4 回に 1 回、
* タブ群の幅 0・印の割合 0.535 が観測された。**こちらの実行では未再現**——待つように
* 直す前後で 768px を跨いだ掃引を何度か回したが、いずれも綺麗だった)。
* 利用者に見えるのは窓を跨いでリサイズした 1 フレームだけなので不具合ではないが、
* 待たずに測る網は、いつか CI で理由の分からない赤を出す(レビュー P3-6)。
*/
async function resizeAndSettle(page: Page, width: number): Promise<void> {
await page.setViewportSize({ width, height: 720 });
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
/** 上の帯の札を、翻訳が長くなったのと同じだけ横に伸ばす。0 で元に戻す。 */
async function growTabLabels(page: Page, extraPx: number): Promise<void> {
await page.evaluate((px) => {
document.getElementById('e2e-tab-growth')?.remove();
if (px <= 0) return;
const style = document.createElement('style');
style.id = 'e2e-tab-growth';
// 札 3 つに均等に配る(チャット・ファイル・その他)。
style.textContent =
`[data-testid="space-tab-group"] button { margin-inline-end: ${px / 3}px !important; }`;
document.head.appendChild(style);
}, extraPx);
}
const WIDTHS = [320, 375, 429, 430, 480, 640, 768, 900, 1024, 1440];
test.describe('ワークスペースの帯: 外部通信の印は切れない', () => {
test('どの幅でも、印は 1 ピクセルも切れない', async ({ page }) => {
await forceNetworkBadge(page);
await createAndOpenCaseSpace(page);
await expect(page.getByTestId('sandbox-network-badge')).toBeVisible();
const seen = new Map<string, Record<string, number | boolean>>();
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
const { ratio, width: badgeWidth } = await badgeVisibleRatio(page);
expect(badgeWidth, `${width}px で印の幅が 0`).toBeGreaterThan(0);
expect(ratio, `${width}px で印が切れた`).toBe(1);
// 札の並びは、境目を跨がないかぎりどの幅でも同じでなければならない。
const key = width < FILES_LABEL_BREAKPOINT ? '狭' : '広';
const now = await tabOffsets(page);
expect(now.clipped, `${width}px で札の箱がはみ出した`).toBe(false);
const first = seen.get(key);
if (first) expect(now, `${width}px で上の帯の札が押し込まれた`).toEqual(first);
else seen.set(key, now);
}
});
test('上の帯の札が伸びても、印は切れない(翻訳が長くなる分の先取り)', async ({ page }) => {
await forceNetworkBadge(page);
await createAndOpenCaseSpace(page);
await expect(page.getByTestId('sandbox-network-badge')).toBeVisible();
// 段は直す前のコードの実測に合わせて選ぶ。+50px までは直す前でも 1.000 で、
// 430px で初めて欠けるのは +60px(0.935)。ここを踏まないと網の目が粗すぎて、
// 組み方を書き戻しても +100px まで赤くならない。
for (const extra of [25, 50, 60, 100]) {
await growTabLabels(page, extra);
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
const { ratio } = await badgeVisibleRatio(page);
expect(ratio, `札を +${extra}px 伸ばした ${width}px で印が切れた`).toBe(1);
}
}
await growTabLabels(page, 0);
});
test('印の文字は、切れるのではなく丸ごと消える', async ({ page }) => {
await forceNetworkBadge(page);
await createAndOpenCaseSpace(page);
const badge = page.getByTestId('sandbox-network-badge');
await expect(badge).toBeVisible();
// 430px 以上では文が出る。それより狭いと絵だけになるが、読み上げ名は変わらない。
await resizeAndSettle(page, 430);
const wide = await badge.evaluate((el) => el.getBoundingClientRect().width);
const ariaWide = await badge.getAttribute('aria-label');
await resizeAndSettle(page, 429);
const narrow = await badge.evaluate((el) => el.getBoundingClientRect().width);
expect(narrow).toBeLessThan(wide);
expect(await badge.getAttribute('aria-label')).toBe(ariaWide);
expect(await badge.getAttribute('title')).toBe(ariaWide);
});
});
test.describe('ワークスペースの帯: 選んでいるファイルの札', () => {
test('選んでいるあいだ、下地が本当に塗られている', async ({ page }) => {
await createAndOpenCaseSpace(page);
const files = page.getByTestId('space-tab-files');
await files.click();
await expect(files).toHaveAttribute('aria-current', 'true');
// 札には `transition-colors`(150ms)が掛かっている。選んでいない状態の下地は
// 指定なし=透明なので、印が付いた直後に読むと**移り始めの透明**が返る。それは
// このテストが探している「1 行も CSS が出ていない」状態と計算値で区別できない
// (実測: CI で同じ中身が緑・緑・赤と揺れた。#1407)。落ち着くまで読み直す。
//
// 透明が返るのは一瞬ではない。移り変わりは最初の描画が来るまで始まらないので、
// 手元の実測でおよそ 25 ミリ秒続いた。CI が詰まっていればもっと伸びる。
//
// 移り変わりを消して直さないこと ── 網の都合で利用者に見える動きを変えることになる。
//
// 罫線はここで見ない。選んでいないときの罫線は `border-hairline`(#e4e4e7)で、
// **透明を一度も通らない**。上の理屈が当てはまらないので、待って読み直す意味が無い。
// 「塗られていること」を罫線で確かめる断言も、選んでいない側から不透明なので、
// 塗り分けの有無を切り分けられない。落ちない断言は守れているように見えるだけ。
const TRANSPARENT = /rgba\(0,\s*0,\s*0,\s*0\)|transparent/;
const bgOf = () => files.evaluate((el) => getComputedStyle(el).backgroundColor);
await expect.poll(bgOf, { message: '下地が塗られない' }).not.toMatch(TRANSPARENT);
});
});