Files
maestro/ui/e2e/space-header-badge.spec.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

253 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 「コマンドの外部通信あり」の印を、幅を振って実測する(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);
});
});