kotalk/scripts/ci/capture-vstalk-web-screenshots.cjs

397 lines
10 KiB
JavaScript
Raw Normal View History

2026-04-16 09:24:26 +09:00
const fs = require('node:fs/promises')
const path = require('node:path')
const process = require('node:process')
const { createRequire } = require('node:module')
const webPackageRequire = createRequire(path.resolve(process.cwd(), 'src/PhysOn.Web/package.json'))
const puppeteer = webPackageRequire('puppeteer-core')
const baseUrl = process.env.VSTALK_CAPTURE_URL ?? 'http://127.0.0.1:4174/'
const outputDir = process.env.VSTALK_CAPTURE_OUTPUT_DIR
?? path.resolve(process.cwd(), 'docs/assets/latest')
const executablePath = process.env.CHROME_BIN ?? '/usr/bin/google-chrome'
const bootstrapPayload = {
me: {
userId: 'me-1',
displayName: '이안',
profileImageUrl: null,
statusMessage: '업무와 일상을 가볍게 잇는 중',
},
session: {
sessionId: 'session-alpha-web',
deviceId: 'device-web-alpha',
deviceName: 'Mobile Web',
createdAt: '2026-04-16T04:50:00Z',
},
ws: {
url: 'wss://vstalk.phy.kr/v1/realtime/ws',
},
conversations: {
items: [
{
conversationId: 'conv-team',
type: 'group',
title: '제품 운영',
avatarUrl: null,
subtitle: '10시 전에 공유안만 확인해 주세요.',
memberCount: 4,
isMuted: false,
isPinned: true,
sortKey: '2026-04-16T05:04:00Z',
unreadCount: 2,
lastReadSequence: 10,
lastMessage: {
messageId: 'msg-11',
text: '10시 전에 공유안만 확인해 주세요.',
createdAt: '2026-04-16T05:04:00Z',
senderUserId: 'u-2',
},
},
{
conversationId: 'conv-friends',
type: 'group',
title: '주말 약속',
avatarUrl: null,
subtitle: '토요일 2시에 브런치 어때?',
memberCount: 3,
isMuted: false,
isPinned: false,
sortKey: '2026-04-16T04:48:00Z',
unreadCount: 0,
lastReadSequence: 5,
lastMessage: {
messageId: 'msg-22',
text: '토요일 2시에 브런치 어때?',
createdAt: '2026-04-16T04:48:00Z',
senderUserId: 'u-3',
},
},
],
nextCursor: null,
},
}
const messageMap = {
'conv-team': {
items: [
{
messageId: 'msg-8',
conversationId: 'conv-team',
clientMessageId: 'client-8',
kind: 'text',
text: '회의 전에 이슈만 짧게 정리해 주세요.',
createdAt: '2026-04-16T04:40:00Z',
editedAt: null,
sender: {
userId: 'u-2',
displayName: '민지',
profileImageUrl: null,
},
isMine: false,
serverSequence: 8,
},
{
messageId: 'msg-9',
conversationId: 'conv-team',
clientMessageId: 'client-9',
kind: 'text',
text: '공유안은 정리해 두었습니다. 바로 올릴게요.',
createdAt: '2026-04-16T04:47:00Z',
editedAt: null,
sender: {
userId: 'me-1',
displayName: '이안',
profileImageUrl: null,
},
isMine: true,
serverSequence: 9,
},
{
messageId: 'msg-10',
conversationId: 'conv-team',
clientMessageId: 'client-10',
kind: 'text',
text: '좋아요. 10시 전에 공유안만 확인해 주세요.',
createdAt: '2026-04-16T05:04:00Z',
editedAt: null,
sender: {
userId: 'u-2',
displayName: '민지',
profileImageUrl: null,
},
isMine: false,
serverSequence: 10,
},
{
messageId: 'msg-11',
conversationId: 'conv-team',
clientMessageId: 'client-11',
kind: 'text',
text: '10시 전에 공유안만 확인해 주세요.',
createdAt: '2026-04-16T05:04:00Z',
editedAt: null,
sender: {
userId: 'u-2',
displayName: '민지',
profileImageUrl: null,
},
isMine: false,
serverSequence: 11,
},
],
nextCursor: null,
},
'conv-friends': {
items: [
{
messageId: 'msg-20',
conversationId: 'conv-friends',
clientMessageId: 'client-20',
kind: 'text',
text: '이번 주말에 시간 괜찮아?',
createdAt: '2026-04-16T04:32:00Z',
editedAt: null,
sender: {
userId: 'u-3',
displayName: '수아',
profileImageUrl: null,
},
isMine: false,
serverSequence: 4,
},
{
messageId: 'msg-21',
conversationId: 'conv-friends',
clientMessageId: 'client-21',
kind: 'text',
text: '토요일 2시에 브런치 어때?',
createdAt: '2026-04-16T04:48:00Z',
editedAt: null,
sender: {
userId: 'u-3',
displayName: '수아',
profileImageUrl: null,
},
isMine: false,
serverSequence: 5,
},
],
nextCursor: null,
},
}
const storedSession = {
apiBaseUrl: '',
tokens: {
accessToken: 'access-token-alpha',
accessTokenExpiresAt: '2026-04-16T06:00:00Z',
refreshToken: 'refresh-token-alpha',
refreshTokenExpiresAt: '2026-05-16T06:00:00Z',
},
bootstrap: bootstrapPayload,
savedAt: '2026-04-16T05:04:00Z',
}
async function ensureOutputDir() {
await fs.mkdir(outputDir, { recursive: true })
}
async function createBrowser() {
return puppeteer.launch({
executablePath,
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'],
defaultViewport: {
width: 390,
height: 844,
isMobile: true,
hasTouch: true,
deviceScaleFactor: 2,
},
})
}
async function installSessionMocks(page) {
await page.evaluateOnNewDocument((session) => {
class FakeWebSocket {
constructor(url) {
this.url = url
this.readyState = 0
this.onopen = null
this.onclose = null
this.onerror = null
this.onmessage = null
window.setTimeout(() => {
this.readyState = 1
if (this.onopen) {
this.onopen({ type: 'open' })
}
}, 80)
}
close() {
this.readyState = 3
if (this.onclose) {
this.onclose({ type: 'close' })
}
}
send() {}
}
window.localStorage.setItem('vs-talk.session', JSON.stringify(session))
window.localStorage.setItem('vs-talk.invite-code', 'ALPHA')
window.WebSocket = FakeWebSocket
}, storedSession)
await page.setRequestInterception(true)
page.on('request', (request) => {
const url = new URL(request.url())
if (url.pathname === '/v1/bootstrap') {
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: bootstrapPayload }),
})
return
}
if (/\/v1\/conversations\/[^/]+\/messages$/.test(url.pathname)) {
const match = url.pathname.match(/\/v1\/conversations\/([^/]+)\/messages/)
const conversationId = match ? match[1] : ''
const payload = messageMap[conversationId] ?? { items: [], nextCursor: null }
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: payload }),
})
return
}
if (/\/v1\/conversations\/[^/]+\/read-cursor$/.test(url.pathname)) {
const match = url.pathname.match(/\/v1\/conversations\/([^/]+)\/read-cursor/)
const conversationId = match ? match[1] : ''
const body = JSON.parse(request.postData() ?? '{}')
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
conversationId,
accountId: 'me-1',
lastReadSequence: body.lastReadSequence ?? 0,
updatedAt: '2026-04-16T05:05:00Z',
},
}),
})
return
}
if (url.pathname === '/v1/auth/token/refresh') {
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
tokens: storedSession.tokens,
},
}),
})
return
}
request.continue()
})
}
async function captureOnboarding(browser) {
const page = await browser.newPage()
await page.goto(baseUrl, { waitUntil: 'networkidle2' })
await page.evaluate(() => {
window.localStorage.clear()
})
await page.reload({ waitUntil: 'networkidle2' })
await page.screenshot({
path: path.join(outputDir, 'vstalk-web-onboarding.png'),
fullPage: false,
})
await page.close()
}
async function captureConversationList(browser) {
const page = await browser.newPage()
await installSessionMocks(page)
await page.goto(baseUrl, { waitUntil: 'networkidle2' })
await page.waitForSelector('.conversation-row')
await page.screenshot({
path: path.join(outputDir, 'vstalk-web-list.png'),
fullPage: false,
})
await page.close()
}
async function captureConversation(browser) {
const page = await browser.newPage()
await installSessionMocks(page)
await page.goto(baseUrl, { waitUntil: 'networkidle2' })
await page.waitForSelector('.conversation-row')
await page.click('.conversation-row')
await page.waitForSelector('.message-bubble')
await page.screenshot({
path: path.join(outputDir, 'vstalk-web-chat.png'),
fullPage: false,
})
await page.close()
}
async function captureSearch(browser) {
const page = await browser.newPage()
await installSessionMocks(page)
await page.goto(baseUrl, { waitUntil: 'networkidle2' })
await page.waitForSelector('.bottom-bar')
await page.click('.bottom-bar .nav-button:nth-child(2)')
await page.waitForSelector('.search-field')
await page.screenshot({
path: path.join(outputDir, 'vstalk-web-search.png'),
fullPage: false,
})
await page.close()
}
async function captureSaved(browser) {
const page = await browser.newPage()
await installSessionMocks(page)
await page.goto(baseUrl, { waitUntil: 'networkidle2' })
await page.waitForSelector('.bottom-bar')
await page.click('.bottom-bar .nav-button:nth-child(3)')
await page.waitForSelector('.saved-section')
await page.screenshot({
path: path.join(outputDir, 'vstalk-web-saved.png'),
fullPage: false,
})
await page.close()
}
async function main() {
await ensureOutputDir()
const browser = await createBrowser()
try {
await captureOnboarding(browser)
await captureConversationList(browser)
await captureSearch(browser)
await captureSaved(browser)
await captureConversation(browser)
} finally {
await browser.close()
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})