/** * 「コマンドの外部通信あり」の印を、幅を振って実測する(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 { 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 { 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> { 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 { await page.setViewportSize({ width, height: 720 }); await page.evaluate( () => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(() => resolve())); }), ); } /** 上の帯の札を、翻訳が長くなったのと同じだけ横に伸ばす。0 で元に戻す。 */ async function growTabLabels(page: Page, extraPx: number): Promise { 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>(); 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); }); });