253 lines
13 KiB
TypeScript
253 lines
13 KiB
TypeScript
/**
|
||
* 「コマンドの外部通信あり」の印を、幅を振って実測する(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);
|
||
});
|
||
});
|