1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-11 20:14:42 +02:00

Compare commits

..

45 Commits

Author SHA1 Message Date
syuilo
25736436c2 refactor 2026-07-09 13:09:45 +09:00
syuilo
262cd79e63 Update chrome.mts 2026-07-09 12:28:52 +09:00
syuilo
a7118a7cb6 fix 2026-07-09 12:18:08 +09:00
syuilo
ccd16fa5b3 Update measure-frontend-browser-comparison.mts 2026-07-09 12:02:34 +09:00
syuilo
a5165510af clean up 2026-07-09 11:55:48 +09:00
syuilo
cfadbc7abf Update measure-frontend-browser-comparison.mts 2026-07-09 11:54:49 +09:00
syuilo
0596d4d09e wip 2026-07-09 11:40:23 +09:00
syuilo
2de9891478 refactor 2026-07-09 11:21:29 +09:00
syuilo
c6b7d44812 Update chrome.mts 2026-07-09 11:18:22 +09:00
syuilo
8df9cd5b9a playwright 2026-07-09 10:02:56 +09:00
syuilo
e42f7436ab Merge branch 'develop' into frontend-browser-metrics-workflow-2 2026-07-09 09:28:44 +09:00
syuilo
efa09e3ee1 Update frontend-browser-report.mts 2026-07-08 14:07:59 +09:00
syuilo
71e73a7faf Update frontend-browser-report.mts 2026-07-08 14:05:13 +09:00
syuilo
0dfbd0b085 Update frontend-browser-report.mts 2026-07-08 13:53:52 +09:00
syuilo
560c0598c9 Update frontend-browser-metrics-report.yml 2026-07-08 13:20:26 +09:00
syuilo
78fd36ff0e Merge branch 'develop' into frontend-browser-metrics-workflow-2 2026-07-08 13:15:48 +09:00
syuilo
31487c013b Revert "wip"
This reverts commit ce7657081b.
2026-07-08 13:15:37 +09:00
syuilo
ce7657081b wip 2026-07-06 14:24:49 +09:00
syuilo
80b5ec66a4 wip 2026-07-03 20:23:44 +09:00
syuilo
5d0dd40434 wip 2026-07-03 19:56:06 +09:00
syuilo
8f2759eb47 Update frontend-browser-report.mts 2026-07-03 19:35:46 +09:00
syuilo
9858fd1417 Merge branch 'develop' into frontend-browser-metrics-workflow-2 2026-07-03 19:28:17 +09:00
syuilo
b487f91087 wip 2026-07-03 11:21:28 +09:00
syuilo
cd953e918c refactor 2026-07-03 11:11:08 +09:00
syuilo
0c7ee11a2b Merge branch 'develop' into frontend-browser-metrics-workflow-2 2026-07-03 11:02:04 +09:00
syuilo
551162b70a Merge branch 'develop' into frontend-browser-metrics-workflow-2 2026-06-28 18:03:10 +09:00
syuilo
8dc5962ce9 Update frontend-browser-report.mts 2026-06-28 18:02:09 +09:00
syuilo
8bc8ebc333 Update frontend-browser-metrics-report.yml 2026-06-28 17:56:58 +09:00
syuilo
7dcf7658b2 fix 2026-06-28 17:48:30 +09:00
syuilo
4a41b1461e wip 2026-06-28 17:24:52 +09:00
syuilo
5f2022341a refactor 2026-06-28 10:46:47 +09:00
syuilo
5f10968491 Update chrome.mts 2026-06-28 10:25:29 +09:00
syuilo
5856784288 fix 2026-06-28 10:16:57 +09:00
syuilo
c5951175ef refactor 2026-06-28 10:06:04 +09:00
syuilo
48f676511c Update frontend-browser-report.mts 2026-06-27 20:44:11 +09:00
syuilo
ce10eceda1 Update frontend-browser-report.mts 2026-06-27 20:27:11 +09:00
syuilo
982d4034bd Merge branch 'develop' into frontend-browser-metrics-workflow-2 2026-06-27 20:21:30 +09:00
syuilo
67f25a7da7 Update frontend-browser-report.mts 2026-06-27 20:18:31 +09:00
syuilo
364ccd07ff Update frontend-browser-report.mts 2026-06-27 20:03:54 +09:00
syuilo
0deac44320 Update frontend-browser-report.mts 2026-06-27 20:00:53 +09:00
syuilo
812b5fbf0b Update frontend-browser-report.mts 2026-06-27 19:57:48 +09:00
syuilo
7247535d65 Merge branch 'develop' into frontend-browser-metrics-workflow-2 2026-06-27 19:46:35 +09:00
syuilo
790c84dcca Update frontend-browser-report.mts 2026-06-27 18:43:03 +09:00
syuilo
21473857d9 Update measure-frontend-browser-comparison.mts 2026-06-27 17:34:44 +09:00
syuilo
96a454ee3a wip 2026-06-27 17:17:43 +09:00
38 changed files with 3685 additions and 2891 deletions

523
.github/scripts/chrome.mts vendored Normal file
View File

@@ -0,0 +1,523 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createRequire } from 'node:module';
import { writeFile } from 'node:fs/promises';
import type { Browser, BrowserContext, CDPSession, Page } from 'playwright';
import type { HeapSnapshotData } from './heap-snapshot-util.mts';
export type NetworkRequest = {
requestId: string;
url: string;
method: string;
resourceType: string;
startedAt: number;
documentUrl?: string;
requestHeaders?: Record<string, string>;
requestBody?: string;
hasRequestBody: boolean;
status?: number;
statusText?: string;
mimeType?: string;
responseHeaders?: Record<string, string>;
protocol?: string;
remoteIPAddress?: string;
remotePort?: number;
encodedDataLength: number;
decodedBodyLength: number;
fromDiskCache: boolean;
fromServiceWorker: boolean;
finished: boolean;
failed: boolean;
errorText?: string;
};
export type WebSocketConnection = {
requestId: string;
url: string;
createdAt: number;
handshakeRequestHeaders?: Record<string, string>;
handshakeResponseStatus?: number;
handshakeResponseStatusText?: string;
handshakeResponseHeaders?: Record<string, string>;
closedAt?: number;
sentFrameCount: number;
receivedFrameCount: number;
sentBytes: number;
receivedBytes: number;
errorCount: number;
};
export type NetworkSummary = {
requestCount: number;
webSocketConnectionCount: number;
webSocketSentBytes: number;
webSocketReceivedBytes: number;
finishedRequestCount: number;
failedRequestCount: number;
cachedRequestCount: number;
serviceWorkerRequestCount: number;
totalEncodedBytes: number;
totalDecodedBodyBytes: number;
sameOriginEncodedBytes: number;
thirdPartyEncodedBytes: number;
byResourceType: Record<string, {
requests: number;
encodedBytes: number;
decodedBodyBytes: number;
}>;
largestRequests: {
url: string;
method: string;
resourceType: string;
status?: number;
encodedBytes: number;
decodedBodyBytes: number;
}[];
failedRequests: {
url: string;
method: string;
resourceType: string;
errorText?: string;
status?: number;
}[];
};
export type TabMemory = {
totalBytes: number;
};
export type BrowserMeasurement = {
label: string;
timestamp: string;
url: string;
scenario: string;
durationMs: number;
network: NetworkSummary;
performance: {
cdpMetrics: Record<string, number>;
runtimeHeap?: {
usedSize: number;
totalSize: number;
};
tabMemory: TabMemory;
webVitals: {
firstPaintMs?: number;
firstContentfulPaintMs?: number;
domContentLoadedEventEndMs?: number;
loadEventEndMs?: number;
longTaskCount: number;
longTaskDurationMs: number;
maxLongTaskDurationMs: number;
resourceEntryCount: number;
domElements: number;
};
};
heapSnapshot: HeapSnapshotData;
};
type PlaywrightModule = typeof import('playwright');
const requireFromFrontend = createRequire(new URL('../../packages/frontend/package.json', import.meta.url));
function loadPlaywright(): PlaywrightModule {
return requireFromFrontend('playwright') as PlaywrightModule;
}
function normalizeHeaders(headers: Record<string, unknown> | undefined) {
if (headers == null) return undefined;
const normalized = {} as Record<string, string>;
for (const [key, value] of Object.entries(headers)) {
normalized[key] = String(value);
}
return normalized;
}
function webSocketFramePayloadBytes(frame: { opcode?: number; payloadData?: string } | undefined) {
if (frame?.payloadData == null) return 0;
if (frame.opcode === 1) return Buffer.byteLength(frame.payloadData, 'utf8');
return Buffer.byteLength(frame.payloadData, 'base64');
}
type PlaywrightBrowserOptions = {
scenarioTimeoutMs: number;
baseUrl: string;
};
export class HeadlessChromeController {
public networkRequests: NetworkRequest[] = [];
public webSocketConnections: WebSocketConnection[] = [];
private readonly browser: Browser;
private readonly context: BrowserContext;
public readonly page: Page;
private readonly cdp: CDPSession;
private pendingNetworkDetailReads: Promise<void>[] = [];
private constructor(
browser: Browser,
context: BrowserContext,
page: Page,
cdp: CDPSession,
options: PlaywrightBrowserOptions,
) {
this.browser = browser;
this.context = context;
this.page = page;
this.cdp = cdp;
this.page.setDefaultTimeout(options.scenarioTimeoutMs);
this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs);
}
static async create(label: string, options: PlaywrightBrowserOptions): Promise<HeadlessChromeController> {
process.stderr.write(`[${label}] Launching Playwright Chromium\n`);
const { chromium } = loadPlaywright();
const browser = await chromium.launch({
channel: 'chromium',
headless: true,
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-background-networking',
'--disable-default-apps',
'--disable-extensions',
'--disable-sync',
'--metrics-recording-only',
'--no-first-run',
'--no-default-browser-check',
'--no-sandbox',
],
});
try {
const context = await browser.newContext({
baseURL: options.baseUrl,
locale: 'en-US',
});
await context.addInitScript(() => {
// @ts-expect-error Test-only runtime hint consumed by Misskey frontend code.
window.isPlaywright = true;
});
const page = await context.newPage();
const cdp = await context.newCDPSession(page);
return new HeadlessChromeController(browser, context, page, cdp, options);
} catch (error) {
await browser.close().catch(() => undefined);
throw error;
}
}
static async with<T>(label: string, options: PlaywrightBrowserOptions, callback: (browser: HeadlessChromeController) => T | Promise<T>): Promise<T> {
const browser = await HeadlessChromeController.create(label, options);
try {
return await callback(browser);
} finally {
await browser.close();
}
}
public async enableNetworkTracking() {
const requests = new Map<string, NetworkRequest>();
const webSockets = new Map<string, WebSocketConnection>();
const readRequestBody = (row: NetworkRequest) => {
if (!row.hasRequestBody || row.requestBody != null) return;
const pending = this.cdp.send<{ postData: string }>('Network.getRequestPostData', {
requestId: row.requestId,
}).then(result => {
row.requestBody = result.postData;
}).catch(() => {
// Some requests expose hasPostData but no longer have retrievable body data.
});
this.pendingNetworkDetailReads.push(pending);
};
this.cdp.on('Network.requestWillBeSent', params => {
if (params.request?.url == null) return;
const row: NetworkRequest = {
requestId: params.requestId,
url: params.request.url,
method: params.request.method ?? 'GET',
resourceType: params.type ?? 'Other',
startedAt: params.timestamp ?? 0,
documentUrl: params.documentURL,
requestHeaders: normalizeHeaders(params.request.headers),
requestBody: typeof params.request.postData === 'string' ? params.request.postData : undefined,
hasRequestBody: params.request.hasPostData === true || typeof params.request.postData === 'string',
encodedDataLength: 0,
decodedBodyLength: 0,
fromDiskCache: false,
fromServiceWorker: false,
finished: false,
failed: false,
};
requests.set(params.requestId, row);
this.networkRequests.push(row);
});
this.cdp.on('Network.webSocketCreated', params => {
if (params.requestId == null || params.url == null) return;
const row: WebSocketConnection = {
requestId: params.requestId,
url: params.url,
createdAt: params.timestamp ?? 0,
sentFrameCount: 0,
receivedFrameCount: 0,
sentBytes: 0,
receivedBytes: 0,
errorCount: 0,
};
webSockets.set(params.requestId, row);
this.webSocketConnections.push(row);
});
this.cdp.on('Network.webSocketWillSendHandshakeRequest', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.handshakeRequestHeaders = normalizeHeaders(params.request?.headers);
});
this.cdp.on('Network.webSocketHandshakeResponseReceived', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.handshakeResponseStatus = params.response?.status;
row.handshakeResponseStatusText = params.response?.statusText;
row.handshakeResponseHeaders = normalizeHeaders(params.response?.headers);
});
this.cdp.on('Network.webSocketFrameSent', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.sentFrameCount += 1;
row.sentBytes += webSocketFramePayloadBytes(params.response);
});
this.cdp.on('Network.webSocketFrameReceived', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.receivedFrameCount += 1;
row.receivedBytes += webSocketFramePayloadBytes(params.response);
});
this.cdp.on('Network.webSocketFrameError', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.errorCount += 1;
});
this.cdp.on('Network.webSocketClosed', params => {
const row = webSockets.get(params.requestId);
if (row == null) return;
row.closedAt = params.timestamp ?? 0;
});
this.cdp.on('Network.responseReceived', params => {
const row = requests.get(params.requestId);
if (row == null) return;
row.status = params.response?.status;
row.statusText = params.response?.statusText;
row.mimeType = params.response?.mimeType;
row.responseHeaders = normalizeHeaders(params.response?.headers);
row.protocol = params.response?.protocol;
row.remoteIPAddress = params.response?.remoteIPAddress;
row.remotePort = params.response?.remotePort;
row.requestHeaders ??= normalizeHeaders(params.response?.requestHeaders);
row.fromDiskCache = params.response?.fromDiskCache === true;
row.fromServiceWorker = params.response?.fromServiceWorker === true;
});
this.cdp.on('Network.dataReceived', params => {
const row = requests.get(params.requestId);
if (row == null) return;
row.decodedBodyLength += params.dataLength ?? 0;
row.encodedDataLength += params.encodedDataLength ?? 0;
});
this.cdp.on('Network.loadingFinished', params => {
const row = requests.get(params.requestId);
if (row == null) return;
row.finished = true;
row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0);
readRequestBody(row);
});
this.cdp.on('Network.loadingFailed', params => {
const row = requests.get(params.requestId);
if (row == null) return;
row.failed = true;
row.finished = true;
row.errorText = params.errorText;
readRequestBody(row);
});
await this.cdp.send('Network.enable');
await this.cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
await this.cdp.send('Network.setBypassServiceWorker', { bypass: true });
await this.cdp.send('Page.enable');
await this.cdp.send('Runtime.enable');
await this.cdp.send('Performance.enable');
}
public async waitForNetworkDetails() {
let settledCount = 0;
while (settledCount < this.pendingNetworkDetailReads.length) {
const pending = this.pendingNetworkDetailReads.slice(settledCount);
settledCount = this.pendingNetworkDetailReads.length;
await Promise.allSettled(pending);
}
}
public async evaluate<T>(expression: string, timeoutMs = 30_000): Promise<T> {
return await Promise.race([
this.page.evaluate(expression),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`Playwright evaluate timed out after ${timeoutMs}ms`)), timeoutMs).unref()),
]) as T;
}
public async collectPerformance(): Promise<BrowserMeasurement['performance']> {
const cdpMetricsResult = await this.cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics');
const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value]));
const runtimeHeap = await this.cdp.send<{ usedSize: number; totalSize: number }>('Runtime.getHeapUsage').catch(() => undefined);
const tabMemory = await this.collectTabMemory();
const webVitals = await this.evaluate<BrowserMeasurement['performance']['webVitals']>(`(() => {
const navigation = performance.getEntriesByType('navigation')[0];
const paintEntries = Object.fromEntries(performance.getEntriesByType('paint').map(entry => [entry.name, entry.startTime]));
const longTasks = performance.getEntriesByType('longtask');
const resourceEntries = performance.getEntriesByType('resource');
return {
firstPaintMs: paintEntries['first-paint'],
firstContentfulPaintMs: paintEntries['first-contentful-paint'],
domContentLoadedEventEndMs: navigation?.domContentLoadedEventEnd,
loadEventEndMs: navigation?.loadEventEnd,
longTaskCount: longTasks.length,
longTaskDurationMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
maxLongTaskDurationMs: longTasks.reduce((max, entry) => Math.max(max, entry.duration), 0),
resourceEntryCount: resourceEntries.length,
domElements: document.getElementsByTagName('*').length,
};
})()`);
return {
cdpMetrics,
runtimeHeap,
tabMemory,
webVitals,
};
}
public async collectTabMemory(): Promise<TabMemory> {
const userAgentSpecificMemory = await this.evaluate<{ bytes?: number }>(`(async () => {
const measureMemory = performance.measureUserAgentSpecificMemory;
if (typeof measureMemory !== 'function') return {};
const result = await measureMemory.call(performance);
return { bytes: result.bytes };
})()`, 60_000);
const userAgentSpecificBytes = userAgentSpecificMemory?.bytes;
if (!Number.isFinite(userAgentSpecificBytes)) {
throw new Error('performance.measureUserAgentSpecificMemory() did not return finite bytes');
}
return {
totalBytes: userAgentSpecificBytes as number,
};
}
public async takeHeapSnapshot(savePath?: string) {
const chunks: string[] = [];
this.cdp.on('HeapProfiler.addHeapSnapshotChunk', params => {
chunks.push(params.chunk);
});
await this.cdp.send('HeapProfiler.enable');
await this.cdp.send('HeapProfiler.collectGarbage');
await this.cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
const content = chunks.join('');
if (savePath != null) {
await writeFile(savePath, content);
}
return JSON.parse(content);
}
public async close() {
await this.cdp.detach().catch(() => undefined);
await this.context.close().catch(() => undefined);
await this.browser.close().catch(() => undefined);
}
}
function isMeasurableRequest(row: NetworkRequest) {
return !row.url.startsWith('data:') && !row.url.startsWith('blob:') && !row.url.startsWith('devtools:');
}
export function summarizeNetwork(requestRows: NetworkRequest[], baseUrl: string, webSocketRows?: WebSocketConnection[]): NetworkSummary {
const origin = new URL(baseUrl).origin;
const rows = requestRows.filter(isMeasurableRequest);
const byResourceType = {} as NetworkSummary['byResourceType'];
for (const row of rows) {
const summary = byResourceType[row.resourceType] ?? {
requests: 0,
encodedBytes: 0,
decodedBodyBytes: 0,
};
summary.requests += 1;
summary.encodedBytes += row.encodedDataLength;
summary.decodedBodyBytes += row.decodedBodyLength;
byResourceType[row.resourceType] = summary;
}
function isSameOrigin(url: string) {
try {
return new URL(url).origin === origin;
} catch {
return false;
}
}
return {
requestCount: rows.length,
webSocketConnectionCount: webSocketRows == null
? rows.filter(row => row.resourceType === 'WebSocket').length
: webSocketRows.length,
webSocketSentBytes: webSocketRows?.reduce((sum, row) => sum + row.sentBytes, 0) ?? 0,
webSocketReceivedBytes: webSocketRows?.reduce((sum, row) => sum + row.receivedBytes, 0) ?? 0,
finishedRequestCount: rows.filter(row => row.finished).length,
failedRequestCount: rows.filter(row => row.failed).length,
cachedRequestCount: rows.filter(row => row.fromDiskCache).length,
serviceWorkerRequestCount: rows.filter(row => row.fromServiceWorker).length,
totalEncodedBytes: rows.reduce((sum, row) => sum + row.encodedDataLength, 0),
totalDecodedBodyBytes: rows.reduce((sum, row) => sum + row.decodedBodyLength, 0),
sameOriginEncodedBytes: rows
.filter(row => isSameOrigin(row.url))
.reduce((sum, row) => sum + row.encodedDataLength, 0),
thirdPartyEncodedBytes: rows
.filter(row => !isSameOrigin(row.url))
.reduce((sum, row) => sum + row.encodedDataLength, 0),
byResourceType,
largestRequests: rows
.toSorted((a, b) => b.encodedDataLength - a.encodedDataLength)
.slice(0, 15)
.map(row => ({
url: row.url,
method: row.method,
resourceType: row.resourceType,
status: row.status,
encodedBytes: row.encodedDataLength,
decodedBodyBytes: row.decodedBodyLength,
})),
failedRequests: rows
.filter(row => row.failed)
.map(row => ({
url: row.url,
method: row.method,
resourceType: row.resourceType,
errorText: row.errorText,
status: row.status,
})),
};
}

View File

@@ -0,0 +1,448 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import * as util from './utility.mts';
import type { BrowserMeasurementSample, BrowserMetricsReport } from './frontend-browser-report.mts';
import type { NetworkRequest } from './chrome.mts';
type DiffDirection = 'added' | 'removed';
type RequestDiff = {
direction: DiffDirection;
round: number;
baseCount: number;
headCount: number;
request: NetworkRequest;
};
function escapeHtml(value: unknown) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function escapeAttribute(value: unknown) {
return escapeHtml(value);
}
function isHttpRequest(request: NetworkRequest) {
try {
const { protocol } = new URL(request.url);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
}
function requestKey(request: NetworkRequest) {
return [
request.method,
request.resourceType,
request.url,
].join('\u0000');
}
function groupRequests(requests: NetworkRequest[] | undefined) {
const grouped = new Map<string, NetworkRequest[]>();
for (const request of requests ?? []) {
if (!isHttpRequest(request)) continue;
const key = requestKey(request);
const rows = grouped.get(key) ?? [];
rows.push(request);
grouped.set(key, rows);
}
return grouped;
}
function byRound(samples: BrowserMeasurementSample[]) {
return new Map(samples.map(sample => [sample.round, sample]));
}
function diffRound(round: number, baseSample: BrowserMeasurementSample | undefined, headSample: BrowserMeasurementSample | undefined) {
const baseRequests = groupRequests(baseSample?.networkRequests);
const headRequests = groupRequests(headSample?.networkRequests);
const keys = [...new Set([
...baseRequests.keys(),
...headRequests.keys(),
])].toSorted();
const diffs: RequestDiff[] = [];
for (const key of keys) {
const baseRows = baseRequests.get(key) ?? [];
const headRows = headRequests.get(key) ?? [];
if (headRows.length > baseRows.length) {
for (const request of headRows.slice(baseRows.length)) {
diffs.push({
direction: 'added',
round,
baseCount: baseRows.length,
headCount: headRows.length,
request,
});
}
} else if (baseRows.length > headRows.length) {
for (const request of baseRows.slice(headRows.length)) {
diffs.push({
direction: 'removed',
round,
baseCount: baseRows.length,
headCount: headRows.length,
request,
});
}
}
}
return diffs;
}
function diffReports(base: BrowserMetricsReport, head: BrowserMetricsReport) {
const baseSamples = byRound(base.samples);
const headSamples = byRound(head.samples);
const rounds = [...new Set([
...baseSamples.keys(),
...headSamples.keys(),
])].toSorted((a, b) => a - b);
return rounds.flatMap(round => diffRound(round, baseSamples.get(round), headSamples.get(round)));
}
function formatMaybeJson(value: string | undefined) {
if (value == null || value === '') return null;
try {
return JSON.stringify(JSON.parse(value), null, '\t');
} catch {
return value;
}
}
function formatHeaders(headers: Record<string, string> | undefined) {
if (headers == null || Object.keys(headers).length === 0) return null;
return JSON.stringify(headers, null, '\t');
}
function countBy<T extends string>(diffs: RequestDiff[], getKey: (diff: RequestDiff) => T) {
const counts = new Map<T, number>();
for (const diff of diffs) {
counts.set(getKey(diff), (counts.get(getKey(diff)) ?? 0) + 1);
}
return [...counts].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
}
function renderSummary(base: BrowserMetricsReport, head: BrowserMetricsReport, diffs: RequestDiff[]) {
const added = diffs.filter(diff => diff.direction === 'added').length;
const removed = diffs.filter(diff => diff.direction === 'removed').length;
const typeRows = countBy(diffs, diff => diff.request.resourceType).map(([type, count]) => `
<tr>
<td>${escapeHtml(type)}</td>
<td class="num">${util.formatNumber(count)}</td>
</tr>`).join('');
return `
<section class="summary">
<div>
<span class="label">Base samples</span>
<strong>${util.formatNumber(base.sampleCount)}</strong>
</div>
<div>
<span class="label">Head samples</span>
<strong>${util.formatNumber(head.sampleCount)}</strong>
</div>
<div>
<span class="label">Added in Head</span>
<strong class="added-text">${util.formatNumber(added)}</strong>
</div>
<div>
<span class="label">Removed in Head</span>
<strong class="removed-text">${util.formatNumber(removed)}</strong>
</div>
</section>
${typeRows === '' ? '' : `
<section>
<h2>Diffs by Resource Type</h2>
<table>
<thead><tr><th>Type</th><th>Diff requests</th></tr></thead>
<tbody>${typeRows}
</tbody>
</table>
</section>`}`;
}
function renderDetails(title: string, content: string | null, open = false) {
if (content == null || content === '') return '';
return `
<details${open ? ' open' : ''}>
<summary>${escapeHtml(title)}</summary>
<pre>${escapeHtml(content)}</pre>
</details>`;
}
function renderRequest(diff: RequestDiff) {
const { request } = diff;
const requestBody = formatMaybeJson(request.requestBody);
const requestHeaders = formatHeaders(request.requestHeaders);
const responseHeaders = formatHeaders(request.responseHeaders);
const bodyNote = requestBody == null && request.hasRequestBody === true
? '<p class="empty">Request body was present but could not be retrieved from CDP.</p>'
: '';
return `
<article class="request ${diff.direction}">
<header>
<span class="badge">${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'}</span>
<span class="method">${escapeHtml(request.method)}</span>
<span class="type">${escapeHtml(request.resourceType)}</span>
<span class="status">${escapeHtml(request.status ?? '-')}</span>
</header>
<a class="url" href="${escapeAttribute(request.url)}">${escapeHtml(request.url)}</a>
<dl>
<div><dt>Round</dt><dd>${util.formatNumber(diff.round)}</dd></div>
<div><dt>Base count</dt><dd>${util.formatNumber(diff.baseCount)}</dd></div>
<div><dt>Head count</dt><dd>${util.formatNumber(diff.headCount)}</dd></div>
<div><dt>Encoded</dt><dd>${util.formatBytes(request.encodedDataLength ?? 0)}</dd></div>
<div><dt>Decoded body</dt><dd>${util.formatBytes(request.decodedBodyLength ?? 0)}</dd></div>
<div><dt>MIME</dt><dd>${escapeHtml(request.mimeType ?? '-')}</dd></div>
<div><dt>Protocol</dt><dd>${escapeHtml(request.protocol ?? '-')}</dd></div>
<div><dt>Remote</dt><dd>${escapeHtml(request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`)}</dd></div>
<div><dt>Failed</dt><dd>${request.failed ? escapeHtml(request.errorText ?? 'yes') : 'no'}</dd></div>
</dl>
${bodyNote}
${renderDetails('Request body', requestBody, requestBody != null)}
${renderDetails('Request headers', requestHeaders)}
${renderDetails('Response headers', responseHeaders)}
</article>`;
}
function renderRound(round: number, diffs: RequestDiff[]) {
const added = diffs.filter(diff => diff.direction === 'added').length;
const removed = diffs.filter(diff => diff.direction === 'removed').length;
return `
<section>
<h2>Round ${util.formatNumber(round)}</h2>
<p>${util.formatNumber(added)} added, ${util.formatNumber(removed)} removed</p>
<div class="requests">
${diffs.map(renderRequest).join('\n')}
</div>
</section>`;
}
function renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) {
const diffs = diffReports(base, head);
const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b);
const generatedAt = new Date().toISOString();
const content = diffs.length === 0
? '<section><p>No added or removed HTTP(S) requests were found in paired samples.</p></section>'
: rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))).join('\n');
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Frontend Browser Network Request Diff</title>
<style>
:root {
color-scheme: light dark;
--bg: #f7f7f8;
--fg: #202124;
--muted: #5f6368;
--card: #ffffff;
--border: #dfe1e5;
--added: #137333;
--added-bg: #e6f4ea;
--removed: #a50e0e;
--removed-bg: #fce8e6;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111315;
--fg: #e8eaed;
--muted: #bdc1c6;
--card: #1b1d20;
--border: #3c4043;
--added-bg: #17351f;
--removed-bg: #3c1f1d;
}
}
body {
margin: 0;
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--fg);
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
h1 {
font-size: 24px;
margin: 0 0 8px;
}
h2 {
font-size: 18px;
margin: 32px 0 8px;
}
.meta {
color: var(--muted);
margin: 0 0 24px;
}
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin: 24px 0;
}
.summary > div, .request, table {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
}
.summary > div {
padding: 14px;
}
.label {
display: block;
color: var(--muted);
font-size: 12px;
}
.summary strong {
display: block;
font-size: 24px;
margin-top: 4px;
}
.added-text {
color: var(--added);
}
.removed-text {
color: var(--removed);
}
table {
border-collapse: collapse;
width: 100%;
overflow: hidden;
}
th, td {
border-bottom: 1px solid var(--border);
padding: 8px 10px;
text-align: left;
}
th {
color: var(--muted);
font-weight: 600;
}
.num {
text-align: right;
}
.requests {
display: grid;
gap: 12px;
}
.request {
padding: 14px;
overflow-wrap: anywhere;
}
.request.added {
border-left: 4px solid var(--added);
}
.request.removed {
border-left: 4px solid var(--removed);
}
.request header {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.badge, .method, .type, .status {
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
}
.added .badge {
background: var(--added-bg);
color: var(--added);
}
.removed .badge {
background: var(--removed-bg);
color: var(--removed);
}
.method, .type, .status {
background: color-mix(in srgb, var(--muted) 14%, transparent);
color: var(--fg);
}
.url {
display: block;
margin: 8px 0 12px;
color: inherit;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
}
dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px 16px;
margin: 0 0 12px;
}
dl div {
min-width: 0;
}
dt {
color: var(--muted);
font-size: 12px;
}
dd {
margin: 0;
}
details {
margin-top: 8px;
}
summary {
cursor: pointer;
color: var(--muted);
}
pre {
white-space: pre-wrap;
overflow-x: auto;
background: color-mix(in srgb, var(--muted) 10%, transparent);
border-radius: 6px;
padding: 10px;
}
.empty {
color: var(--muted);
}
</style>
</head>
<body>
<main>
<h1>Frontend Browser Network Request Diff</h1>
<p class="meta">Generated at ${escapeHtml(generatedAt)}. Requests are compared per paired round by method, resource type, and exact URL. Bodies are shown for added/removed request instances when CDP exposes them.</p>
${renderSummary(base, head, diffs)}
${content}
</main>
</body>
</html>
`;
}
async function main() {
const [baseFile, headFile, outputFile] = process.argv.slice(2);
if (baseFile == null || headFile == null || outputFile == null) {
throw new Error('Usage: node frontend-browser-detailed-html.mts <base-browser.json> <head-browser.json> <output.html>');
}
const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport;
const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport;
await writeFile(outputFile, renderHtml(base, head));
}
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@@ -0,0 +1,378 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import * as util from './utility.mts';
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
import type { HeapSnapshotData, HeapSnapshotReport } from './heap-snapshot-util.mts';
import type { NetworkRequest } from './chrome.mts';
export type BrowserMeasurement = {
label: string;
timestamp: string;
url: string;
scenario: string;
durationMs: number;
network: {
requestCount: number;
webSocketConnectionCount: number;
webSocketSentBytes: number;
webSocketReceivedBytes: number;
finishedRequestCount: number;
failedRequestCount: number;
cachedRequestCount: number;
serviceWorkerRequestCount: number;
totalEncodedBytes: number;
totalDecodedBodyBytes: number;
sameOriginEncodedBytes: number;
thirdPartyEncodedBytes: number;
byResourceType: Record<string, {
requests: number;
encodedBytes: number;
decodedBodyBytes: number;
}>;
largestRequests: {
url: string;
method: string;
resourceType: string;
status?: number;
encodedBytes: number;
decodedBodyBytes: number;
}[];
failedRequests: {
url: string;
method: string;
resourceType: string;
errorText?: string;
status?: number;
}[];
};
performance: {
cdpMetrics: Record<string, number>;
runtimeHeap?: {
usedSize: number;
totalSize: number;
};
tabMemory: {
totalBytes: number;
};
webVitals: {
firstPaintMs?: number;
firstContentfulPaintMs?: number;
domContentLoadedEventEndMs?: number;
loadEventEndMs?: number;
longTaskCount: number;
longTaskDurationMs: number;
maxLongTaskDurationMs: number;
resourceEntryCount: number;
domElements: number;
};
};
heapSnapshot: HeapSnapshotData;
};
export type BrowserMeasurementSample = BrowserMeasurement & {
round: number;
networkRequests?: NetworkRequest[];
};
export type BrowserMetricsReport = {
label: string;
timestamp: string;
url: string;
scenario: string;
sampleCount: number;
aggregation: 'median';
summary: BrowserMeasurement;
samples: BrowserMeasurementSample[];
};
function escapeCell(value: string) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
function truncate(value: string, maxLength = 140) {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength - 3)}...`;
}
function formatMs(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return '-';
if (value >= 1_000) return `${util.formatNumber(value / 1_000)} s`;
return `${util.formatNumber(value)} ms`;
}
function formatSecondsAsMs(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return '-';
return formatMs(value * 1_000);
}
function formatDelta(delta: number, formatter: (value: number) => string, colorThreshold = 0) {
if (delta === 0) return formatter(0);
return util.formatColoredDelta(delta, v => formatter(v), colorThreshold);
}
function finiteValues(values: (number | null | undefined)[]) {
return values.filter(value => Number.isFinite(value)) as number[];
}
function sampleSpread(report: BrowserMetricsReport, getValue: (sample: BrowserMeasurementSample) => number | null | undefined) {
const values = finiteValues(report.samples.map(sample => getValue(sample)));
if (values.length < 2) return null;
const center = util.median(values);
return util.median(values.map(value => Math.abs(value - center)));
}
function formatValueWithSpread(report: BrowserMetricsReport, value: number, getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, formatter: (value: number) => string) {
const spread = sampleSpread(report, getSampleValue);
if (spread == null) return formatter(value);
return `${formatter(value)}<br>± ${formatter(spread)}`;
}
function metricRow(
label: string,
base: BrowserMetricsReport,
head: BrowserMetricsReport,
getSummaryValue: (summary: BrowserMeasurement) => number,
getSampleValue: (sample: BrowserMeasurementSample) => number,
formatter: (value: number) => string,
significantThreshold = 0,
skipIfNotSignificant = true
) {
const baseValue = getSummaryValue(base.summary);
const headValue = getSummaryValue(head.summary);
if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null;
const summary = util.pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample));
// 有意な閾値に満たない場合はそもそもrowとして出力しない
if (skipIfNotSignificant && (Math.abs(summary.median) < significantThreshold)) return null;
const percent = baseValue === 0 ? null : summary.median * 100 / baseValue;
//const deltaMedian = `${formatDelta(summary.median, formatter, colorThreshold)}<br>${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
const deltaMedian = formatDelta(summary.median, formatter, significantThreshold);
//return `| **${label}** | ${formatValueWithSpread(base, baseValue, getSampleValue, formatter)} | ${formatValueWithSpread(head, headValue, getSampleValue, formatter)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter)} | ${summary == null ? '-' : formatDelta(summary.max, formatter)} |`;
return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter, significantThreshold)} | ${summary == null ? '-' : formatDelta(summary.max, formatter, significantThreshold)} |`;
}
function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) {
return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0);
}
function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) {
return resourceTypeBytes(sample, resourceTypes);
}
function getMetric(report: BrowserMeasurement, key: string) {
return report.performance.cdpMetrics[key];
}
function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) {
const rows = [
//metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs),
metricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber, 1, !all),
//metricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, util.formatNumber),
metricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes, 10000, !all),
metricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, util.formatBytes, 10000, !all),
metricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, util.formatBytes, 10000, !all),
metricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, util.formatBytes, 10000, !all),
metricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), util.formatBytes, 10000, !all),
metricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), util.formatBytes, 10000, !all),
metricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes, 10000, !all),
metricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes, 10000, !all),
metricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), util.formatBytes, 10000, !all),
//metricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, formatMs),
//metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, formatMs),
//metricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, util.formatNumber),
//metricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, formatMs),
//metricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, formatMs),
//metricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), util.formatBytes),
//metricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), util.formatBytes),
//metricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, util.formatBytes, 10000),
//metricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, util.formatNumber),
//metricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), util.formatNumber),
//metricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), util.formatNumber),
//metricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), util.formatNumber),
//metricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), util.formatNumber),
//metricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), formatSecondsAsMs),
//metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), formatSecondsAsMs),
metricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, util.formatNumber, 1, !all),
metricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, util.formatBytes, 10000, !all),
metricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, util.formatBytes, 10000, !all),
metricRow('Tab memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, util.formatBytes, 10000, !all),
].filter(row => row != null);
return [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
...rows,
].join('\n');
}
function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetricsReport) {
const preferredOrder = ['Document', 'Script', 'Stylesheet', 'Fetch', 'XHR', 'Image', 'Font', 'Media', 'WebSocket', 'EventSource', 'Other'];
const keys = [...new Set([
...preferredOrder,
...Object.keys(base.summary.network.byResourceType),
...Object.keys(head.summary.network.byResourceType),
])].filter(key => base.summary.network.byResourceType[key] != null || head.summary.network.byResourceType[key] != null);
const lines = [
'<table>',
'<thead>',
'<tr>',
'<th rowspan="2">Type</th>',
'<th colspan="3">Requests</th>',
'<th colspan="3">Encoded bytes</th>',
'</tr>',
'<tr>',
'<th>Base</th>',
'<th>Head</th>',
'<th>Δ</th>',
'<th>Base</th>',
'<th>Head</th>',
'<th>Δ</th>',
'</tr>',
'</thead>',
'<tbody>',
];
for (const key of keys) {
const baseRow = base.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 };
const headRow = head.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 };
lines.push('<tr>');
lines.push(`<td><b>${key}</b></td>`);
lines.push(`<td align="right">${util.formatNumber(baseRow.requests)}</td>`);
lines.push(`<td align="right">${util.formatNumber(headRow.requests)}</td>`);
lines.push(`<td align="right">${formatDelta(headRow.requests - baseRow.requests, util.formatNumber)}</td>`);
lines.push(`<td align="right">${util.formatBytes(baseRow.encodedBytes)}</td>`);
lines.push(`<td align="right">${util.formatBytes(headRow.encodedBytes)}</td>`);
lines.push(`<td align="right">${formatDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)}</td>`);
lines.push('</tr>');
}
lines.push('</tbody>');
lines.push('</table>');
return lines.join('\n');
}
function renderLargestRequests(report: BrowserMetricsReport, title: string) {
if (report.summary.network.largestRequests.length === 0) return null;
const lines = [
`<details><summary>${title}</summary>`,
'',
'| Resource | Type | Status | Encoded | Decoded |',
'| --- | --- | ---: | ---: | ---: |',
];
for (const request of report.summary.network.largestRequests.slice(0, 10)) {
lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${util.formatBytes(request.encodedBytes)} | ${util.formatBytes(request.decodedBodyBytes)} |`);
}
lines.push('', '</details>');
return lines.join('\n');
}
function renderFailedRequests(report: BrowserMetricsReport, title: string) {
if (report.summary.network.failedRequests.length === 0) return null;
const lines = [
`<details><summary>${title}</summary>`,
'',
'| Resource | Type | Status | Error |',
'| --- | --- | ---: | --- |',
];
for (const request of report.summary.network.failedRequests.slice(0, 20)) {
lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${escapeCell(request.errorText ?? '')} |`);
}
lines.push('', '</details>');
return lines.join('\n');
}
function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport {
return {
summary: report.summary.heapSnapshot,
samples: report.samples.map(sample => ({
round: sample.round,
data: sample.heapSnapshot,
})),
};
}
export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: BrowserMetricsReport, options: {
headHeapSnapshotUrl?: string;
detailedHtmlUrl?: string;
} = {}) {
const headHeapSnapshotUrl = options.headHeapSnapshotUrl;
const detailedHtmlUrl = options.detailedHtmlUrl;
const sampleSummary = base.sampleCount === head.sampleCount
? `${base.sampleCount} samples per side`
: `${base.sampleCount} base sample(s), ${head.sampleCount} head sample(s)`;
const heapSnapshotTable = heapSnapshotUtil.renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head));
const lines = [
'## 🖥 Frontend Browser Metrics',
'',
'Only metrics showing significant changes are displayed.',
'',
renderSummaryTable(base, head),
'',
//`> Measured ${sampleSummary} with fresh headless Chrome profiles, browser cache disabled, service workers bypassed, and forced V8 GC before each heap snapshot. Base/Head values are medians; Δ median is the median of paired Head - Base sample deltas; percent uses Δ median / Base median; ± and Δ MAD are median absolute deviations. Scenario: sign up, dismiss the initial account setup dialog, create the first timeline note, then wait until that note is visible.`,
//'',
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`,
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '',
'<details>',
'<summary>Requests by resource type</summary>',
'',
renderResourceTypeTable(base, head),
'',
'</details>',
'',
'<details>',
'<summary>V8 heap snapshot statistics</summary>',
'',
heapSnapshotTable ?? '_No V8 heap snapshot data._',
'',
heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'),
'',
`[Download representative head heap snapshot](${headHeapSnapshotUrl})`,
'</details>',
'',
];
for (const section of [
//renderLargestRequests(head, 'Largest representative head requests'),
//renderFailedRequests(base, 'Failed representative base requests'),
//renderFailedRequests(head, 'Failed representative head requests'),
]) {
if (section == null) continue;
lines.push(section, '');
}
return lines.filter(line => line != null).join('\n').trimEnd() + '\n';
}
async function main() {
const [baseFile, headFile, outputFile] = process.argv.slice(2);
if (baseFile == null || headFile == null || outputFile == null) {
throw new Error('Usage: node frontend-browser-report.mts <base-browser.json> <head-browser.json> <output.md>');
}
const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport;
const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport;
await writeFile(outputFile, renderFrontendBrowserReport(base, head, {
headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL,
detailedHtmlUrl: process.env.FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL,
}));
}
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@@ -32,6 +32,326 @@ export type HeapSnapshotReport = {
}[];
};
export const defaultHeapSnapshotBreakdownTopN = 6;
export function createEmptyHeapSnapshotData(): HeapSnapshotData {
const categories = {} as HeapSnapshotData['categories'];
const nodeCounts = {} as HeapSnapshotData['nodeCounts'];
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
categories[category] = 0;
nodeCounts[category] = 0;
}
return {
categories,
nodeCounts,
breakdowns: {} as HeapSnapshotData['breakdowns'],
};
}
function sanitizeHeapSnapshotBreakdownLabel(value: unknown, fallback = 'unknown') {
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
if (label === '') return fallback;
if (label.length <= 80) return label;
return `${label.slice(0, 77)}...`;
}
function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type: string, name: string) {
if (category === 'strings') return type;
if (category === 'jsArrays') {
if (type === 'array elements') return 'Array elements';
if (type === 'object' && name === 'Array') return 'Array objects';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}
if (category === 'typedArrays') {
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}
if (category === 'systemObjects') {
if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name);
if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name);
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
if (category === 'otherJsObjects') {
if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown');
return type;
}
if (category === 'otherNonJsObjects') {
if (type === 'extra native bytes') return 'Extra native bytes';
if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
if (category === 'code') {
const lowerName = name.toLowerCase();
if (lowerName.includes('bytecode')) return 'bytecode';
if (lowerName.includes('builtin')) return 'builtins';
if (lowerName.includes('regexp')) return 'regexp code';
if (lowerName.includes('stub')) return 'stubs';
return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown');
}
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
export function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>, topN = defaultHeapSnapshotBreakdownTopN) {
const entries = Object.entries(breakdown)
.filter(([, value]) => value > 0)
.toSorted((a, b) => b[1] - a[1]);
const topEntries = entries.slice(0, topN);
const otherValue = entries
.slice(topN)
.reduce((sum, [, value]) => sum + value, 0);
const collapsed = Object.fromEntries(topEntries);
if (otherValue > 0) collapsed.Other = otherValue;
return collapsed;
}
export function collapseHeapSnapshotBreakdowns(
breakdowns: Partial<Record<keyof typeof heapSnapshotCategory, Record<string, number>>>,
topN = defaultHeapSnapshotBreakdownTopN,
) {
const collapsed = {} as NonNullable<HeapSnapshotData['breakdowns']>;
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
if (category === 'total') continue;
const categoryBreakdown = breakdowns[category];
if (categoryBreakdown == null) continue;
const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN);
if (Object.keys(collapsedCategory).length > 0) {
collapsed[category] = collapsedCategory;
}
}
return collapsed;
}
// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view.
export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData {
const meta = snapshot?.snapshot?.meta;
const nodes = snapshot?.nodes;
const edges = snapshot?.edges;
const strings = snapshot?.strings;
if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) {
throw new Error('Invalid heap snapshot format');
}
const nodeFields = meta.node_fields;
if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
const edgeFields = meta.edge_fields;
if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields');
const typeOffset = nodeFields.indexOf('type');
const nameOffset = nodeFields.indexOf('name');
const selfSizeOffset = nodeFields.indexOf('self_size');
const edgeCountOffset = nodeFields.indexOf('edge_count');
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) {
throw new Error('Heap snapshot is missing required node fields');
}
const edgeTypeOffset = edgeFields.indexOf('type');
const edgeNameOffset = edgeFields.indexOf('name_or_index');
const edgeToNodeOffset = edgeFields.indexOf('to_node');
if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) {
throw new Error('Heap snapshot is missing required edge fields');
}
const nodeTypeNames = meta.node_types?.[typeOffset];
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
const edgeTypeNames = meta.edge_types?.[edgeTypeOffset];
if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types');
const nodeFieldCount = nodeFields.length;
const edgeFieldCount = edgeFields.length;
const nativeType = nodeTypeNames.indexOf('native');
const codeType = nodeTypeNames.indexOf('code');
const hiddenType = nodeTypeNames.indexOf('hidden');
const stringTypes = new Set([
nodeTypeNames.indexOf('string'),
nodeTypeNames.indexOf('concatenated string'),
nodeTypeNames.indexOf('sliced string'),
]);
const internalEdgeType = edgeTypeNames.indexOf('internal');
const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0;
const { categories, nodeCounts } = createEmptyHeapSnapshotData();
const breakdowns = {} as Record<keyof typeof heapSnapshotCategory, Record<string, number>>;
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
if (category !== 'total') breakdowns[category] = {};
}
function addValue(map: Record<string, number>, key: string, value: number) {
map[key] = (map[key] ?? 0) + value;
}
const edgeStartIndexes = new Map<number, number>();
const retainerCounts = new Map<number, number>();
let edgeIndex = 0;
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
edgeStartIndexes.set(nodeIndex, edgeIndex);
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) {
const toNodeIndex = edges[edgeIndex + edgeToNodeOffset];
retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1);
}
}
const jsArrayElementNodeIndexes = new Set<number>();
function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) {
if (value <= 0) return;
categories[category] += value;
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value);
if (nodeIndex != null) nodeCounts[category]++;
}
function addJsArrayElementSize(nodeIndex: number) {
const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0;
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) {
const edgeType = edges[currentEdgeIndex + edgeTypeOffset];
if (edgeType !== internalEdgeType) continue;
const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]];
if (edgeName !== 'elements') continue;
const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset];
if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) {
const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0;
addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex);
jsArrayElementNodeIndexes.add(elementsNodeIndex);
}
break;
}
}
if (extraNativeBytes > 0) {
addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes');
}
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
const typeId = nodes[nodeIndex + typeOffset];
const type = nodeTypeNames[typeId] ?? 'unknown';
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
categories.total += selfSize;
nodeCounts.total++;
if (typeId === hiddenType) {
addCategoryValue('systemObjects', selfSize, type, name, nodeIndex);
continue;
}
if (typeId === nativeType) {
if (name === 'system / JSArrayBufferData') {
addCategoryValue('typedArrays', selfSize, type, name, nodeIndex);
} else {
addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex);
}
continue;
}
if (typeId === codeType) {
addCategoryValue('code', selfSize, type, name, nodeIndex);
continue;
}
if (stringTypes.has(typeId)) {
addCategoryValue('strings', selfSize, type, name, nodeIndex);
continue;
}
if (name === 'Array') {
addCategoryValue('jsArrays', selfSize, type, name, nodeIndex);
addJsArrayElementSize(nodeIndex);
continue;
}
}
categories.total += extraNativeBytes;
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
if (jsArrayElementNodeIndexes.has(nodeIndex)) continue;
const typeId = nodes[nodeIndex + typeOffset];
if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue;
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
if (name === 'Array') continue;
const type = nodeTypeNames[typeId] ?? 'unknown';
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex);
}
return {
categories,
nodeCounts,
breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN),
};
}
function finiteMedian(values: (number | null | undefined)[]) {
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
if (finiteValues.length === 0) return null;
return util.median(finiteValues);
}
export function summarizeHeapSnapshotDataSamples<T>(
samples: T[],
getData: (sample: T) => HeapSnapshotData | null | undefined,
options: { breakdownTopN?: number } = {},
) {
const data = samples.map(getData);
const categories = {} as HeapSnapshotData['categories'];
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
const value = finiteMedian(data.map(snapshot => snapshot?.categories?.[category]));
if (value != null) categories[category] = value;
}
const nodeCounts = {} as HeapSnapshotData['nodeCounts'];
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
const value = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category]));
if (value != null) nodeCounts[category] = value;
}
if (Object.keys(categories).length === 0) return null;
const breakdowns = {} as NonNullable<HeapSnapshotData['breakdowns']>;
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
if (category === 'total') continue;
const childKeys = new Set<string>();
for (const snapshot of data) {
for (const childKey of Object.keys(snapshot?.breakdowns?.[category] ?? {})) {
childKeys.add(childKey);
}
}
const categoryBreakdown = {} as Record<string, number>;
for (const childKey of childKeys) {
const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey]));
if (value != null) categoryBreakdown[childKey] = value;
}
const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN);
if (Object.keys(collapsed).length > 0) {
breakdowns[category] = collapsed;
}
}
return {
categories,
nodeCounts,
...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}),
};
}
function getHeapSnapshotCategoryValue(report: HeapSnapshotReport, category: keyof typeof heapSnapshotCategory) {
return report.summary.categories[category];
}

View File

@@ -38,7 +38,7 @@ export type MemoryReport = {
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1);
const HEAD_HEAP_SNAPSHOT_WORK_DIR = resolve('head-heap-snapshots');
const HEAD_HEAP_SNAPSHOT_OUTPUT_PATH = resolve('head-heap-snapshot.heapsnapshot');
@@ -70,51 +70,6 @@ async function resetState(repoDir: string) {
}
}
function summarizeHeapSnapshotBreakdowns(samples: MemoryReport['samples'], phase: typeof phases[number]) {
const breakdowns = {} as Record<keyof typeof heapSnapshotUtil.heapSnapshotCategory, Record<string, number>>;
for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) {
if (category === 'total') continue;
const childKeys = new Set<string>();
for (const sample of samples) {
for (const childKey of Object.keys(sample.phases[phase].heapSnapshot?.breakdowns?.[category] ?? {})) {
childKeys.add(childKey);
}
}
const categoryBreakdown = {} as Record<string, number>;
for (const childKey of childKeys) {
const values = samples
.map(sample => sample.phases[phase].heapSnapshot?.breakdowns?.[category]?.[childKey])
.filter(value => Number.isFinite(value)) as number[];
if (values.length > 0) categoryBreakdown[childKey] = util.median(values);
}
if (Object.keys(categoryBreakdown).length > 0) {
breakdowns[category] = collapseHeapSnapshotBreakdown(categoryBreakdown);
}
}
return breakdowns;
}
function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>) {
const entries = Object.entries(breakdown)
.filter(([, value]) => value > 0)
.toSorted((a, b) => b[1] - a[1]);
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
const otherValue = entries
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
.reduce((sum, [, value]) => sum + value, 0);
const collapsed = Object.fromEntries(topEntries);
if (otherValue > 0) collapsed.Other = otherValue;
return collapsed;
}
function summarizeSamples(samples: MemoryReport['samples']) {
const summary = {} as MemoryReport['summary'];
@@ -135,33 +90,12 @@ function summarizeSamples(samples: MemoryReport['samples']) {
summary[phase].memoryUsage[key] = util.median(values);
}
const heapSnapshotCategoryValues = {} as Record<keyof typeof heapSnapshotUtil.heapSnapshotCategory, number>;
for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) {
const values = samples
.map(sample => sample.phases[phase].heapSnapshot?.categories?.[category])
.filter(value => Number.isFinite(value)) as number[];
if (values.length > 0) heapSnapshotCategoryValues[category] = util.median(values);
}
const heapSnapshotNodeCountValues = {} as Record<keyof typeof heapSnapshotUtil.heapSnapshotCategory, number>;
for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) {
const values = samples
.map(sample => sample.phases[phase].heapSnapshot?.nodeCounts?.[category])
.filter(value => Number.isFinite(value)) as number[];
if (values.length > 0) heapSnapshotNodeCountValues[category] = util.median(values);
}
if (Object.keys(heapSnapshotCategoryValues).length > 0) {
const heapSnapshotBreakdowns = summarizeHeapSnapshotBreakdowns(samples, phase);
summary[phase].heapSnapshot = {
categories: heapSnapshotCategoryValues,
nodeCounts: heapSnapshotNodeCountValues,
...(Object.keys(heapSnapshotBreakdowns).length > 0 ? { breakdowns: heapSnapshotBreakdowns } : {}),
};
}
const heapSnapshot = heapSnapshotUtil.summarizeHeapSnapshotDataSamples(
samples,
sample => sample.phases[phase].heapSnapshot,
{ breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N },
);
if (heapSnapshot != null) summary[phase].heapSnapshot = heapSnapshot;
}
return summary;

View File

@@ -0,0 +1,274 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import * as util from './utility.mts';
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
import { HeadlessChromeController, summarizeNetwork } from './chrome.mts';
import type { BrowserMeasurement, NetworkRequest, NetworkSummary } from './chrome.mts';
import { closeUserSetupDialog, postNote, signupThroughUi, visitHome } from '../../packages/frontend/test/e2e/shared.ts';
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg, headHeapSnapshotOutputArg] = process.argv.slice(2);
const baseUrl = process.env.FRONTEND_BROWSER_METRICS_URL ?? 'http://127.0.0.1:61812';
const sampleCount = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SAMPLE_COUNT', 5, 1);
const heapSnapshotBreakdownTopN = util.readIntegerEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1);
const headHeapSnapshotWorkDir = resolve('frontend-browser-head-heap-snapshots');
type BrowserMeasurementSample = BrowserMeasurement & {
round: number;
networkRequests: NetworkRequest[];
};
type BrowserMetricsReport = {
label: string;
timestamp: string;
url: string;
scenario: string;
sampleCount: number;
aggregation: 'median';
summary: BrowserMeasurement;
samples: BrowserMeasurementSample[];
};
async function runSignupAndPostScenario(chrome: HeadlessChromeController) {
const page = chrome.page;
const noteText = `Frontend browser metrics ${Date.now()}`;
await visitHome(page, baseUrl);
await signupThroughUi(page, { username: 'alice', password: 'password' });
await closeUserSetupDialog(page);
await postNote(page, noteText, 10_000);
await util.sleep(1000);
}
function finiteMedian(values: (number | null | undefined)[], defaultValue = 0) {
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
if (finiteValues.length === 0) return defaultValue;
return util.median(finiteValues);
}
function selectRepresentativeSample(samples: BrowserMeasurementSample[], getValue: (sample: BrowserMeasurementSample) => number) {
const medianValue = finiteMedian(samples.map(getValue));
let selected: { sample: BrowserMeasurementSample; distance: number } | null = null;
for (const sample of samples) {
const value = getValue(sample);
if (!Number.isFinite(value)) continue;
const distance = Math.abs(value - medianValue);
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.sample.round)) {
selected = {
sample,
distance,
};
}
}
return selected?.sample ?? samples[0];
}
function summarizeResourceType(samples: BrowserMeasurementSample[], resourceType: string) {
return {
requests: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.requests)),
encodedBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.encodedBytes)),
decodedBodyBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.decodedBodyBytes)),
};
}
function summarizeNetworkSamples(samples: BrowserMeasurementSample[]): NetworkSummary {
const resourceTypes = new Set<string>();
for (const sample of samples) {
for (const resourceType of Object.keys(sample.network.byResourceType)) {
resourceTypes.add(resourceType);
}
}
const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes);
const byResourceType = {} as NetworkSummary['byResourceType'];
for (const resourceType of resourceTypes) {
byResourceType[resourceType] = summarizeResourceType(samples, resourceType);
}
return {
requestCount: finiteMedian(samples.map(sample => sample.network.requestCount)),
webSocketConnectionCount: finiteMedian(samples.map(sample => sample.network.webSocketConnectionCount)),
webSocketSentBytes: finiteMedian(samples.map(sample => sample.network.webSocketSentBytes)),
webSocketReceivedBytes: finiteMedian(samples.map(sample => sample.network.webSocketReceivedBytes)),
finishedRequestCount: finiteMedian(samples.map(sample => sample.network.finishedRequestCount)),
failedRequestCount: finiteMedian(samples.map(sample => sample.network.failedRequestCount)),
cachedRequestCount: finiteMedian(samples.map(sample => sample.network.cachedRequestCount)),
serviceWorkerRequestCount: finiteMedian(samples.map(sample => sample.network.serviceWorkerRequestCount)),
totalEncodedBytes: finiteMedian(samples.map(sample => sample.network.totalEncodedBytes)),
totalDecodedBodyBytes: finiteMedian(samples.map(sample => sample.network.totalDecodedBodyBytes)),
sameOriginEncodedBytes: finiteMedian(samples.map(sample => sample.network.sameOriginEncodedBytes)),
thirdPartyEncodedBytes: finiteMedian(samples.map(sample => sample.network.thirdPartyEncodedBytes)),
byResourceType,
largestRequests: representative.network.largestRequests,
failedRequests: representative.network.failedRequests,
};
}
function summarizePerformanceSamples(samples: BrowserMeasurementSample[]): BrowserMeasurement['performance'] {
const cdpMetricKeys = new Set<string>();
for (const sample of samples) {
for (const key of Object.keys(sample.performance.cdpMetrics)) {
cdpMetricKeys.add(key);
}
}
const cdpMetrics = {} as Record<string, number>;
for (const key of cdpMetricKeys) {
cdpMetrics[key] = finiteMedian(samples.map(sample => sample.performance.cdpMetrics[key]));
}
const webVitalKeys = [
'firstPaintMs',
'firstContentfulPaintMs',
'domContentLoadedEventEndMs',
'loadEventEndMs',
'longTaskCount',
'longTaskDurationMs',
'maxLongTaskDurationMs',
'resourceEntryCount',
'domElements',
] as const satisfies (keyof BrowserMeasurement['performance']['webVitals'])[];
const webVitals = {} as BrowserMeasurement['performance']['webVitals'];
for (const key of webVitalKeys) {
webVitals[key] = finiteMedian(samples.map(sample => sample.performance.webVitals[key]));
}
return {
cdpMetrics,
runtimeHeap: {
usedSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.usedSize)),
totalSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.totalSize)),
},
tabMemory: {
totalBytes: finiteMedian(samples.map(sample => sample.performance.tabMemory.totalBytes)),
},
webVitals,
};
}
function summarizeHeapSnapshotSamples(samples: BrowserMeasurementSample[]) {
const summary = heapSnapshotUtil.summarizeHeapSnapshotDataSamples(
samples,
sample => sample.heapSnapshot,
{ breakdownTopN: heapSnapshotBreakdownTopN },
);
if (summary == null) throw new Error('No heap snapshot samples');
return summary;
}
function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSample[]): BrowserMetricsReport {
if (samples.length === 0) throw new Error(`No browser metric samples for ${label}`);
const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes);
const summary: BrowserMeasurement = {
label,
timestamp: new Date().toISOString(),
url: baseUrl,
scenario: representative.scenario,
durationMs: finiteMedian(samples.map(sample => sample.durationMs)),
network: summarizeNetworkSamples(samples),
performance: summarizePerformanceSamples(samples),
heapSnapshot: summarizeHeapSnapshotSamples(samples),
};
return {
label,
timestamp: new Date().toISOString(),
url: baseUrl,
scenario: representative.scenario,
sampleCount: samples.length,
aggregation: 'median',
summary,
samples,
};
}
async function measureSample(label: 'base' | 'head', round: number, heapSnapshotSavePath?: string) {
await util.prepareInstance(baseUrl);
return await HeadlessChromeController.with(label, { scenarioTimeoutMs: 120000, baseUrl }, async chrome => {
await chrome.enableNetworkTracking();
const startedAt = Date.now();
await runSignupAndPostScenario(chrome);
const durationMs = Date.now() - startedAt;
await chrome.waitForNetworkDetails();
const performance = await chrome.collectPerformance();
const heapSnapshotRaw = await chrome.takeHeapSnapshot(heapSnapshotSavePath);
const heapSnapshot = heapSnapshotUtil.analyzeHeapSnapshot(heapSnapshotRaw, { breakdownTopN: heapSnapshotBreakdownTopN });
const measurement: BrowserMeasurementSample = {
label,
round,
timestamp: new Date().toISOString(),
url: baseUrl,
scenario: 'fresh browser signup, first timeline note, after the note becomes visible',
durationMs,
network: summarizeNetwork(chrome.networkRequests, baseUrl, chrome.webSocketConnections),
networkRequests: chrome.networkRequests,
performance,
heapSnapshot,
};
return measurement;
});
}
function headHeapSnapshotPath(round: number) {
return join(headHeapSnapshotWorkDir, `round-${round}.heapsnapshot`);
}
async function saveRepresentativeHeadHeapSnapshot(report: BrowserMetricsReport, outputPath: string) {
const representative = selectRepresentativeSample(report.samples, sample => sample.heapSnapshot.categories.total);
await copyFile(headHeapSnapshotPath(representative.round), outputPath);
process.stderr.write(`[head] Selected round ${representative.round} heap snapshot for artifact\n`);
await rm(headHeapSnapshotWorkDir, { recursive: true, force: true });
}
async function measureRepo(label: 'base' | 'head', repoDir: string, outputPath: string, heapSnapshotSavePath?: string) {
let server: ReturnType<typeof util.startServer> | null = null;
try {
server = util.startServer(label, repoDir);
await util.waitForServer(baseUrl, server!);
if (label === 'head' && heapSnapshotSavePath != null) {
await rm(headHeapSnapshotWorkDir, { recursive: true, force: true });
await mkdir(headHeapSnapshotWorkDir, { recursive: true });
}
const samples: BrowserMeasurementSample[] = [];
for (let round = 1; round <= sampleCount; round++) {
process.stderr.write(`[${label}] Measuring browser metrics sample ${round}/${sampleCount}\n`);
samples.push(await measureSample(
label,
round,
label === 'head' && heapSnapshotSavePath != null ? headHeapSnapshotPath(round) : undefined,
));
}
const report = summarizeSamples(label, samples);
await writeFile(outputPath, JSON.stringify(report, null, '\t'));
process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`);
if (label === 'head' && heapSnapshotSavePath != null) {
await saveRepresentativeHeadHeapSnapshot(report, heapSnapshotSavePath);
}
} finally {
if (server != null) await util.stopServer(server);
}
}
async function main() {
await measureRepo('base', resolve(baseDirArg), resolve(baseOutputArg));
await measureRepo('head', resolve(headDirArg), resolve(headOutputArg), headHeapSnapshotOutputArg == null ? undefined : resolve(headHeapSnapshotOutputArg));
}
await main();

View File

@@ -5,10 +5,14 @@
// NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない
import { spawn } from 'node:child_process';
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { promises as fs } from 'node:fs';
import path from 'node:path';
export function sleep(ms: number) {
return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
}
export function median(values: number[]) {
const sorted = values.toSorted((a, b) => a - b);
const center = Math.floor(sorted.length / 2);
@@ -202,3 +206,91 @@ export function run(command: string, args: string[], options: { cwd?: string; en
});
});
}
export function startServer(label: string, repoDir: string) {
process.stderr.write(`[${label}] Starting Misskey test server\n`);
const child = spawn(commandName('pnpm'), ['start:test'], {
cwd: repoDir,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
});
child.stdout.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
child.stderr.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
return child;
}
export async function waitForServer(baseUrl: string, child: ChildProcessWithoutNullStreams) {
const startedAt = Date.now();
while (Date.now() - startedAt < 120_000) {
if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`);
try {
const response = await fetch(`${baseUrl}/`, { redirect: 'manual' });
if (response.status < 500) return;
} catch {
// retry
}
await sleep(1_000);
}
throw new Error(`Timed out waiting for ${baseUrl}`);
}
export async function api(baseUrl: string, endpoint: string, body: Record<string, unknown>) {
const response = await fetch(`${baseUrl}/api/${endpoint}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`/api/${endpoint} returned ${response.status}: ${await response.text()}`);
}
if (response.status === 204) return null;
return await response.json();
}
export async function prepareInstance(baseUrl: string) {
await api(baseUrl, 'reset-db', {});
await api(baseUrl, 'admin/accounts/create', {
username: 'admin',
password: 'admin1234',
setupPassword: 'example_password_please_change_this_or_you_will_get_hacked',
});
}
export async function stopServer(child: ChildProcessWithoutNullStreams) {
if (child.exitCode != null) return;
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
} else if (child.pid != null) {
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill('SIGTERM');
}
}
await new Promise<void>(resolvePromise => {
if (child.exitCode != null) {
resolvePromise();
return;
}
child.once('exit', () => resolvePromise());
setTimeout(() => {
if (child.pid != null) {
try {
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
} else {
process.kill(-child.pid, 'SIGKILL');
}
} catch {
child.kill('SIGKILL');
}
}
resolvePromise();
}, 10_000).unref();
});
}

View File

@@ -0,0 +1,44 @@
name: frontend-browser-metrics-report-comment
on:
workflow_run:
workflows:
- frontend-browser-metrics-report
types:
- completed
permissions:
actions: read
contents: read
issues: write
pull-requests: write
jobs:
comment:
name: Comment frontend browser metrics report
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
concurrency:
group: frontend-browser-metrics-report-comment-${{ github.event.workflow_run.id }}
cancel-in-progress: true
steps:
- name: Download browser metrics report
uses: actions/download-artifact@v8
with:
name: frontend-browser-metrics-report
path: ${{ runner.temp }}/frontend-browser-metrics-report
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Load PR number
id: load-pr-number
shell: bash
run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-browser-metrics-report/pr-number.txt")" >> "$GITHUB_OUTPUT"
- name: Comment on pull request
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-pr-number.outputs.pr-number }}
comment-tag: frontend_browser_metrics_report
file-path: ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md

View File

@@ -0,0 +1,198 @@
name: frontend-browser-metrics-report
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- packages/frontend/**
- packages/frontend-shared/**
- packages/frontend-builder/**
- packages/backend/**
- packages/i18n/**
- packages/icons-subsetter/**
- packages/misskey-js/**
- packages/misskey-reversi/**
- packages/misskey-bubble-game/**
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .node-version
- .github/misskey/test.yml
- .github/scripts/utility.mts
- .github/scripts/frontend-browser-detailed-html.mts
- .github/scripts/frontend-browser-report.mts
- .github/scripts/heap-snapshot-util.mts
- .github/scripts/measure-frontend-browser-comparison.mts
- .github/scripts/chrome.mts
- .github/workflows/frontend-browser-metrics-report.yml
- .github/workflows/frontend-browser-metrics-report-comment.yml
permissions:
contents: read
pull-requests: read
concurrency:
group: frontend-browser-metrics-report-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
report:
name: Measure frontend browser metrics
runs-on: ubuntu-latest
timeout-minutes: 90
services:
postgres:
image: postgres:18
ports:
- 54312:5432
env:
POSTGRES_DB: test-misskey
POSTGRES_HOST_AUTH_METHOD: trust
redis:
image: redis:8
ports:
- 56312:6379
steps:
- name: Checkout base
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.sha }}
path: before
submodules: true
- name: Checkout pull request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
path: after
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
with:
package_json_file: after/package.json
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version-file: after/.node-version
cache: pnpm
cache-dependency-path: |
before/pnpm-lock.yaml
after/pnpm-lock.yaml
- name: Install dependencies for base
working-directory: before
run: pnpm i --frozen-lockfile
- name: Configure base
working-directory: before
run: cp .github/misskey/test.yml .config
- name: Build base
working-directory: before
run: pnpm build
- name: Install dependencies for pull request
working-directory: after
run: pnpm i --frozen-lockfile
- name: Configure pull request
working-directory: after
run: cp .github/misskey/test.yml .config
- name: Build pull request
working-directory: after
run: pnpm build
- name: Install Playwright browsers
working-directory: after/packages/frontend
run: pnpm exec playwright install --with-deps --no-shell chromium
- name: Measure frontend browser metrics
shell: bash
env:
FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5
MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true"
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report"
mkdir -p "$REPORT_DIR"
node after/.github/scripts/measure-frontend-browser-comparison.mts before after "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/head-heap-snapshot.heapsnapshot"
- name: Upload browser head heap snapshot
id: upload-browser-head-heap-snapshot
uses: actions/upload-artifact@v7
with:
name: frontend-browser-metrics-head-heap-snapshot
path: ${{ runner.temp }}/frontend-browser-metrics-report/head-heap-snapshot.heapsnapshot
if-no-files-found: error
retention-days: 7
- name: Generate browser detailed html
shell: bash
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report"
test -s "$REPORT_DIR/before-browser.json"
test -s "$REPORT_DIR/after-browser.json"
node after/.github/scripts/frontend-browser-detailed-html.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-detailed-html.html"
- name: Upload browser detailed html
id: upload-browser-detailed-html
uses: actions/upload-artifact@v7
with:
name: frontend-browser-metrics-detailed-html
path: ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-detailed-html.html
if-no-files-found: error
archive: false
retention-days: 7
- name: Generate browser metrics report
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }}
FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }}
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report"
test -s "$REPORT_DIR/before-browser.json"
test -s "$REPORT_DIR/after-browser.json"
node after/.github/scripts/frontend-browser-report.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-metrics-report.md"
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
- name: Check browser metrics report
shell: bash
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report"
test -s "$REPORT_DIR/frontend-browser-metrics-report.md"
test -s "$REPORT_DIR/frontend-browser-detailed-html.html"
test -s "$REPORT_DIR/pr-number.txt"
test -s "$REPORT_DIR/head-sha.txt"
cat "$REPORT_DIR/frontend-browser-metrics-report.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload browser metrics report
uses: actions/upload-artifact@v7
with:
name: frontend-browser-metrics-report
path: |
${{ runner.temp }}/frontend-browser-metrics-report/before-browser.json
${{ runner.temp }}/frontend-browser-metrics-report/after-browser.json
${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md
${{ runner.temp }}/frontend-browser-metrics-report/pr-number.txt
${{ runner.temp }}/frontend-browser-metrics-report/base-sha.txt
${{ runner.temp }}/frontend-browser-metrics-report/head-sha.txt
${{ runner.temp }}/frontend-browser-metrics-report/pr-url.txt
if-no-files-found: error
retention-days: 7

View File

@@ -21,15 +21,6 @@
### Client
- 2025.4.0 以前の設定情報の移行処理が削除されました
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
- Enhance: 画像ビューワーを独自実装に変更・動画プレイヤーを統合
- 操作性の改善
- ビューワーとMisskeyの各種機能との統合を強化
- パフォーマンスの向上
- Enhance: マウスホイールで拡大縮小できるように
- Fix: 幅が狭い画面で動画の再生が困難な問題を修正
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正

View File

@@ -3,7 +3,6 @@ import { version as summalyVersion } from '@misskey-dev/summaly';
import type { Plugin, ExternalOption } from 'rolldown';
import { execa, execaNode } from 'execa';
import type { ResultPromise } from 'execa';
import fkill from 'fkill';
import esmShim from '@rollup/plugin-esm-shim';
/**
@@ -11,7 +10,6 @@ import esmShim from '@rollup/plugin-esm-shim';
*/
function backendDevServerPlugin(): Plugin {
let backendProcess: ResultPromise | null = null;
let backendShutdownPromise: Promise<void> | null = null;
async function runBuildAssets() {
await execa('pnpm', ['run', 'build-assets'], {
@@ -22,31 +20,12 @@ function backendDevServerPlugin(): Plugin {
}
async function killBackendProcess() {
if (backendShutdownPromise) return backendShutdownPromise;
if (!backendProcess) return;
const processToKill = backendProcess;
backendProcess = null;
processToKill.catch(() => {}); // プロセスの終了によって発生する例外を無視するためにcatch()を呼び出す
backendShutdownPromise = (async () => {
if (process.platform === 'win32' && processToKill.pid != null) {
await fkill(processToKill.pid, {
force: true,
tree: true,
silent: true,
waitForExit: 5000,
});
} else {
processToKill.kill();
}
await processToKill.catch(() => {});
})().finally(() => {
backendShutdownPromise = null;
});
return backendShutdownPromise;
if (backendProcess) {
backendProcess.catch(() => {}); // backendProcess.kill()によって発生する例外を無視するためにcatch()を呼び出す
backendProcess.kill();
await new Promise(resolve => backendProcess!.on('exit', resolve));
backendProcess = null;
}
}
return {
@@ -70,9 +49,6 @@ function backendDevServerPlugin(): Plugin {
await runBuildAssets();
}
},
async closeWatcher() {
await killBackendProcess();
},
};
}

View File

@@ -10,7 +10,7 @@ import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
//import * as http from 'node:http';
import * as fs from 'node:fs/promises';
import { heapSnapshotCategory, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts';
import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -40,7 +40,7 @@ const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Tim
const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0);
const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false);
const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1);
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1);
const HEAP_SNAPSHOT_SAVE_PATH = process.env.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH;
const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
@@ -79,246 +79,6 @@ function bytesToKiB(value: number) {
return Math.round(value / 1024);
}
function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') {
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
if (label === '') return fallback;
if (label.length <= 80) return label;
return `${label.slice(0, 77)}...`;
}
function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type, name) {
if (category === 'strings') return type;
if (category === 'jsArrays') {
if (type === 'array elements') return 'Array elements';
if (type === 'object' && name === 'Array') return 'Array objects';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}
if (category === 'typedArrays') {
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}
if (category === 'systemObjects') {
if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name);
if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name);
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
if (category === 'otherJsObjects') {
if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown');
return type;
}
if (category === 'otherNonJsObjects') {
if (type === 'extra native bytes') return 'Extra native bytes';
if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
if (category === 'code') {
const lowerName = name.toLowerCase();
if (lowerName.includes('bytecode')) return 'bytecode';
if (lowerName.includes('builtin')) return 'builtins';
if (lowerName.includes('regexp')) return 'regexp code';
if (lowerName.includes('stub')) return 'stubs';
return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown');
}
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}
function collapseHeapSnapshotBreakdown(breakdowns: Record<string, Record<string, number>>) {
const collapsed = {} as Record<string, Record<string, number>>;
for (const [category, children] of Object.entries(breakdowns)) {
const entries = Object.entries(children)
.filter(([, value]) => value > 0)
.toSorted((a, b) => b[1] - a[1]);
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
const otherValue = entries
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
.reduce((sum, [, value]) => sum + value, 0);
const categoryBreakdown = Object.fromEntries(topEntries);
if (otherValue > 0) categoryBreakdown.Other = otherValue;
if (Object.keys(categoryBreakdown).length > 0) collapsed[category] = categoryBreakdown;
}
return collapsed;
}
// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view.
function analyzeHeapSnapshot(snapshot) {
const meta = snapshot?.snapshot?.meta;
const nodes = snapshot?.nodes;
const edges = snapshot?.edges;
const strings = snapshot?.strings;
if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) {
throw new Error('Invalid heap snapshot format');
}
const nodeFields = meta.node_fields;
if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
const edgeFields = meta.edge_fields;
if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields');
const typeOffset = nodeFields.indexOf('type');
const nameOffset = nodeFields.indexOf('name');
const selfSizeOffset = nodeFields.indexOf('self_size');
const edgeCountOffset = nodeFields.indexOf('edge_count');
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) {
throw new Error('Heap snapshot is missing required node fields');
}
const edgeTypeOffset = edgeFields.indexOf('type');
const edgeNameOffset = edgeFields.indexOf('name_or_index');
const edgeToNodeOffset = edgeFields.indexOf('to_node');
if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) {
throw new Error('Heap snapshot is missing required edge fields');
}
const nodeTypeNames = meta.node_types?.[typeOffset];
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
const edgeTypeNames = meta.edge_types?.[edgeTypeOffset];
if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types');
function createEmptyHeapSnapshotCategoryMap() {
return Object.fromEntries(Object.keys(heapSnapshotCategory).map(category => [category, 0])) as Record<keyof typeof heapSnapshotCategory, number>;
}
const nodeFieldCount = nodeFields.length;
const edgeFieldCount = edgeFields.length;
const nativeType = nodeTypeNames.indexOf('native');
const codeType = nodeTypeNames.indexOf('code');
const hiddenType = nodeTypeNames.indexOf('hidden');
const stringTypes = new Set([
nodeTypeNames.indexOf('string'),
nodeTypeNames.indexOf('concatenated string'),
nodeTypeNames.indexOf('sliced string'),
]);
const internalEdgeType = edgeTypeNames.indexOf('internal');
const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0;
const categories = createEmptyHeapSnapshotCategoryMap();
const nodeCounts = createEmptyHeapSnapshotCategoryMap();
const breakdowns = Object.fromEntries(
(Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[])
.filter(category => category !== 'total')
.map(category => [category, {}]),
);
function addValue(map: Record<string, number>, key: string, value: number) {
map[key] = (map[key] ?? 0) + value;
}
const edgeStartIndexes = new Map<number, number>();
const retainerCounts = new Map<number, number>();
let edgeIndex = 0;
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
edgeStartIndexes.set(nodeIndex, edgeIndex);
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) {
const toNodeIndex = edges[edgeIndex + edgeToNodeOffset];
retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1);
}
}
const jsArrayElementNodeIndexes = new Set<number>();
function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) {
if (value <= 0) return;
categories[category] += value;
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value);
if (nodeIndex != null) nodeCounts[category]++;
}
function addJsArrayElementSize(nodeIndex: number) {
const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0;
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) {
const edgeType = edges[currentEdgeIndex + edgeTypeOffset];
if (edgeType !== internalEdgeType) continue;
const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]];
if (edgeName !== 'elements') continue;
const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset];
if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) {
const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0;
addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex);
jsArrayElementNodeIndexes.add(elementsNodeIndex);
}
break;
}
}
if (extraNativeBytes > 0) {
addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes');
}
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
const typeId = nodes[nodeIndex + typeOffset];
const type = nodeTypeNames[typeId] ?? 'unknown';
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
categories.total += selfSize;
nodeCounts.total++;
if (typeId === hiddenType) {
addCategoryValue('systemObjects', selfSize, type, name, nodeIndex);
continue;
}
if (typeId === nativeType) {
if (name === 'system / JSArrayBufferData') {
addCategoryValue('typedArrays', selfSize, type, name, nodeIndex);
} else {
addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex);
}
continue;
}
if (typeId === codeType) {
addCategoryValue('code', selfSize, type, name, nodeIndex);
continue;
}
if (stringTypes.has(typeId)) {
addCategoryValue('strings', selfSize, type, name, nodeIndex);
continue;
}
if (name === 'Array') {
addCategoryValue('jsArrays', selfSize, type, name, nodeIndex);
addJsArrayElementSize(nodeIndex);
continue;
}
}
categories.total += extraNativeBytes;
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
if (jsArrayElementNodeIndexes.has(nodeIndex)) continue;
const typeId = nodes[nodeIndex + typeOffset];
if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue;
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
if (name === 'Array') continue;
const type = nodeTypeNames[typeId] ?? 'unknown';
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex);
}
return {
categories,
nodeCounts,
breakdowns: collapseHeapSnapshotBreakdown(breakdowns),
};
}
async function getMemoryUsage(pid: number) {
const path = `/proc/${pid}/status`;
const status = await fs.readFile(path, 'utf-8');
@@ -417,7 +177,7 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise<H
}
const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8'));
return analyzeHeapSnapshot(snapshot);
return analyzeHeapSnapshot(snapshot, { breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N });
} finally {
await fs.unlink(writtenPath).catch(err => {
process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`);

View File

@@ -61,6 +61,7 @@ it('Composition API (standard)', () => {
import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
import { M as MkContainer } from './MkContainer-!~{03M}~.js';
import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
import './photoswipe-!~{003}~.js';
const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
const _sfc_main = /* @__PURE__ */ defineComponent({
@@ -178,6 +179,7 @@ export { index_photos as default };
import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
import { M as MkContainer } from './MkContainer-!~{03M}~.js';
import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
import './photoswipe-!~{003}~.js';
const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
const index_photos = /* @__PURE__ */ defineComponent({

View File

@@ -56,6 +56,7 @@
"misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.4",
"punycode.js": "2.3.1",
"qr-code-styling": "1.9.2",
"qr-scanner": "1.4.2",

View File

@@ -1,183 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<canvas
v-show="show"
ref="canvas"
:width="canvasWidth"
:height="canvasHeight"
draggable="false"
tabindex="-1"
style="-webkit-user-drag: none;"
></canvas>
</template>
<script lang="ts">
import DrawBlurhash from '@/workers/draw-blurhash?worker';
import TestWebGL2 from '@/workers/test-webgl2?worker';
import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js';
// テスト環境で Web Worker インスタンスは作成できない
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const isTest = (import.meta.env.MODE === 'test' || window.isPlaywright);
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
if (isTest) {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
return;
}
const testWorker = new TestWebGL2();
testWorker.addEventListener('message', event => {
if (event.data.result) {
const workers = new WorkerMultiDispatch(
() => new DrawBlurhash(),
Math.min(navigator.hardwareConcurrency - 1, 4),
);
resolve(workers);
} else {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
}
testWorker.terminate();
});
});
</script>
<script lang="ts" setup>
import { watch, ref, shallowRef, useTemplateRef, onMounted, onUnmounted } from 'vue';
import { genId } from '@/utility/id.js';
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
const props = withDefaults(defineProps<{
blurhash: string | null;
onlyAvgColor?: boolean;
width?: number;
height?: number;
// v-showが何故か動作しないため
show?: boolean;
}>(), {
onlyAvgColor: false,
width: 64,
height: 64,
show: true,
});
const canvas = useTemplateRef('canvas');
const canvasWidth = ref(64);
const canvasHeight = ref(64);
const viewId = genId();
const bitmapTmp = shallowRef<CanvasImageSource | undefined>();
watch([() => props.width, () => props.height, canvas], () => {
const ratio = props.width / props.height;
if (ratio > 1) {
canvasWidth.value = Math.round(64 * ratio);
canvasHeight.value = 64;
} else {
canvasWidth.value = 64;
canvasHeight.value = Math.round(64 / ratio);
}
}, {
immediate: true,
});
watch(() => props.blurhash, () => {
draw();
});
function drawImage(bitmap: CanvasImageSource) {
// canvasがないmountedされていない場合はTmpに保存しておく
if (!canvas.value) {
bitmapTmp.value = bitmap;
return;
}
// canvasがあれば描画する
bitmapTmp.value = undefined;
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
}
function drawAvg() {
if (!canvas.value) return;
const color = (props.blurhash != null && extractAvgColorFromBlurhash(props.blurhash)) || '#888';
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
// avgColorでお茶をにごす
ctx.beginPath();
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
}
async function draw() {
if (isTest && props.blurhash == null) return;
drawAvg();
if (props.blurhash == null) return;
if (props.onlyAvgColor) return;
const work = await canvasPromise;
if (work instanceof WorkerMultiDispatch) {
work.postMessage(
{
id: viewId,
hash: props.blurhash,
},
undefined,
);
} else {
try {
const { render } = await import('buraha');
render(props.blurhash, work);
drawImage(work);
} catch (error) {
console.error('Error occurred during drawing blurhash', error);
}
}
}
function workerOnMessage(event: MessageEvent) {
if (event.data.id !== viewId) return;
drawImage(event.data.bitmap as ImageBitmap);
}
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.addListener(workerOnMessage);
}
draw();
});
onMounted(() => {
// drawImageがmountedより先に呼ばれている場合はここで描画する
if (bitmapTmp.value) {
drawImage(bitmapTmp.value);
}
});
onUnmounted(() => {
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.removeListener(workerOnMessage);
}
});
});
</script>

View File

@@ -1,39 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" class="_gaps_s">
<MkKeyValue v-if="content.filename != null">
<template #key>{{ i18n.ts.fileName }}</template>
<template #value>{{ content.filename }}</template>
</MkKeyValue>
<MkKeyValue v-if="content.file != null">
<template #key>{{ i18n.ts.description }}</template>
<template #value>
<div :class="$style.pre">{{ content.file.comment ? content.file.comment : `(${i18n.ts.none})` }}</div>
</template>
</MkKeyValue>
</div>
</template>
<script lang="ts" setup>
import MkKeyValue from '@/components/MkKeyValue.vue';
import { i18n } from '@/i18n.js';
import type { Content } from '@/components/MkImageGallery.item.vue';
const props = defineProps<{
content: Content;
}>();
</script>
<style lang="scss" module>
.root {
padding: 4px 14px;
}
.pre {
white-space: pre-wrap;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,303 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<!-- durationは子Itemコンポーネントがフェードイン/アウトするdurationと合わせる -->
<Transition
:enterActiveClass="prefer.s.animation ? $style.transition_root_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_root_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_root_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_root_leaveTo : ''"
:duration="{ enter: prefer.s.animation ? openAnimDuration : 0, leave: prefer.s.animation ? closeAnimDuration : 0 }"
appear
@afterLeave="onAfterLeave"
>
<!-- v-ifを使うとfalseになったとき(transitionが行われている間)子コンポーネントの更新が停止するのか子コンポーネントがアニメーションされなくなる -->
<div v-show="showing" ref="rootEl" v-hotkey.global="keymap" :class="$style.root" :style="{ zIndex }">
<div :class="[$style.bg]" class="_modalBg"></div>
<div ref="mainEl" :class="$style.main">
<div
ref="itemsEl"
:class="[$style.items, { [$style.itemsTransition]: enableSlideTransition }]"
:style="{ translate: `${contentsOffset}px 0` }"
@transitionend.self="onSlideTransitionFinished"
@transitioncancel.self="onSlideTransitionFinished"
>
<div v-for="(content, i) in contents" :key="content.url" ref="itemEl" :class="$style.item">
<XItem
:ref="(comp) => { items.set(i, comp as InstanceType<typeof XItem>); }"
:content="content"
:initiallyOpened="i === (props.defaultIndex ?? 0)"
:activated="activatedIndexes.has(i)"
@close="onItemClose"
@horizontalSwipe="onHorizontalSwipe"
@prev="onPrev"
@next="onNext"
@cancelHorizontalSwipe="onCancelHorizontalSwipe"
/>
</div>
</div>
<button v-if="!isTouchUsing && currentIndex > 0" class="_button" :class="[$style.prevButton]" @click="onPrev"><div :class="$style.buttonIcon"><i class="ti ti-arrow-left"></i></div></button>
<button v-if="!isTouchUsing && currentIndex < contents.length - 1" class="_button" :class="[$style.nextButton]" @click="onNext"><div :class="$style.buttonIcon"><i class="ti ti-arrow-right"></i></div></button>
</div>
</div>
</Transition>
</template>
<script lang="ts" setup>
import { ref, watch, nextTick, onBeforeUnmount, onMounted } from 'vue';
import XItem from './MkImageGallery.item.vue';
import type { Content } from './MkImageGallery.item.vue';
import type { Keymap } from '@/utility/hotkey.js';
import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
import { isTouchUsing } from '@/utility/touch.js';
const props = withDefaults(defineProps<{
defaultIndex?: number;
contents: Content[];
}>(), {
});
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const activatedIndexes = ref(new Set<number>());
const items = new Map<number, InstanceType<typeof XItem>>();
const currentIndex = ref(props.defaultIndex ?? 0);
watch(currentIndex, (newIndex, oldIndex) => {
activatedIndexes.value.add(newIndex);
nextTick(() => {
if (oldIndex != null && items.has(oldIndex)) {
items.get(oldIndex)!.onDeactive();
}
if (items.has(newIndex)) {
items.get(newIndex)!.onActive();
}
});
}, { immediate: true });
watch(currentIndex, (newIndex) => {
for (let i = 0; i < props.contents.length; i++) {
const content = props.contents[i];
if (content.sourceElement != null) {
content.sourceElement.style.visibility = i === newIndex ? 'hidden' : '';
}
}
}, { immediate: false });
const openAnimDuration = 200;
const closeAnimDuration = 200;
const slideAnimDuration = 300;
const zIndex = os.claimZIndex('high');
const showing = ref(true);
const screenWidth = ref(window.innerWidth);
const contentsOffset = ref(currentIndex.value * -window.innerWidth);
const enableSlideTransition = ref(false);
let currentScrollLeft = contentsOffset.value;
function onResize() {
screenWidth.value = window.innerWidth;
scrollToCurrentIndex();
}
window.addEventListener('resize', onResize, { passive: true });
function onHorizontalSwipe(offset: number) {
if (currentIndex.value === 0 && offset > 0) { // これ以上戻れない
contentsOffset.value = currentScrollLeft + (offset / 3);
} else if (currentIndex.value === props.contents.length - 1 && offset < 0) { // これ以上進めない
contentsOffset.value = currentScrollLeft + (offset / 3);
} else {
contentsOffset.value = currentScrollLeft + offset;
}
}
function scrollToCurrentIndex() {
const targetOffset = currentIndex.value * -screenWidth.value;
currentScrollLeft = targetOffset;
if (!prefer.s.animation || contentsOffset.value === targetOffset) {
enableSlideTransition.value = false;
contentsOffset.value = targetOffset;
return;
}
enableSlideTransition.value = true;
contentsOffset.value = targetOffset;
}
/** ギャラリーそのものを閉じるための内部処理(閉じる処理を書く場合は `close` か、item内では `closeThis` を使う) */
function closeGallery() {
showing.value = false;
if (window.location.hash === '#pswp') {
window.history.back();
}
}
function close() {
if (items.has(currentIndex.value)) {
items.get(currentIndex.value)!.closeThis();
} else {
closeGallery();
}
}
function onSlideTransitionFinished(ev: TransitionEvent) {
if (ev.propertyName !== 'translate') return;
enableSlideTransition.value = false;
}
function onCancelHorizontalSwipe() {
scrollToCurrentIndex();
}
function onNext() {
if (currentIndex.value < props.contents.length - 1) {
currentIndex.value++;
}
scrollToCurrentIndex();
}
function onPrev() {
if (currentIndex.value > 0) {
currentIndex.value--;
}
scrollToCurrentIndex();
}
function onItemClose() {
closeGallery();
}
function onAfterLeave() {
for (const content of props.contents) {
if (content.sourceElement != null) {
content.sourceElement.style.visibility = '';
}
}
emit('closed');
}
function onPopState() {
if (showing.value) {
close();
}
}
onMounted(() => {
window.history.pushState(null, '', '#pswp');
window.addEventListener('popstate', onPopState);
});
const keymap = {
'esc': {
allowRepeat: true,
callback: () => onItemClose(),
},
'arrowleft': {
allowRepeat: true,
callback: () => onPrev(),
},
'arrowright': {
allowRepeat: true,
callback: () => onNext(),
},
} as const satisfies Keymap;
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
window.removeEventListener('popstate', onPopState);
});
defineExpose({
close,
});
</script>
<style lang="scss" module>
.transition_root_enterActive,
.transition_root_leaveActive {
> .bg {
transition: opacity v-bind("closeAnimDuration + 'ms'"); // 子Itemコンポーネントがフェードイン/アウトするdurationと合わせる
}
}
.transition_root_enterFrom,
.transition_root_leaveTo {
pointer-events: none;
> .bg {
opacity: 0;
}
}
.root {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.bg {
}
.main {
position: absolute;
width: 100%;
height: 100%;
}
.items {
position: absolute;
display: flex;
width: calc(v-bind("screenWidth + 'px'") * v-bind("contents.length"));
height: 100dvh;
overflow: clip;
contain: strict;
}
.itemsTransition {
pointer-events: none;
transition: translate v-bind("slideAnimDuration + 'ms'") cubic-bezier(0.45, 0, 0.55, 1);
}
.item {
width: 100dvw;
height: 100dvh;
overflow: clip;
contain: strict;
flex-shrink: 0;
}
.prevButton,
.nextButton {
position: absolute;
top: 0;
width: 70px;
height: 100%;
display: grid;
place-items: center;
}
.prevButton {
left: 0;
}
.nextButton {
right: 0;
}
.buttonIcon {
width: 45px;
height: 45px;
display: grid;
place-items: center;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 100%;
color: #fff;
}
</style>

View File

@@ -14,15 +14,18 @@ SPDX-License-Identifier: AGPL-3.0-only
:enterToClass="prefer.s.animation && props.transition?.enterToClass || undefined"
:leaveFromClass="prefer.s.animation && props.transition?.leaveFromClass || undefined"
>
<MkBlurhash
<canvas
v-show="hide"
key="canvas"
ref="canvas"
:class="$style.canvas"
:blurhash="hash ?? null"
:height="imgHeight ?? undefined"
:width="imgWidth ?? undefined"
:onlyAvgColor="props.onlyAvgColor"
:show="hide"
/>
:width="canvasWidth"
:height="canvasHeight"
:title="title ?? undefined"
draggable="false"
tabindex="-1"
style="-webkit-user-drag: none;"
></canvas>
<img
v-show="!hide"
key="img"
@@ -33,7 +36,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:src="src ?? undefined"
:title="title ?? undefined"
:alt="alt ?? undefined"
:data-marker="marker ?? undefined"
loading="eager"
decoding="async"
draggable="false"
@@ -44,10 +46,50 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<script lang="ts">
import DrawBlurhash from '@/workers/draw-blurhash?worker';
import TestWebGL2 from '@/workers/test-webgl2?worker';
import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js';
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
// テスト環境で Web Worker インスタンスは作成できない
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const isTest = (import.meta.env.MODE === 'test' || window.isPlaywright);
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
if (isTest) {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
return;
}
const testWorker = new TestWebGL2();
testWorker.addEventListener('message', event => {
if (event.data.result) {
const workers = new WorkerMultiDispatch(
() => new DrawBlurhash(),
Math.min(navigator.hardwareConcurrency - 1, 4),
);
resolve(workers);
} else {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
}
testWorker.terminate();
});
});
</script>
<script lang="ts" setup>
import { computed, nextTick, useTemplateRef, watch, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue';
import { genId } from '@/utility/id.js';
import { render } from 'buraha';
import { prefer } from '@/preferences.js';
import MkBlurhash from '@/components/MkBlurhash.vue';
const props = withDefaults(defineProps<{
transition?: {
@@ -68,7 +110,6 @@ const props = withDefaults(defineProps<{
cover?: boolean;
forceBlurhash?: boolean;
onlyAvgColor?: boolean; // 軽量化のためにBlurhashを使わずに平均色だけを描画
marker?: string;
}>(), {
transition: null,
src: null,
@@ -81,11 +122,16 @@ const props = withDefaults(defineProps<{
onlyAvgColor: false,
});
const viewId = genId();
const canvas = useTemplateRef('canvas');
const root = useTemplateRef('root');
const img = useTemplateRef('img');
const loaded = ref(false);
const canvasWidth = ref(64);
const canvasHeight = ref(64);
const imgWidth = ref(props.width);
const imgHeight = ref(props.height);
const bitmapTmp = ref<CanvasImageSource | undefined>();
const hide = computed(() => !loaded.value || props.forceBlurhash);
function waitForDecode() {
@@ -104,6 +150,14 @@ function waitForDecode() {
watch([() => props.width, () => props.height, root], () => {
const ratio = props.width / props.height;
if (ratio > 1) {
canvasWidth.value = Math.round(64 * ratio);
canvasHeight.value = 64;
} else {
canvasWidth.value = 64;
canvasHeight.value = Math.round(64 / ratio);
}
const clientWidth = root.value?.clientWidth ?? 300;
imgWidth.value = clientWidth;
imgHeight.value = Math.round(clientWidth / ratio);
@@ -111,10 +165,97 @@ watch([() => props.width, () => props.height, root], () => {
immediate: true,
});
function drawImage(bitmap: CanvasImageSource) {
// canvasがないmountedされていない場合はTmpに保存しておく
if (!canvas.value) {
bitmapTmp.value = bitmap;
return;
}
// canvasがあれば描画する
bitmapTmp.value = undefined;
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
}
function drawAvg() {
if (!canvas.value) return;
const color = (props.hash != null && extractAvgColorFromBlurhash(props.hash)) || '#888';
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
// avgColorでお茶をにごす
ctx.beginPath();
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
}
async function draw() {
if (isTest && props.hash == null) return;
drawAvg();
if (props.hash == null) return;
if (props.onlyAvgColor) return;
const work = await canvasPromise;
if (work instanceof WorkerMultiDispatch) {
work.postMessage(
{
id: viewId,
hash: props.hash,
},
undefined,
);
} else {
try {
render(props.hash, work);
drawImage(work);
} catch (error) {
console.error('Error occurred during drawing blurhash', error);
}
}
}
function workerOnMessage(event: MessageEvent) {
if (event.data.id !== viewId) return;
drawImage(event.data.bitmap as ImageBitmap);
}
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.addListener(workerOnMessage);
}
draw();
});
watch(() => props.src, () => {
waitForDecode();
}, {
immediate: true,
});
watch(() => props.hash, () => {
draw();
});
onMounted(() => {
// drawImageがmountedより先に呼ばれている場合はここで描画する
if (bitmapTmp.value) {
drawImage(bitmapTmp.value);
}
waitForDecode();
});
onUnmounted(() => {
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.removeListener(workerOnMessage);
}
});
});
</script>

View File

@@ -49,8 +49,8 @@ SPDX-License-Identifier: AGPL-3.0-only
tabindex="-1"
@click.stop="togglePlayPause"
>
<i v-if="isPlaying" class="ti ti-player-pause"></i>
<i v-else class="ti ti-player-play"></i>
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
<i v-else class="ti ti-player-play-filled"></i>
</button>
</div>
<div :class="[$style.controlsChild, $style.controlsRight]">
@@ -100,7 +100,6 @@ import { hms } from '@/filters/hms.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
import { $i, iAmModerator } from '@/i.js';
import { prefer } from '@/preferences.js';
import { getFileMenu } from '@/utility/get-file-menu.js';
import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js';
const props = defineProps<{
@@ -209,11 +208,56 @@ function showMenu(ev: MouseEvent) {
{
type: 'divider',
},
{
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
hide.value = true;
},
},
];
menu.push(...getFileMenu(props.audio, (newState) => {
hide.value = newState;
}));
if (iAmModerator) {
menu.push({
text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: props.audio.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
danger: true,
action: () => toggleSensitive(props.audio),
});
}
const details: MenuItem[] = [];
if ($i?.id === props.audio.userId) {
details.push({
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.audio.id}`,
});
}
if (iAmModerator) {
details.push({
type: 'link',
text: i18n.ts.moderation,
icon: 'ti ti-photo-exclamation',
to: `/admin/file/${props.audio.id}`,
});
}
if (details.length > 0) {
menu.push({ type: 'divider' }, ...details);
}
if (prefer.s.devMode) {
menu.push({ type: 'divider' }, {
icon: 'ti ti-hash',
text: i18n.ts.copyFileId,
action: () => {
copyToClipboard(props.audio.id);
},
});
}
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
@@ -224,6 +268,20 @@ function showMenu(ev: MouseEvent) {
});
}
async function toggleSensitive(file: Misskey.entities.DriveFile) {
const { canceled } = await os.confirm({
type: 'warning',
text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
});
if (canceled) return;
os.apiWithDialog('drive/files/update', {
fileId: file.id,
isSensitive: !file.isSensitive,
});
}
// MediaControl: Common State
const oncePlayed = ref(false);
const isReady = ref(false);

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onClick" @contextmenu.stop="onContextmenu">
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="reveal" @contextmenu.stop="onContextmenu">
<component
:is="disableImageLink ? 'div' : 'a'"
v-bind="disableImageLink ? {
@@ -29,7 +29,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:height="image.properties.height"
:style="hide ? 'filter: brightness(0.7);' : null"
:class="$style.image"
:marker="marker"
/>
<div
v-else-if="prefer.s.dataSaver.media || hide"
@@ -43,7 +42,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:alt="image.comment || image.name"
:title="image.comment || image.name"
:class="$style.image"
:data-marker="marker"
/>
</component>
<template v-if="hide">
@@ -70,14 +68,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { watch, ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import type { MenuItem } from '@/types/menu.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard';
import { getStaticImageUrl } from '@/utility/media-proxy.js';
import bytes from '@/filters/bytes.js';
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { $i, iAmModerator } from '@/i.js';
import { prefer } from '@/preferences.js';
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
import { getFileMenu } from '@/utility/get-file-menu.js';
const props = withDefaults(defineProps<{
image: Misskey.entities.DriveFile;
@@ -85,17 +85,12 @@ const props = withDefaults(defineProps<{
cover?: boolean;
disableImageLink?: boolean;
controls?: boolean;
marker?: string;
}>(), {
cover: false,
disableImageLink: false,
controls: true,
});
const emit = defineEmits<{
(event: 'mediaClick', ev: PointerEvent): void;
}>();
const hide = ref(true);
const url = computed(() => (props.raw || prefer.s.loadRawImages)
@@ -105,9 +100,8 @@ const url = computed(() => (props.raw || prefer.s.loadRawImages)
: props.image.thumbnailUrl!,
);
async function onClick(ev: PointerEvent) {
async function reveal(ev: PointerEvent) {
if (!props.controls) {
emit('mediaClick', ev);
return;
}
@@ -118,8 +112,6 @@ async function onClick(ev: PointerEvent) {
}
hide.value = false;
} else {
emit('mediaClick', ev);
}
}
@@ -131,12 +123,80 @@ watch(() => props.image, (newImage) => {
immediate: true,
});
function getMenu() {
const menuItems: MenuItem[] = [];
menuItems.push({
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
hide.value = true;
},
});
if (iAmModerator) {
menuItems.push({
text: props.image.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: 'ti ti-eye-exclamation',
danger: true,
action: async () => {
const { canceled } = await os.confirm({
type: 'warning',
text: props.image.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
});
if (canceled) return;
os.apiWithDialog('drive/files/update', {
fileId: props.image.id,
isSensitive: !props.image.isSensitive,
});
},
});
}
const details: MenuItem[] = [];
if ($i?.id === props.image.userId) {
details.push({
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.image.id}`,
});
}
if (iAmModerator) {
details.push({
type: 'link',
text: i18n.ts.moderation,
icon: 'ti ti-photo-exclamation',
to: `/admin/file/${props.image.id}`,
});
}
if (details.length > 0) {
menuItems.push({ type: 'divider' }, ...details);
}
if (prefer.s.devMode) {
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-hash',
text: i18n.ts.copyFileId,
action: () => {
copyToClipboard(props.image.id);
},
});
}
return menuItems;
}
function showMenu(ev: PointerEvent) {
os.popupMenu(getFileMenu(props.image, (newHide) => { hide.value = newHide; }), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
function onContextmenu(ev: PointerEvent) {
os.contextMenu(getFileMenu(props.image, (newHide) => { hide.value = newHide; }), ev);
os.contextMenu(getMenu(), ev);
}
</script>

View File

@@ -20,23 +20,8 @@ SPDX-License-Identifier: AGPL-3.0-only
]"
>
<template v-for="media in mediaList.filter(media => previewable(media))">
<XVideo
v-if="media.type.startsWith('video')"
:key="`video:${media.id}`"
:class="$style.media"
:video="media"
@mediaClick="onMediaClick(media)"
/>
<XImage
v-else-if="media.type.startsWith('image')"
:key="`image:${media.id}`"
:marker="`${markerId}:${media.id}`"
:disableImageLink="true"
:class="$style.media"
:image="media"
:raw="raw"
@mediaClick="onMediaClick(media)"
/>
<XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.media" :video="media"/>
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.media" class="image" :data-id="media.id" :image="media" :raw="raw"/>
</template>
</div>
</div>
@@ -44,16 +29,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, markRaw, onMounted, onUnmounted, useTemplateRef } from 'vue';
import { computed, onMounted, onUnmounted, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';
import 'photoswipe/style.css';
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
import XBanner from '@/components/MkMediaBanner.vue';
import XImage from '@/components/MkMediaImage.vue';
import XVideo from '@/components/MkMediaVideo.vue';
import * as os from '@/os.js';
import { focusParent } from '@/utility/focus.js';
import { prefer } from '@/preferences.js';
import { genId } from '@/utility/id.js';
import type { Content } from '@/components/MkImageGallery.item.vue';
const props = defineProps<{
mediaList: Misskey.entities.DriveFile[];
@@ -61,8 +48,18 @@ const props = defineProps<{
}>();
const gallery = useTemplateRef('gallery');
const pswpZIndex = os.claimZIndex('middle');
window.document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
const markerId = genId();
let lightbox: PhotoSwipeLightbox | null = null;
let activeEl: HTMLElement | null = null;
const popstateHandler = (): void => {
if (lightbox?.pswp && lightbox.pswp.isOpen === true) {
lightbox.pswp.close();
}
};
async function calcAspectRatio() {
if (!gallery.value) return;
@@ -99,9 +96,121 @@ onMounted(() => {
calcAspectRatio();
if (gallery.value == null) return; // TSを黙らすため
lightbox = new PhotoSwipeLightbox({
dataSource: props.mediaList
.filter(media => {
if (media.type === 'image/svg+xml') return true; // svgのwebpublicはpngなのでtrue
return media.type.startsWith('image') && FILE_TYPE_BROWSERSAFE.includes(media.type);
})
.map(media => {
const item = {
src: media.url,
w: media.properties.width,
h: media.properties.height,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
alt: media.comment || media.name,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
comment: media.comment || media.name,
};
if (media.properties.orientation != null && media.properties.orientation >= 5) {
[item.w, item.h] = [item.h, item.w];
}
return item;
}),
gallery: gallery.value,
mainClass: 'pswp',
children: '.image',
thumbSelector: '.image',
loop: false,
padding: window.innerWidth > 500 ? {
top: 32,
bottom: 90,
left: 32,
right: 32,
} : {
top: 0,
bottom: 78,
left: 0,
right: 0,
},
imageClickAction: 'close',
tapAction: 'close',
bgOpacity: 1,
showAnimationDuration: 100,
hideAnimationDuration: 100,
returnFocus: false,
pswpModule: PhotoSwipe,
});
lightbox.addFilter('itemData', (itemData) => {
// element is children
const { element } = itemData;
const id = element?.dataset.id;
const file = props.mediaList.find(media => media.id === id);
if (!file) return itemData;
itemData.src = file.url;
itemData.w = Number(file.properties.width);
itemData.h = Number(file.properties.height);
if (file.properties.orientation != null && file.properties.orientation >= 5) {
[itemData.w, itemData.h] = [itemData.h, itemData.w];
}
itemData.msrc = file.thumbnailUrl ?? undefined;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
itemData.alt = file.comment || file.name;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
itemData.comment = file.comment || file.name;
itemData.thumbCropped = true;
return itemData;
});
lightbox.on('uiRegister', () => {
lightbox?.pswp?.ui?.registerElement({
name: 'altText',
className: 'pswp__alt-text-container',
appendTo: 'wrapper',
onInit: (el, pswp) => {
const textBox = window.document.createElement('p');
textBox.className = 'pswp__alt-text _acrylic';
el.appendChild(textBox);
pswp.on('change', () => {
textBox.textContent = pswp.currSlide?.data.comment;
});
},
});
});
lightbox.on('afterInit', () => {
activeEl = window.document.activeElement instanceof HTMLElement ? window.document.activeElement : null;
focusParent(activeEl, true, true);
lightbox?.pswp?.element?.focus({
preventScroll: true,
});
window.history.pushState(null, '', '#pswp');
});
lightbox.on('destroy', () => {
focusParent(activeEl, true, false);
activeEl = null;
if (window.location.hash === '#pswp') {
window.history.back();
}
});
window.addEventListener('popstate', popstateHandler);
lightbox.init();
});
onUnmounted(() => {
window.removeEventListener('popstate', popstateHandler);
lightbox?.destroy();
lightbox = null;
activeEl = null;
});
const previewable = (file: Misskey.entities.DriveFile): boolean => {
@@ -110,47 +219,11 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
};
function onMediaClick(file: Misskey.entities.DriveFile) {
if (prefer.s.imageNewTab) {
window.open(file.url, '_blank');
return;
const openGallery = () => {
if (props.mediaList.filter(media => previewable(media)).length > 0) {
lightbox?.loadAndOpen(0);
}
openGallery(file.id);
}
async function openGallery(id?: string) {
if (id == null) {
const firstImage = props.mediaList.find(media => previewable(media));
if (firstImage == null) return;
id = firstImage.id;
}
const getElementByMarker = (marker: string) => {
if (gallery.value == null) return null;
const found = gallery.value.querySelector(`[data-marker="${marker}"]`) as HTMLElement | null;
if (found == null) return null;
return markRaw(found);
};
const contents = props.mediaList.filter(media => previewable(media)).map<Content>(media => ({
id: media.id,
type: media.type.startsWith('video') ? 'video' : 'image',
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.properties.width ?? 0,
height: media.properties.height ?? 0,
filename: media.name,
file: media,
sourceElement: getElementByMarker(`${markerId}:${media.id}`),
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageGallery.vue').then(x => x.default), {
defaultIndex: contents.findIndex(conten => conten.id === id),
contents: contents,
}, {
closed: () => dispose(),
});
}
};
defineExpose({
openGallery,
@@ -243,7 +316,6 @@ defineExpose({
.media {
overflow: hidden; // clipにするとバグる
border-radius: 8px;
cursor: zoom-in;
}
@container (min-width: 500px) {
@@ -259,4 +331,42 @@ defineExpose({
}
}
}
:global(.pswp) {
--pswp-root-z-index: var(--mk-pswp-root-z-index, 2000700) !important;
--pswp-bg: var(--MI_THEME-modalBg) !important;
}
</style>
<style lang="scss">
.pswp__bg {
background: var(--MI_THEME-modalBg);
backdrop-filter: var(--MI-modalBgFilter);
}
.pswp__alt-text-container {
display: flex;
flex-direction: row;
align-items: center;
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
width: 75%;
max-width: 800px;
}
.pswp__alt-text {
color: var(--MI_THEME-fg);
margin: 0 auto;
text-align: center;
padding: var(--MI-margin);
border-radius: var(--MI-radius);
max-height: 8em;
overflow-y: auto;
text-shadow: var(--MI_THEME-bg) 0 0 10px, var(--MI_THEME-bg) 0 0 3px, var(--MI_THEME-bg) 0 0 3px;
white-space: pre-line;
}
</style>

View File

@@ -5,9 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- Media系専用のinput range -->
<template>
<div :class="$style.controlsSeekbar">
<progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress>
<input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/>
<div :style="sliderBgWhite ? '--sliderBg: rgba(255,255,255,.25);' : '--sliderBg: var(--MI_THEME-scrollbarHandle);'">
<div :class="$style.controlsSeekbar">
<progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress>
<input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/>
</div>
</div>
</template>
@@ -16,8 +18,10 @@ import { computed } from 'vue';
withDefaults(defineProps<{
buffer?: number;
sliderBgWhite?: boolean;
}>(), {
buffer: undefined,
sliderBgWhite: false,
});
const emit = defineEmits<{
@@ -34,8 +38,6 @@ const modelValue = computed({
<style lang="scss" module>
.controlsSeekbar {
position: relative;
--sliderBg: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15));
--thumbSize: 17px;
}
.seek {
@@ -47,7 +49,7 @@ const modelValue = computed({
border-radius: 26px;
color: var(--MI_THEME-accent);
display: block;
height: 24px;
height: 19px;
margin: 0;
min-width: 0;
padding: 0;
@@ -81,11 +83,11 @@ const modelValue = computed({
border: 0;
border-radius: 100%;
box-shadow: 0 1px 1px rgba(35, 40, 47, .15),0 0 0 1px rgba(35, 40, 47, .2);
height: var(--thumbSize);
margin-top: calc((5px - var(--thumbSize)) / 2);
height: 13px;
margin-top: -4px;
position: relative;
transition: all .2s ease;
width: var(--thumbSize);
width: 13px;
&:active {
box-shadow: 0 1px 1px rgba(35, 40, 47, .15), 0 0 0 1px rgba(35, 40, 47, .15), 0 0 0 3px rgba(255, 255, 255, .5);
@@ -97,10 +99,10 @@ const modelValue = computed({
border: 0;
border-radius: 100%;
box-shadow: 0 1px 1px rgba(35, 40, 47, .15),0 0 0 1px rgba(35, 40, 47, .2);
height: var(--thumbSize);
height: 13px;
position: relative;
transition: all .2s ease;
width: var(--thumbSize);
width: 13px;
&:active {
box-shadow: 0 1px 1px rgba(35, 40, 47, .15), 0 0 0 1px rgba(35, 40, 47, .15), 0 0 0 3px rgba(255, 255, 255, .5);
@@ -117,7 +119,7 @@ const modelValue = computed({
.buffer {
appearance: none;
background: transparent;
color: color(from var(--MI_THEME-accent) srgb r g b / 0.25);
color: var(--sliderBg);
border: 0;
border-radius: 99rem;
height: 5px;

View File

@@ -6,12 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
ref="playerEl"
v-hotkey="keymap"
tabindex="0"
:class="[
$style.root,
$style.videoContainer,
controlsShowing && $style.active,
(video.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
]"
@contextmenu.stop="onContextmenu"
@mouseover.passive="onMouseOver"
@mousemove.passive="onMouseMove"
@mouseleave.passive="onMouseLeave"
@contextmenu.stop
@keydown.stop
>
<button v-if="hide" :class="$style.hidden" @click="reveal">
@@ -22,49 +27,154 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</button>
<div v-else :class="$style.videoRoot" @click="emit('mediaClick', $event)">
<img
v-if="video.thumbnailUrl"
:class="$style.video"
:src="video.thumbnailUrl"
:alt="video.comment ?? undefined"
/>
<div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.videoRoot">
<video
v-else
ref="videoEl"
:class="$style.video"
:poster="video.thumbnailUrl ?? undefined"
:title="video.comment ?? undefined"
:alt="video.comment"
preload="metadata"
controls
@keydown.prevent
>
<source :src="video.url">
</video>
<div :class="$style.playIconWrapper">
<div :class="$style.playIcon">
<i class="ti ti-player-play"></i>
</div>
<i class="ti ti-eye-off" :class="$style.hide" @click="hide = true"></i>
<div :class="$style.indicators">
<div v-if="video.comment" :class="$style.indicator">ALT</div>
<div v-if="video.isSensitive" :class="$style.indicator" style="color: var(--MI_THEME-warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
</div>
</div>
<div v-else :class="$style.videoRoot">
<video
ref="videoEl"
:class="$style.video"
:poster="video.thumbnailUrl ?? undefined"
:title="video.comment ?? undefined"
:alt="video.comment"
preload="metadata"
playsinline
@keydown.prevent
@click.self="togglePlayPause"
>
<source :src="video.url">
</video>
<button v-if="isReady && !isPlaying" class="_button" :class="$style.videoOverlayPlayButton" @click="togglePlayPause"><i class="ti ti-player-play-filled"></i></button>
<div v-else-if="!isActuallyPlaying" :class="$style.videoLoading">
<MkLoading/>
</div>
<i class="ti ti-eye-off" :class="$style.hide" @click="hide = true"></i>
<div :class="$style.indicators">
<div v-if="video.comment" :class="$style.indicator">ALT</div>
<div v-if="video.isSensitive" :class="$style.indicator" style="color: var(--MI_THEME-warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
</div>
<div :class="$style.videoControls" @click.self="togglePlayPause">
<div :class="[$style.controlsChild, $style.controlsLeft]">
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
<i v-else class="ti ti-player-play-filled"></i>
</button>
</div>
<div :class="[$style.controlsChild, $style.controlsRight]">
<button class="_button" :class="$style.controlButton" @click="showMenu">
<i class="ti ti-settings"></i>
</button>
<button class="_button" :class="$style.controlButton" @click="toggleFullscreen">
<i v-if="isFullscreen" class="ti ti-arrows-minimize"></i>
<i v-else class="ti ti-arrows-maximize"></i>
</button>
</div>
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
<div :class="[$style.controlsChild, $style.controlsVolume]">
<button class="_button" :class="$style.controlButton" @click="toggleMute">
<i v-if="volume === 0" class="ti ti-volume-3"></i>
<i v-else class="ti ti-volume"></i>
</button>
<MkMediaRange
v-model="volume"
:sliderBgWhite="true"
:class="$style.volumeSeekbar"
/>
</div>
<MkMediaRange
v-model="rangePercent"
:sliderBgWhite="true"
:class="$style.seekbarRoot"
:buffer="bufferedDataRatio"
/>
</div>
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;"></i></button>
<i class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import type { MenuItem } from '@/types/menu.js';
import type { Keymap } from '@/utility/hotkey.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard';
import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import * as os from '@/os.js';
import { getFileMenu } from '@/utility/get-file-menu.js';
import { exitFullscreen, requestFullscreen } from '@/utility/fullscreen.js';
import hasAudio from '@/utility/media-has-audio.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
import { $i, iAmModerator } from '@/i.js';
import { prefer } from '@/preferences.js';
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
const props = defineProps<{
video: Misskey.entities.DriveFile;
}>();
const emit = defineEmits<{
(event: 'mediaClick', ev: PointerEvent): void;
}>();
const keymap = {
'up': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.min(volume.value + 0.1, 1);
}
},
},
'down': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.max(volume.value - 0.1, 0);
}
},
},
'left': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
}
},
},
'right': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
}
},
},
'space': () => {
if (hasFocus()) {
togglePlayPause();
}
},
} as const satisfies Keymap;
// PlayerElもしくはその子要素にフォーカスがあるかどうか
function hasFocus() {
if (!playerEl.value) return false;
return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement);
}
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const hide = ref(shouldHideFileByDefault(props.video));
@@ -77,17 +187,365 @@ async function reveal() {
hide.value = false;
}
// Menu
const menuShowing = ref(false);
function showMenu(ev: PointerEvent) {
os.popupMenu(getFileMenu(props.video, (newHide) => { hide.value = newHide; }), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
const menu: MenuItem[] = [
// TODO: 再生キューに追加
{
type: 'switch',
text: i18n.ts._mediaControls.loop,
icon: 'ti ti-repeat',
ref: loop,
},
{
type: 'radio',
text: i18n.ts._mediaControls.playbackRate,
icon: 'ti ti-clock-play',
ref: speed,
options: [{
label: '0.25x',
value: 0.25,
}, {
label: '0.5x',
value: 0.5,
}, {
label: '0.75x',
value: 0.75,
}, {
label: '1.0x',
value: 1,
}, {
label: '1.25x',
value: 1.25,
}, {
label: '1.5x',
value: 1.5,
}, {
label: '2.0x',
value: 2,
}],
},
...(window.document.pictureInPictureEnabled ? [{
text: i18n.ts._mediaControls.pip,
icon: 'ti ti-picture-in-picture',
action: togglePictureInPicture,
}] : []),
{
type: 'divider',
},
{
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
hide.value = true;
},
},
];
if (iAmModerator) {
menu.push({
text: props.video.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: props.video.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
danger: true,
action: () => toggleSensitive(props.video),
});
}
const details: MenuItem[] = [];
if ($i?.id === props.video.userId) {
details.push({
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.video.id}`,
});
}
if (iAmModerator) {
details.push({
type: 'link',
text: i18n.ts.moderation,
icon: 'ti ti-photo-exclamation',
to: `/admin/file/${props.video.id}`,
});
}
if (details.length > 0) {
menu.push({ type: 'divider' }, ...details);
}
if (prefer.s.devMode) {
menu.push({ type: 'divider' }, {
icon: 'ti ti-hash',
text: i18n.ts.copyFileId,
action: () => {
copyToClipboard(props.video.id);
},
});
}
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
align: 'right',
onClosing: () => {
menuShowing.value = false;
},
});
}
function onContextmenu(ev: PointerEvent) {
os.contextMenu(getFileMenu(props.video, (newHide) => { hide.value = newHide; }), ev);
async function toggleSensitive(file: Misskey.entities.DriveFile) {
const { canceled } = await os.confirm({
type: 'warning',
text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
});
if (canceled) return;
os.apiWithDialog('drive/files/update', {
fileId: file.id,
isSensitive: !file.isSensitive,
});
}
// MediaControl: Video State
const videoEl = useTemplateRef('videoEl');
const playerEl = useTemplateRef('playerEl');
const isHoverring = ref(false);
const controlsShowing = computed(() => {
if (!oncePlayed.value) return true;
if (isHoverring.value) return true;
if (menuShowing.value) return true;
return false;
});
const isFullscreen = ref(false);
let controlStateTimer: number | null = null;
// MediaControl: Common State
const oncePlayed = ref(false);
const isReady = ref(false);
const isPlaying = ref(false);
const isActuallyPlaying = ref(false);
const elapsedTimeMs = ref(0);
const durationMs = ref(0);
const rangePercent = computed({
get: () => {
return (elapsedTimeMs.value / durationMs.value) || 0;
},
set: (to) => {
if (!videoEl.value) return;
videoEl.value.currentTime = to * durationMs.value / 1000;
},
});
const volume = ref(.25);
const speed = ref(1);
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
const bufferedEnd = ref(0);
const bufferedDataRatio = computed(() => {
if (!videoEl.value) return 0;
return bufferedEnd.value / videoEl.value.duration;
});
// MediaControl Events
function onMouseOver() {
if (controlStateTimer) {
window.clearTimeout(controlStateTimer);
}
isHoverring.value = true;
controlStateTimer = window.setTimeout(() => {
isHoverring.value = false;
}, 3000);
}
function onMouseMove() {
if (controlStateTimer) {
window.clearTimeout(controlStateTimer);
}
isHoverring.value = true;
controlStateTimer = window.setTimeout(() => {
isHoverring.value = false;
}, 3000);
}
function onMouseLeave() {
if (controlStateTimer) {
window.clearTimeout(controlStateTimer);
}
controlStateTimer = window.setTimeout(() => {
isHoverring.value = false;
}, 100);
}
function togglePlayPause() {
if (!isReady.value || !videoEl.value) return;
if (isPlaying.value) {
videoEl.value.pause();
isPlaying.value = false;
} else {
videoEl.value.play();
isPlaying.value = true;
oncePlayed.value = true;
}
}
function toggleFullscreen() {
if (playerEl.value == null || videoEl.value == null) return;
if (isFullscreen.value) {
exitFullscreen({
videoEl: videoEl.value,
});
isFullscreen.value = false;
} else {
requestFullscreen({
videoEl: videoEl.value,
playerEl: playerEl.value,
options: {
navigationUI: 'hide',
},
});
isFullscreen.value = true;
}
}
function togglePictureInPicture() {
if (videoEl.value) {
if (window.document.pictureInPictureElement) {
window.document.exitPictureInPicture();
} else {
videoEl.value.requestPictureInPicture();
}
}
}
function toggleMute() {
if (volume.value === 0) {
volume.value = .25;
} else {
volume.value = 0;
}
}
let onceInit = false;
let mediaTickFrameId: number | null = null;
let stopVideoElWatch: () => void;
function init() {
if (onceInit) return;
onceInit = true;
stopVideoElWatch = watch(videoEl, () => {
if (videoEl.value) {
isReady.value = true;
function updateMediaTick() {
if (videoEl.value) {
try {
bufferedEnd.value = videoEl.value.buffered.end(0);
} catch (err) {
bufferedEnd.value = 0;
}
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
if (videoEl.value.loop !== loop.value) {
loop.value = videoEl.value.loop;
}
}
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
}
updateMediaTick();
videoEl.value.addEventListener('play', () => {
isActuallyPlaying.value = true;
});
videoEl.value.addEventListener('pause', () => {
isActuallyPlaying.value = false;
isPlaying.value = false;
});
videoEl.value.addEventListener('ended', () => {
oncePlayed.value = false;
isActuallyPlaying.value = false;
isPlaying.value = false;
});
durationMs.value = videoEl.value.duration * 1000;
videoEl.value.addEventListener('durationchange', () => {
if (videoEl.value) {
durationMs.value = videoEl.value.duration * 1000;
}
});
videoEl.value.volume = volume.value;
hasAudio(videoEl.value).then(had => {
if (!had && videoEl.value) {
videoEl.value.loop = videoEl.value.muted = true;
videoEl.value.play();
}
});
}
}, {
immediate: true,
});
}
watch(volume, (to) => {
if (videoEl.value) videoEl.value.volume = to;
});
watch(speed, (to) => {
if (videoEl.value) videoEl.value.playbackRate = to;
});
watch(loop, (to) => {
if (videoEl.value) videoEl.value.loop = to;
});
watch(hide, (to) => {
if (videoEl.value && to && isFullscreen.value) {
exitFullscreen({
videoEl: videoEl.value,
});
isFullscreen.value = false;
}
});
onMounted(() => {
init();
});
onActivated(() => {
init();
});
onDeactivated(() => {
isReady.value = false;
isPlaying.value = false;
isActuallyPlaying.value = false;
elapsedTimeMs.value = 0;
durationMs.value = 0;
bufferedEnd.value = 0;
hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore');
stopVideoElWatch();
onceInit = false;
if (mediaTickFrameId) {
window.cancelAnimationFrame(mediaTickFrameId);
mediaTickFrameId = null;
}
if (controlStateTimer) {
window.clearTimeout(controlStateTimer);
controlStateTimer = null;
}
});
</script>
<style lang="scss" module>
.root {
.videoContainer {
container-type: inline-size;
position: relative;
overflow: clip;
@@ -95,12 +553,6 @@ function onContextmenu(ev: PointerEvent) {
&:focus-visible {
outline: none;
}
&:hover {
.playIcon {
scale: 1.2;
}
}
}
.sensitive {
@@ -119,6 +571,42 @@ function onContextmenu(ev: PointerEvent) {
}
}
.indicators {
display: inline-flex;
position: absolute;
top: 10px;
left: 10px;
pointer-events: none;
opacity: .5;
gap: 6px;
}
.indicator {
/* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */
background-color: black;
border-radius: 6px;
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
display: inline-block;
font-weight: bold;
font-size: 0.8em;
padding: 2px 5px;
}
.hide {
display: block;
position: absolute;
border-radius: 6px;
background-color: var(--MI_THEME-fg);
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
font-size: 12px;
opacity: .5;
padding: 5px 8px;
text-align: center;
cursor: pointer;
top: 12px;
right: 12px;
}
.hidden {
width: 100%;
height: 100%;
@@ -128,7 +616,7 @@ function onContextmenu(ev: PointerEvent) {
font: inherit;
color: inherit;
cursor: pointer;
padding: 12px 0;
padding: 60px 0;
display: flex;
align-items: center;
justify-content: center;
@@ -152,62 +640,154 @@ function onContextmenu(ev: PointerEvent) {
display: block;
height: 100%;
width: 100%;
object-fit: contain;
}
.playIconWrapper {
.videoOverlayPlayButton {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
opacity: 0;
transition: opacity .4s ease-in-out;
background: var(--MI_THEME-accent);
color: #fff;
padding: 1rem;
border-radius: 99rem;
font-size: 1.1rem;
&:focus-visible {
outline: none;
}
}
.videoLoading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: grid;
place-items: center;
display: flex;
align-items: center;
justify-content: center;
}
.playIcon {
.videoControls {
display: grid;
place-items: center;
width: 50px;
height: 50px;
border-radius: 100%;
font-size: 120%;
background: var(--MI_THEME-accent);
color: var(--MI_THEME-fgOnAccent);
scale: 1;
transition: scale 100ms ease;
}
grid-template-areas:
"left time . volume right"
"seekbar seekbar seekbar seekbar seekbar";
grid-template-columns: auto auto 1fr auto auto;
align-items: center;
gap: 4px 8px;
padding: 35px 10px 10px 10px;
background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, .75));
.menu {
display: block;
position: absolute;
background-color: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
border-radius: 9px 0 0 0;
color: #fff;
font-size: 0.8em;
width: 28px;
height: 28px;
text-align: center;
left: 0;
right: 0;
bottom: 0;
right: 0;
transform: translateY(100%);
pointer-events: none;
opacity: 0;
transition: opacity .4s ease-in-out, transform .4s ease-in-out;
}
.hide {
display: block;
position: absolute;
background-color: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
border-radius: 0 0 0 9px;
.active {
.videoControls {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.videoOverlayPlayButton {
opacity: 1;
}
}
.controlsChild {
display: flex;
align-items: center;
gap: 4px;
color: #fff;
font-size: 12px;
opacity: .5;
padding: 5px 8px;
text-align: center;
cursor: pointer;
top: 0;
right: 0;
.controlButton {
padding: 6px;
border-radius: calc(var(--MI-radius) / 2);
transition: background-color .15s ease;
font-size: 1.05rem;
&:hover {
background-color: var(--MI_THEME-accent);
}
&:focus-visible {
outline: none;
}
}
}
.controlsLeft {
grid-area: left;
}
.controlsRight {
grid-area: right;
}
.controlsTime {
grid-area: time;
font-size: .9rem;
}
.controlsVolume {
grid-area: volume;
.volumeSeekbar {
display: none;
}
}
.seekbarRoot {
grid-area: seekbar;
/* ▼シークバー操作をやりやすくするためにクリックイベントが伝播されないエリアを拡張する */
margin: -10px;
padding: 10px;
}
@container (min-width: 500px) {
.videoControls {
grid-template-areas: "left seekbar time volume right";
grid-template-columns: auto 1fr auto auto auto;
}
.controlsVolume {
.volumeSeekbar {
max-width: 90px;
display: block;
flex-grow: 1;
}
}
}
@container (max-width: 300px) {
.videoControls {
grid-template-areas:
"left . right"
"seekbar seekbar seekbar";
grid-template-columns: auto 1fr auto;
}
.controlsTime {
display: none;
}
.controlsVolume {
display: none;
}
}
</style>

View File

@@ -1,310 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div :class="[$style.seekbar]">
<MkMediaRange
v-model="rangePercent"
:buffer="bufferedDataRatio"
/>
</div>
<div :class="[$style.controlsChild, $style.controlsLeft]">
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
<i v-if="isPlaying" class="ti ti-player-pause"></i>
<i v-else class="ti ti-player-play"></i>
</button>
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }} / {{ hms(durationMs) }}</div>
</div>
<div :class="[$style.controlsChild, $style.controlsCenter]">
</div>
<div :class="[$style.controlsChild, $style.controlsRight]">
<button class="_button" :class="$style.controlButton" @click="toggleMute">
<i v-if="volume === 0" class="ti ti-volume-3"></i>
<i v-else class="ti ti-volume"></i>
</button>
<MkMediaRange
v-model="volume"
:class="$style.volumeSeekbar"
/>
<button class="_button" :class="$style.controlButton" @click="showMenu">
<i class="ti ti-settings"></i>
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, shallowRef, inject, computed, watch, onBeforeUnmount } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import { DI } from '@/di.js';
import { hms } from '@/filters/hms.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import hasAudio from '@/utility/media-has-audio.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
const videoEl = inject(DI.mkImageGalleryItemVideoEl, shallowRef<HTMLVideoElement | null>(null));
// Menu
const menuShowing = ref(false);
function showMenu(ev: PointerEvent) {
const menu: MenuItem[] = [
// TODO: 再生キューに追加
{
type: 'switch',
text: i18n.ts._mediaControls.loop,
icon: 'ti ti-repeat',
ref: loop,
},
{
type: 'radio',
text: i18n.ts._mediaControls.playbackRate,
icon: 'ti ti-clock-play',
ref: speed,
options: [{
label: '0.25x',
value: 0.25,
}, {
label: '0.5x',
value: 0.5,
}, {
label: '0.75x',
value: 0.75,
}, {
label: '1.0x',
value: 1,
}, {
label: '1.25x',
value: 1.25,
}, {
label: '1.5x',
value: 1.5,
}, {
label: '2.0x',
value: 2,
}],
},
...(window.document.pictureInPictureEnabled ? [{
text: i18n.ts._mediaControls.pip,
icon: 'ti ti-picture-in-picture',
action: togglePictureInPicture,
}] : []),
];
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
align: 'right',
onClosing: () => {
menuShowing.value = false;
},
});
}
// MediaControl: Common State
const oncePlayed = ref(false);
const isReady = ref(false);
const isPlaying = ref(false);
const isActuallyPlaying = ref(false);
const elapsedTimeMs = ref(0);
const durationMs = ref(0);
const rangePercent = computed({
get: () => {
return (elapsedTimeMs.value / durationMs.value) || 0;
},
set: (to) => {
if (videoEl.value == null) return;
videoEl.value.currentTime = to * durationMs.value / 1000;
},
});
const volume = ref(.25);
const speed = ref(1);
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
const bufferedEnd = ref(0);
const bufferedDataRatio = computed(() => {
if (videoEl.value == null || videoEl.value.duration === 0) return 0;
return bufferedEnd.value / videoEl.value.duration;
});
function togglePlayPause() {
if (!isReady.value) return;
if (isPlaying.value) {
videoEl.value?.pause();
isPlaying.value = false;
} else {
videoEl.value?.play();
isPlaying.value = true;
oncePlayed.value = true;
}
}
function togglePictureInPicture() {
if (window.document.pictureInPictureElement) {
window.document.exitPictureInPicture();
} else {
videoEl.value?.requestPictureInPicture();
}
}
function toggleMute() {
if (volume.value === 0) {
volume.value = .25;
} else {
volume.value = 0;
}
}
let abortController: AbortController | null = null;
let mediaTickFrameId: number | null = null;
function init() {
if (videoEl.value == null) return;
isReady.value = true;
abortController = new AbortController();
function updateMediaTick() {
if (videoEl.value == null) return;
try {
bufferedEnd.value = videoEl.value.buffered.end(0);
} catch (err) {
bufferedEnd.value = 0;
}
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
if (videoEl.value.loop !== loop.value) {
loop.value = videoEl.value.loop;
}
if (videoEl.value.paused !== !isPlaying.value) {
isPlaying.value = !videoEl.value.paused;
}
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
}
updateMediaTick();
videoEl.value.addEventListener('play', () => {
isActuallyPlaying.value = true;
}, { signal: abortController.signal });
videoEl.value.addEventListener('pause', () => {
isActuallyPlaying.value = false;
isPlaying.value = false;
}, { signal: abortController.signal });
videoEl.value.addEventListener('ended', () => {
oncePlayed.value = false;
isActuallyPlaying.value = false;
isPlaying.value = false;
}, { signal: abortController.signal });
durationMs.value = videoEl.value.duration * 1000;
videoEl.value.addEventListener('durationchange', () => {
durationMs.value = videoEl.value!.duration * 1000;
}, { signal: abortController.signal });
videoEl.value.volume = volume.value;
hasAudio(videoEl.value).then(had => {
if (!had) {
videoEl.value!.loop = videoEl.value!.muted = true;
videoEl.value!.play();
}
});
}
watch(volume, (to) => {
if (videoEl.value == null) return;
videoEl.value.volume = to;
});
watch(speed, (to) => {
if (videoEl.value == null) return;
videoEl.value.playbackRate = to;
});
watch(loop, (to) => {
if (videoEl.value == null) return;
videoEl.value.loop = to;
});
watch(videoEl, () => {
if (abortController != null) {
abortController.abort();
}
init();
}, { immediate: true });
onBeforeUnmount(() => {
if (mediaTickFrameId != null) {
window.cancelAnimationFrame(mediaTickFrameId);
}
});
defineExpose({
isActuallyPlaying,
});
</script>
<style lang="scss" module>
.root {
display: grid;
grid-template-areas:
"seekbar seekbar seekbar"
"left center right";
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 4px 8px;
width: 100%;
}
.controlsChild {
display: flex;
align-items: center;
gap: 4px;
}
.controlsLeft {
grid-area: left;
}
.controlsRight {
grid-area: right;
}
.controlsCenter {
grid-area: center;
justify-content: center;
}
.controlButton {
padding: 6px;
border-radius: 4px;
&:hover {
background-color: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
}
&:focus-visible {
outline: none;
}
}
.controlsTime {
font-size: 85%;
}
.seekbar {
grid-area: seekbar;
}
</style>

View File

@@ -19,5 +19,4 @@ export const DI = {
inModal: Symbol() as InjectionKey<boolean>,
inAppSearchMarkerId: Symbol() as InjectionKey<Ref<string | null>>,
inChannel: Symbol() as InjectionKey<ComputedRef<string | null> | null>, // 現在開いているチャンネルのID
mkImageGalleryItemVideoEl: Symbol() as InjectionKey<Ref<HTMLVideoElement | null>>,
};

View File

@@ -1,56 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function makeDoubleTapDetector(onDoubletap: (event: TouchEvent) => void) {
const positionThreshold = 10; // px
const durationThreshold = 300; // ms
let lastTapTime = 0;
let lastTapPosition = { x: 0, y: 0 };
function onTouchstart(ev: TouchEvent) {
if (ev.touches.length !== 1) return;
const currentTime = new Date().getTime();
const tapLength = currentTime - lastTapTime;
const positionDelta = Math.max(
Math.abs(ev.touches[0].clientX - lastTapPosition.x),
Math.abs(ev.touches[0].clientY - lastTapPosition.y),
);
if (tapLength < durationThreshold && tapLength > 0 && positionDelta < positionThreshold) { // ダブルタップ
onDoubletap(ev);
lastTapTime = 0;
return;
}
lastTapTime = currentTime;
lastTapPosition = {
x: ev.touches[0].clientX,
y: ev.touches[0].clientY,
};
}
function onTouchmove(ev: TouchEvent) {
const positionDelta = Math.max(
Math.abs(ev.touches[0].clientX - lastTapPosition.x),
Math.abs(ev.touches[0].clientY - lastTapPosition.y),
);
if (positionDelta > positionThreshold) {
lastTapTime = 0;
}
}
function reset() {
lastTapTime = 0;
lastTapPosition = { x: 0, y: 0 };
}
return {
onTouchstart,
onTouchmove,
reset,
};
}

View File

@@ -97,7 +97,6 @@ async function deleteFile(file: Misskey.entities.DriveFile) {
globalEvents.emit('driveFilesDeleted', [file]);
}
/** 自分のドライブファイルを操作する際のメニュー */
export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] {
const _isImage = file.type.startsWith('image/');

View File

@@ -1,83 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
import { $i, iAmModerator } from '@/i.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import * as os from '@/os.js';
import type { MenuItem } from '@/types/menu.js';
/** 添付ファイルなど、公開ファイル用のメニュー */
export function getFileMenu(file: Misskey.entities.DriveFile, onHideStateUpdated?: (newState: boolean) => void): MenuItem[] {
const menuItems: MenuItem[] = [];
if (onHideStateUpdated != null) {
menuItems.push({
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
onHideStateUpdated(true);
},
});
}
if (iAmModerator) {
menuItems.push({
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: 'ti ti-eye-exclamation',
danger: true,
action: async () => {
const { canceled } = await os.confirm({
type: 'warning',
text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
});
if (canceled) return;
os.apiWithDialog('drive/files/update', {
fileId: file.id,
isSensitive: !file.isSensitive,
});
},
});
}
const details: MenuItem[] = [];
if ($i?.id === file.userId) {
details.push({
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${file.id}`,
});
}
if (iAmModerator) {
details.push({
type: 'link',
text: i18n.ts.moderation,
icon: 'ti ti-photo-exclamation',
to: `/admin/file/${file.id}`,
});
}
if (details.length > 0) {
menuItems.push({ type: 'divider' }, ...details);
}
if (prefer.s.devMode) {
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-hash',
text: i18n.ts.copyFileId,
action: () => {
copyToClipboard(file.id);
},
});
}
return menuItems;
}

View File

@@ -53,6 +53,7 @@ let latestHotkey: Pattern & { callback: CallbackFunction } | null = null;
export const makeHotkey = (keymap: Keymap, ignoreElements = IGNORE_ELEMENTS) => {
const actions = parseKeymap(keymap);
return (ev: KeyboardEvent) => {
if ('pswp' in window && window.pswp != null) return;
if (window.document.activeElement != null) {
if (ignoreElements.includes(window.document.activeElement.tagName.toLowerCase())) return;
if (getHTMLElementOrNull(window.document.activeElement)?.isContentEditable) return;

View File

@@ -8,8 +8,8 @@ import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
import { i18n } from '@/i18n.js';
export function shouldHideFileByDefault(file: Misskey.entities.DriveFile, ignoreDataSaver = false): boolean {
if (prefer.s.nsfw === 'force' || (!ignoreDataSaver && prefer.s.dataSaver.media)) {
export function shouldHideFileByDefault(file: Misskey.entities.DriveFile): boolean {
if (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) {
return true;
}

View File

@@ -10,7 +10,7 @@ import {
// locator helper
locateMkInput, locateMkSwitch, locateMkTextarea,
// utils
registerUser, resetState, visitHome,
registerUser, resetState, visitHome, closeUserSetupDialog, postNote,
// page utils
waitApiResponse, signIn,
} from './utils.js';
@@ -194,17 +194,11 @@ test.describe('After user setup', () => {
await signIn(page, 'alice', 'alice1234');
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout: 30000 });
await page.getByTestId('modal-dialog-ok').click();
await closeUserSetupDialog(page);
});
test('note', async ({ page }) => {
await page.getByTestId('open-post-form').waitFor({ state: 'visible' });
await page.getByTestId('open-post-form').click();
await page.getByTestId('post-form-text').fill('Hello, Misskey!');
await page.getByTestId('post-form-submit').click();
await page.getByText('Hello, Misskey!').waitFor({ timeout: 15000 });
await postNote(page, 'Hello, Misskey!');
});
test('open note form with hotkey', async ({ page }) => {

View File

@@ -10,7 +10,7 @@ import {
// utils
resetState, registerUser,
// page utils
signIn,
signIn, closeUserSetupDialogIfVisible,
} from './utils.js';
test.describe('Router transition', () => {
@@ -26,10 +26,7 @@ test.describe('Router transition', () => {
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする。少し待つ
await page.waitForTimeout(1000);
if (await page.getByTestId('user-setup-dialog').isVisible()) {
await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click();
await page.getByTestId('modal-dialog-ok').click();
}
await closeUserSetupDialogIfVisible(page);
});
test.describe('Redirect', () => {

View File

@@ -0,0 +1,135 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Locator, Page } from 'playwright';
export const ADMIN_SETUP_PASSWORD = 'example_password_please_change_this_or_you_will_get_hacked';
export const DEFAULT_INVITATION_CODE = 'test-invitation-code';
export interface RegisteredUser {
id: string;
token: string;
}
export function assertOk(status: number, route: string): void {
if (status < 200 || status >= 300) {
throw new Error(`${route} failed: status=${status}`);
}
}
export async function api(baseUrl: string, endpoint: string, body: Record<string, unknown>) {
const response = await fetch(`${baseUrl}/api/${endpoint}`, {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
});
assertOk(response.status, `/api/${endpoint}`);
if (response.status === 204) return null;
return await response.json();
}
export async function resetState(baseUrl: string): Promise<void> {
await api(baseUrl, 'reset-db', {});
}
export async function registerUser(
baseUrl: string,
username: string,
password: string,
isAdmin = false,
): Promise<RegisteredUser> {
const route = isAdmin ? 'admin/accounts/create' : 'signup';
const result = await api(baseUrl, route, {
username,
password,
...(isAdmin ? { setupPassword: ADMIN_SETUP_PASSWORD } : {}),
});
return result as RegisteredUser;
}
export function locateMkInput(page: Page, testId: string): Locator {
return page.locator(`[data-testid="${testId}"] input`);
}
export function locateMkTextarea(page: Page, testId: string): Locator {
return page.locator(`[data-testid="${testId}"] textarea`);
}
export function locateMkSwitch(page: Page, testId: string): Locator {
return page.locator(`[data-testid="${testId}"] [data-testid="switch-toggle"]`);
}
export async function visitHome(page: Page, baseUrl: string): Promise<void> {
await page.goto(`${baseUrl}/`);
await page.locator('button').first().waitFor({ state: 'visible', timeout: 30_000 });
}
export async function waitApiResponse(page: Page, path: string, timeout = 30_000): Promise<void> {
await page.waitForResponse((response) => {
return response.url().endsWith(path) && response.request().method() === 'POST';
}, { timeout });
}
export async function signIn(page: Page, baseUrl: string, username: string, password: string): Promise<void> {
await visitHome(page, baseUrl);
await page.getByTestId('signin').click();
await page.getByTestId('signin-page-input').waitFor({ state: 'visible', timeout: 10_000 });
await locateMkInput(page, 'signin-username').fill(username);
await page.keyboard.press('Enter');
await page.getByTestId('signin-page-password').waitFor({ state: 'visible', timeout: 10_000 });
await locateMkInput(page, 'signin-password').fill(password);
const signinResponse = waitApiResponse(page, '/api/signin-flow');
await page.keyboard.press('Enter');
await signinResponse;
}
export async function acceptSignupRules(page: Page): Promise<void> {
await page.getByTestId('signup-rules-continue').waitFor({ state: 'visible' });
await locateMkSwitch(page, 'signup-rules-notes-agree').click();
await page.getByTestId('modal-dialog-ok').click();
await page.getByTestId('signup-rules-continue').click();
}
export async function signupThroughUi(
page: Page,
options: {
username: string;
password: string;
invitationCode?: string;
},
): Promise<void> {
await page.getByTestId('signup').click();
await acceptSignupRules(page);
await locateMkInput(page, 'signup-username').fill(options.username);
await locateMkInput(page, 'signup-password').fill(options.password);
await locateMkInput(page, 'signup-password-retype').fill(options.password);
await locateMkInput(page, 'signup-invitation-code').fill(options.invitationCode ?? DEFAULT_INVITATION_CODE);
const signupResponse = waitApiResponse(page, '/api/signup');
await page.getByTestId('signup-submit').click();
await signupResponse;
}
export async function closeUserSetupDialog(page: Page, timeout = 30_000): Promise<void> {
await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout });
await page.getByTestId('modal-dialog-ok').click();
}
export async function closeUserSetupDialogIfVisible(page: Page): Promise<void> {
if (await page.getByTestId('user-setup-dialog').isVisible()) {
await closeUserSetupDialog(page);
}
}
export async function postNote(page: Page, noteText: string, timeout = 15_000): Promise<void> {
await page.getByTestId('open-post-form').waitFor({ state: 'visible' });
await page.getByTestId('open-post-form').click();
await page.getByTestId('post-form-text').fill(noteText);
await page.getByTestId('post-form-submit').click();
await page.getByText(noteText).waitFor({ timeout });
}

View File

@@ -3,92 +3,50 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Locator, Page } from 'playwright';
import type { Page } from 'playwright';
import {
registerUser as registerUserWithBaseUrl,
resetState as resetStateWithBaseUrl,
signIn as signInWithBaseUrl,
visitHome as visitHomeWithBaseUrl,
} from './shared.js';
export type { RegisteredUser } from './shared.js';
export {
ADMIN_SETUP_PASSWORD,
DEFAULT_INVITATION_CODE,
acceptSignupRules,
assertOk,
closeUserSetupDialog,
closeUserSetupDialogIfVisible,
locateMkInput,
locateMkSwitch,
locateMkTextarea,
postNote,
waitApiResponse,
} from './shared.js';
export const BASE_URL = 'http://localhost:61812';
export const ADMIN_SETUP_PASSWORD = 'example_password_please_change_this_or_you_will_get_hacked';
export interface RegisteredUser {
id: string;
token: string;
}
//#region Misc
export function assertOk(status: number, route: string): void {
if (status < 200 || status >= 300) {
throw new Error(`${route} failed: status=${status}`);
}
}
export async function resetState(): Promise<void> {
const response = await fetch(`${BASE_URL}/api/reset-db`, {
method: 'POST',
body: '{}',
headers: {
'Content-Type': 'application/json',
},
});
assertOk(response.status, '/api/reset-db');
await resetStateWithBaseUrl(BASE_URL);
}
export async function registerUser(
username: string,
password: string,
isAdmin = false,
): Promise<RegisteredUser> {
const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup';
const response = await fetch(`${BASE_URL}${route}`, {
method: 'POST',
body: JSON.stringify({
username,
password,
...(isAdmin ? { setupPassword: ADMIN_SETUP_PASSWORD } : {}),
}),
headers: {
'Content-Type': 'application/json',
},
});
assertOk(response.status, route);
return await response.json() as RegisteredUser;
}
//#endregion
//#region Locator Helpers
export function locateMkInput(page: Page, testId: string): Locator {
return page.locator(`[data-testid="${testId}"] input`);
}
export function locateMkTextarea(page: Page, testId: string): Locator {
return page.locator(`[data-testid="${testId}"] textarea`);
}
export function locateMkSwitch(page: Page, testId: string): Locator {
return page.locator(`[data-testid="${testId}"] [data-testid="switch-toggle"]`);
): ReturnType<typeof registerUserWithBaseUrl> {
return registerUserWithBaseUrl(BASE_URL, username, password, isAdmin);
}
//#endregion
//#region Page Helpers
export async function visitHome(page: Page): Promise<void> {
await page.goto(`${BASE_URL}/`);
await page.locator('button').first().waitFor({ state: 'visible', timeout: 30_000 });
}
export async function waitApiResponse(page: Page, path: string): Promise<void> {
await page.waitForResponse((response) => {
return response.url().endsWith(path) && response.request().method() === 'POST';
}, { timeout: 30_000 });
await visitHomeWithBaseUrl(page, BASE_URL);
}
export async function signIn(page: Page, username: string, password: string): Promise<void> {
await visitHome(page);
await page.getByTestId('signin').click();
await page.getByTestId('signin-page-input').waitFor({ state: 'visible', timeout: 10_000 });
await locateMkInput(page, 'signin-username').fill(username);
await page.keyboard.press('Enter');
await page.getByTestId('signin-page-password').waitFor({ state: 'visible', timeout: 10_000 });
await locateMkInput(page, 'signin-password').fill(password);
const signinResponse = waitApiResponse(page, '/api/signin-flow');
await page.keyboard.press('Enter');
await signinResponse;
await signInWithBaseUrl(page, BASE_URL, username, password);
}
//#endregion

View File

@@ -220,6 +220,9 @@ export function getConfig(): UserConfig {
groups: [{
name: 'vue',
test: /node_modules[\\/]vue/,
}, {
name: 'photoswipe',
test: /node_modules[\\/]photoswipe/,
}, {
// split i18n related module to distinct module
name: 'i18n',

9
pnpm-lock.yaml generated
View File

@@ -646,6 +646,9 @@ importers:
misskey-reversi:
specifier: workspace:*
version: link:../misskey-reversi
photoswipe:
specifier: 5.4.4
version: 5.4.4
punycode.js:
specifier: 2.3.1
version: 2.3.1
@@ -7234,6 +7237,10 @@ packages:
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
photoswipe@5.4.4:
resolution: {integrity: sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==}
engines: {node: '>= 0.12.0'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -15630,6 +15637,8 @@ snapshots:
dependencies:
split2: 4.2.0
photoswipe@5.4.4: {}
picocolors@1.1.1: {}
picomatch@2.3.2: {}

View File

@@ -10,264 +10,104 @@ import { execa } from 'execa';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
/** @type {Set<import('execa').ResultPromise>} */
const childProcesses = new Set();
/** @type {Set<import('execa').ResultPromise>} */
const persistentChildProcesses = new Set();
let shuttingDown = false;
let persistentChildProcessesStarted = false;
let persistentChildProcessFailed = false;
/** @type {Promise<void> | null} */
let shutdownPromise = null;
/**
* 開発用コマンドを起動し、終了時にまとめて停止できるよう追跡する。
* Windows では Ctrl+C の配信先を分離し、出力を親コンソールへ中継する。
*
* @param {string} command - 実行するコマンド。
* @param {string[]} args - コマンドへ渡す引数。
* @param {import('execa').Options} options - execa の起動オプション。
* @returns {import('execa').ResultPromise} 起動した子プロセス。
*/
function spawnChildProcess(command, args, options) {
const isWindows = process.platform === 'win32';
const pnpmPath = _dirname + '/../node_modules/pnpm/bin/pnpm.mjs';
const windowsCommand = [process.execPath, pnpmPath, ...args]
.map(argument => `"${argument}"`)
.join(' ');
const executable = isWindows && command === 'pnpm' ? process.env.ComSpec ?? 'cmd.exe' : command;
const executableArgs = isWindows && command === 'pnpm'
? ['/d', '/s', '/c', `start "" /b /wait ${windowsCommand}`]
: args;
const childProcess = execa(executable, executableArgs, isWindows ? {
...options,
// `start /b` keeps the process in the current console without forwarding
// Ctrl+C, allowing only this supervisor to coordinate the shutdown.
windowsVerbatimArguments: true,
windowsHide: false,
stdout: 'pipe',
stderr: 'pipe',
buffer: false,
} : options);
if (isWindows) {
if (options.stdout != null) childProcess.stdout?.pipe(options.stdout, { end: false });
if (options.stderr != null) childProcess.stderr?.pipe(options.stderr, { end: false });
}
childProcesses.add(childProcess);
return childProcess;
}
/**
* 子プロセスの終了を待機し、追跡対象から取り除く。
*
* @param {string} command - 実行するコマンド。
* @param {string[]} args - コマンドへ渡す引数。
* @param {import('execa').Options} options - execa の起動オプション。
* @returns {Promise<import('execa').Result>} 子プロセスの実行結果。
*/
async function runChildProcess(command, args, options) {
const childProcess = spawnChildProcess(command, args, options);
try {
return await childProcess;
} finally {
childProcesses.delete(childProcess);
}
}
/**
* 常駐する子プロセスがすべて終了していれば、終了結果を引き継いで親も終了する。
*
* @returns {void}
*/
function shutdownIfAllPersistentChildProcessesStopped() {
if (shuttingDown || !persistentChildProcessesStarted || persistentChildProcesses.size > 0) return;
void shutdown(persistentChildProcessFailed ? 1 : 0);
}
/**
* 常駐する子プロセスを起動し、終了時の追跡解除・エラー出力・親の終了判定を設定する。
*
* @param {string} command - 実行するコマンド。
* @param {string[]} args - コマンドへ渡す引数。
* @param {import('execa').Options} options - execa の起動オプション。
* @returns {void}
*/
function startChildProcess(command, args, options) {
const childProcess = spawnChildProcess(command, args, options);
persistentChildProcesses.add(childProcess);
void childProcess.then(() => {
childProcesses.delete(childProcess);
persistentChildProcesses.delete(childProcess);
shutdownIfAllPersistentChildProcessesStopped();
}, error => {
childProcesses.delete(childProcess);
persistentChildProcesses.delete(childProcess);
if (!shuttingDown) {
persistentChildProcessFailed = true;
console.error(error);
shutdownIfAllPersistentChildProcessesStopped();
}
});
}
/**
* 子プロセスとその配下のプロセスを停止し、終了を待機する。
*
* @param {import('execa').ResultPromise} childProcess - 停止する子プロセス。
* @returns {Promise<void>}
*/
async function stopChildProcess(childProcess) {
if (process.platform === 'win32' && childProcess.pid != null) {
const result = await execa('taskkill', ['/pid', childProcess.pid.toString(), '/t', '/f'], {
reject: false,
});
if (result.failed) childProcess.kill();
} else {
childProcess.kill();
}
await childProcess.catch(() => {});
}
/**
* 追跡中の子プロセスを一度だけ停止して、指定した終了コードで終了する。
*
* @param {number} exitCode - 親プロセスに返す終了コード。
* @returns {Promise<void>} 実行中または完了した停止処理。
*/
function shutdown(exitCode) {
if (shutdownPromise != null) return shutdownPromise;
shuttingDown = true;
shutdownPromise = (async () => {
await Promise.allSettled([...childProcesses].map(stopChildProcess));
process.exit(exitCode);
})();
return shutdownPromise;
}
process.on('SIGINT', () => {
void shutdown(0);
await execa('pnpm', ['clean'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
process.on('SIGTERM', () => {
void shutdown(0);
// アセットのビルドで依存しているので一番最初に必要
await execa('pnpm', ['--filter', 'i18n', 'build'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
try {
await runChildProcess('pnpm', ['clean'], {
await Promise.all([
execa('pnpm', ['build-pre'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
// アセットのビルドで依存しているので一番最初に必要
await runChildProcess('pnpm', ['--filter', 'i18n', 'build'], {
}),
execa('pnpm', ['build-assets'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
await Promise.all([
runChildProcess('pnpm', ['build-pre'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
}),
runChildProcess('pnpm', ['build-assets'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
}),
runChildProcess('pnpm', ['--filter', 'backend...', '--filter=!backend', 'build'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
}),
// icons-subsetterは開発段階では使用されないが、型エラーを抑制するためにはじめの一度だけビルドする
runChildProcess('pnpm', ['--filter', 'icons-subsetter', 'build'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
}),
runChildProcess('pnpm', ['--filter', 'misskey-js', 'build'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
}),
]);
startChildProcess('pnpm', ['build-pre', '--watch'], {
}),
execa('pnpm', ['--filter', 'backend...', '--filter=!backend', 'build'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
startChildProcess('pnpm', ['build-assets', '--watch'], {
}),
// icons-subsetterは開発段階では使用されないが、型エラーを抑制するためにはじめの一度だけビルドする
execa('pnpm', ['--filter', 'icons-subsetter', 'build'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
startChildProcess('pnpm', ['--filter', 'backend', 'dev'], {
}),
execa('pnpm', ['--filter', 'misskey-js', 'build'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
}),
]);
startChildProcess('pnpm', ['--filter', 'frontend', 'watch'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
execa('pnpm', ['build-pre', '--watch'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
startChildProcess('pnpm', ['--filter', 'frontend-embed', 'watch'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
execa('pnpm', ['build-assets', '--watch'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
startChildProcess('pnpm', ['--filter', 'sw', 'watch'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
execa('pnpm', ['--filter', 'backend', 'dev'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
startChildProcess('pnpm', ['--filter', 'misskey-js', 'watch', '--no-clean'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
execa('pnpm', ['--filter', 'frontend', 'watch'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
startChildProcess('pnpm', ['--filter', 'i18n', 'watch', '--no-clean'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
execa('pnpm', ['--filter', 'frontend-embed', 'watch'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
startChildProcess('pnpm', ['--filter', 'misskey-reversi', 'watch', '--no-clean'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
execa('pnpm', ['--filter', 'sw', 'watch'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
startChildProcess('pnpm', ['--filter', 'misskey-bubble-game', 'watch', '--no-clean'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
execa('pnpm', ['--filter', 'misskey-js', 'watch', '--no-clean'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
persistentChildProcessesStarted = true;
shutdownIfAllPersistentChildProcessesStopped();
} catch (error) {
if (!shuttingDown) {
console.error(error);
await shutdown(1);
}
}
execa('pnpm', ['--filter', 'i18n', 'watch', '--no-clean'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
execa('pnpm', ['--filter', 'misskey-reversi', 'watch', '--no-clean'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});
execa('pnpm', ['--filter', 'misskey-bubble-game', 'watch', '--no-clean'], {
cwd: _dirname + '/../',
stdout: process.stdout,
stderr: process.stderr,
});