Merge branch 'main' into feature/nation-deception-boundary-parity

This commit is contained in:
2026-07-26 10:13:52 +00:00
16 changed files with 1314 additions and 27 deletions
+117
View File
@@ -5,7 +5,9 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
import { asRecord } from '@sammo-ts/common';
import { authedProcedure, router } from '../../trpc.js';
import { resolveAccessWindows } from '../../services/generalAccess.js';
import { getMyGeneral } from '../shared/general.js';
import { resolveNationNotice } from '../nation/shared.js';
const zGeneralSettings = z.object({
tnmt: z.number().int().optional(),
@@ -381,4 +383,119 @@ export const generalRouter = router({
history: trimRecentRecords(history, input.lastWorldHistoryId),
};
}),
getFrontStatus: authedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
const worldState = await ctx.db.worldState.findFirst({
orderBy: { id: 'asc' },
select: {
tickSeconds: true,
meta: true,
},
});
if (!worldState) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
const now = new Date();
const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta);
const [onlineAccess, ownNation, latestVote] = await Promise.all([
ctx.db.generalAccessLog.findMany({
where: {
lastRefresh: {
gte: scoreStartedAt,
},
},
select: { generalId: true },
}),
me.nationId > 0
? ctx.db.nation.findUnique({
where: { id: me.nationId },
select: { meta: true },
})
: Promise.resolve(null),
ctx.db.votePoll.findFirst({
where: {
startAt: { lte: now },
closedAt: null,
OR: [{ endAt: null }, { endAt: { gte: now } }],
},
orderBy: { id: 'desc' },
select: {
id: true,
title: true,
},
}),
]);
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
const onlineGenerals =
onlineGeneralIds.length > 0
? await ctx.db.general.findMany({
where: { id: { in: onlineGeneralIds } },
orderBy: { id: 'asc' },
select: {
id: true,
name: true,
nationId: true,
},
})
: [];
const nationIds = [...new Set(onlineGenerals.map((general) => general.nationId).filter((id) => id > 0))];
const nations =
nationIds.length > 0
? await ctx.db.nation.findMany({
where: { id: { in: nationIds } },
select: {
id: true,
name: true,
},
})
: [];
const nationNames = new Map(nations.map((nation) => [nation.id, nation.name]));
const onlineByNation = new Map<number, typeof onlineGenerals>();
for (const general of onlineGenerals) {
const bucket = onlineByNation.get(general.nationId) ?? [];
bucket.push(general);
onlineByNation.set(general.nationId, bucket);
}
const onlineNations = [...onlineByNation.entries()]
.sort((left, right) => right[1].length - left[1].length || left[0] - right[0])
.map(([nationId]) => `${nationId === 0 ? '재야' : (nationNames.get(nationId) ?? `세력 ${nationId}`)}`)
.join(', ');
const myOnlineGenerals = onlineGenerals
.filter((general) => general.nationId === me.nationId)
.map((general) => general.name)
.join(', ');
const myVote = latestVote
? await ctx.db.vote.findFirst({
where: {
voteId: latestVote.id,
generalId: me.id,
},
select: { id: true },
})
: null;
const worldMeta = asRecord(worldState.meta);
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
const parsedLastExecuted =
typeof rawLastExecuted === 'string' || rawLastExecuted instanceof Date ? new Date(rawLastExecuted) : null;
return {
onlineUserCount: onlineGenerals.length,
onlineNations,
onlineGenerals: myOnlineGenerals,
nationNotice: ownNation ? resolveNationNotice(asRecord(ownNation.meta)) : '',
lastExecuted:
parsedLastExecuted && Number.isFinite(parsedLastExecuted.getTime())
? parsedLastExecuted.toISOString()
: null,
latestVote: latestVote
? {
id: latestVote.id,
title: latestVote.title,
hasVoted: Boolean(myVote),
}
: null,
};
}),
});
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { DatabaseClient, GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js';
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: 'front-status-owner',
user: {
id: 'owner',
username: 'owner',
displayName: 'Owner',
roles: [],
},
sanctions: {},
};
const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean } = {}) =>
({
auth: options.auth === undefined ? auth : options.auth,
db: {
general: {
findFirst: vi.fn(async () => ({
id: 7,
userId: 'owner',
nationId: 2,
})),
findMany: vi.fn(async () => [
{ id: 7, name: '유비', nationId: 2 },
{ id: 8, name: '관우', nationId: 2 },
{ id: 9, name: '조조', nationId: 3 },
]),
},
worldState: {
findFirst: vi.fn(async () => ({
tickSeconds: 3600,
meta: {
lastTurnTime: '2026-07-26T10:00:00.000Z',
},
})),
},
generalAccessLog: {
findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }]),
},
nation: {
findUnique: vi.fn(async () => ({
meta: {
notice: '<p>북벌 준비</p>',
},
})),
findMany: vi.fn(async () => [
{ id: 2, name: '촉' },
{ id: 3, name: '위' },
]),
},
votePoll: {
findFirst: vi.fn(async () => ({
id: 12,
title: '다음 시즌 턴 시간',
})),
},
vote: {
findFirst: vi.fn(async () => (options.hasVoted ? { id: 21 } : null)),
},
} as unknown as DatabaseClient,
}) as GameApiContext;
describe('general.getFrontStatus', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-26T10:30:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('returns ref-compatible current-turn online, nation notice, and new vote data', async () => {
const context = buildContext();
const caller = appRouter.createCaller(context);
const result = await caller.general.getFrontStatus();
expect(result).toEqual({
onlineUserCount: 3,
onlineNations: '【촉】, 【위】',
onlineGenerals: '유비, 관우',
nationNotice: '<p>북벌 준비</p>',
lastExecuted: '2026-07-26T10:00:00.000Z',
latestVote: {
id: 12,
title: '다음 시즌 턴 시간',
hasVoted: false,
},
});
expect(context.db.generalAccessLog.findMany).toHaveBeenCalledWith({
where: {
lastRefresh: {
gte: new Date('2026-07-26T10:00:00.000Z'),
},
},
select: { generalId: true },
});
});
it('reports that the session-owned general already voted', async () => {
const caller = appRouter.createCaller(buildContext({ hasVoted: true }));
await expect(caller.general.getFrontStatus()).resolves.toMatchObject({
latestVote: {
id: 12,
hasVoted: true,
},
});
});
it('requires a game session and does not expose names or policy publicly', async () => {
const caller = appRouter.createCaller(buildContext({ auth: null }));
await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
});
@@ -0,0 +1,99 @@
<script setup lang="ts">
defineProps<{
status: {
onlineUserCount: number;
onlineNations: string;
onlineGenerals: string;
nationNotice: string;
lastExecuted: string | null;
latestVote: {
id: number;
title: string;
hasVoted: boolean;
} | null;
} | null;
}>();
</script>
<template>
<section class="front-status" aria-label="접속 현황과 국가 방침">
<div class="status-row vote-status">
<RouterLink v-if="status?.latestVote" to="/survey">
<span class="vote-label">설문 진행 : </span>{{ status.latestVote.title }}
</RouterLink>
<span v-else class="vote-empty">진행중인 설문 없음</span>
</div>
<div class="status-row online-nations">접속중인 국가: {{ status?.onlineNations ?? '' }}</div>
<div class="status-row online-users"> 접속자 {{ status?.onlineGenerals ?? '' }}</div>
<div class="status-row nation-notice">
<div class="notice-title"> 국가방침 </div>
<!-- 레거시 국가 방침은 같은 저장 형식의 HTML 본문을 그대로 표시한다. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="nation-notice-body" v-html="status?.nationNotice ?? ''" />
</div>
</section>
</template>
<style scoped>
.front-status {
width: calc(100% + 48px);
margin-left: -24px;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
color: #fff;
font-size: 14px;
font-weight: 400;
line-height: 21px;
}
.status-row {
box-sizing: border-box;
min-height: 36px;
border-top: 1px solid gray;
padding: 7px;
}
.nation-notice {
padding: 7px 0;
}
.notice-title {
padding: 0 7px;
}
.nation-notice-body {
overflow-wrap: anywhere;
}
.nation-notice-body :deep(p) {
min-height: 1em;
margin: 0;
}
.vote-status {
width: 33.333333%;
margin-left: auto;
padding-right: 0;
padding-left: 0;
text-align: center;
}
.vote-status a {
color: #fff;
text-decoration: gray underline;
}
.vote-label {
color: cyan;
}
.vote-empty {
color: magenta;
}
@media (max-width: 991px) {
.vote-status {
width: 50%;
}
}
</style>
+65 -13
View File
@@ -27,10 +27,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
const loading = ref(false);
const error = ref<string | null>(null);
const recordsError = ref<string | null>(null);
const frontStatusError = ref<string | null>(null);
const realtimeEnabled = ref(true);
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
@@ -47,6 +49,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const globalRecords = ref<RecentRecord[]>([]);
const generalRecords = ref<RecentRecord[]>([]);
const worldHistory = ref<RecentRecord[]>([]);
const frontStatus = ref<FrontStatus | null>(null);
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
let lastGeneralRecordId = 0;
let lastWorldHistoryId = 0;
let recordGeneralId: number | null = null;
@@ -199,6 +203,28 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
};
const updateFrontStatus = (nextStatus: FrontStatus) => {
frontStatus.value = nextStatus;
const latestVote = nextStatus.latestVote;
if (!latestVote || latestVote.hasVoted || typeof window === 'undefined') {
surveyNotice.value = null;
return;
}
const serverId = session.profile?.split(':', 1)[0] ?? 'game';
const storageKey = `state.${serverId}.lastVote`;
const lastSeenVoteId = Number.parseInt(window.localStorage.getItem(storageKey) ?? '0', 10);
if (latestVote.id <= (Number.isFinite(lastSeenVoteId) ? lastSeenVoteId : 0)) {
surveyNotice.value = null;
return;
}
window.localStorage.setItem(storageKey, latestVote.id.toString());
surveyNotice.value = latestVote;
};
const dismissSurveyNotice = () => {
surveyNotice.value = null;
};
const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => {
const merged = new Map(current.map((entry) => [entry.id, entry]));
for (const entry of incoming) {
@@ -214,6 +240,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
lastGeneralRecordId = 0;
lastWorldHistoryId = 0;
recordGeneralId = id;
frontStatus.value = null;
surveyNotice.value = null;
};
const loadMainData = async () => {
@@ -223,6 +251,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
loading.value = true;
error.value = null;
recordsError.value = null;
frontStatusError.value = null;
try {
const context = await trpc.general.me.query();
@@ -256,19 +285,35 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
recordsError.value = resolveErrorMessage(err);
return null;
});
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns, records] =
await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
trpc.turns.getCommandTable.query({ generalId: id }),
trpc.messages.getRecent.query({ generalId: id }),
trpc.messages.getContacts.query({ generalId: id }),
trpc.board.getAccess.query(),
generalTurnsPromise,
nationTurnsPromise,
recordsPromise,
]);
const frontStatusPromise = trpc.general.getFrontStatus.query().catch((err: unknown) => {
frontStatusError.value = resolveErrorMessage(err);
return null;
});
const [
layout,
lobby,
map,
commands,
messageData,
contacts,
access,
generalTurns,
nationTurns,
records,
nextFrontStatus,
] = await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
trpc.turns.getCommandTable.query({ generalId: id }),
trpc.messages.getRecent.query({ generalId: id }),
trpc.messages.getContacts.query({ generalId: id }),
trpc.board.getAccess.query(),
generalTurnsPromise,
nationTurnsPromise,
recordsPromise,
frontStatusPromise,
]);
mapLayout.value = layout;
lobbyInfo.value = lobby;
@@ -290,6 +335,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
);
lastWorldHistoryId = Math.max(lastWorldHistoryId, records.history[0]?.id ?? 0);
}
if (nextFrontStatus) {
updateFrontStatus(nextFrontStatus);
}
if (initializedMailboxGeneralId !== id) {
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
initializedMailboxGeneralId = id;
@@ -639,6 +687,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
loading,
error,
recordsError,
frontStatusError,
realtimeEnabled,
realtimeStatus,
generalContext,
@@ -658,12 +707,15 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
globalRecords,
generalRecords,
worldHistory,
frontStatus,
surveyNotice,
messageDraftText,
targetMailbox,
mailboxGroups,
statusLine,
realtimeLabel,
setRealtimeEnabled,
dismissSurveyNotice,
loadMainData,
refreshMessages,
sendMessage,
+83 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useMediaQuery } from '@vueuse/core';
import PanelCard from '../components/ui/PanelCard.vue';
@@ -12,6 +12,7 @@ import NationBasicCard from '../components/main/NationBasicCard.vue';
import MessagePanel from '../components/main/MessagePanel.vue';
import SelectedCityPanel from '../components/main/SelectedCityPanel.vue';
import RecordPanel from '../components/main/RecordPanel.vue';
import MainFrontStatus from '../components/main/MainFrontStatus.vue';
import { formatLog } from '../utils/formatLog';
import { useSessionStore } from '../stores/session';
import { useMainDashboardStore } from '../stores/mainDashboard';
@@ -38,6 +39,7 @@ const {
loading,
error,
recordsError,
frontStatusError,
realtimeEnabled,
general,
city,
@@ -53,6 +55,8 @@ const {
globalRecords,
generalRecords,
worldHistory,
frontStatus,
surveyNotice,
messageDraftText,
targetMailbox,
mailboxGroups,
@@ -60,6 +64,22 @@ const {
realtimeLabel,
} = storeToRefs(dashboard);
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
watch(surveyNotice, (notice) => {
if (surveyNoticeTimer) {
clearTimeout(surveyNoticeTimer);
surveyNoticeTimer = null;
}
if (notice) {
surveyNoticeTimer = setTimeout(() => dashboard.dismissSurveyNotice(), 60_000);
}
});
onUnmounted(() => {
if (surveyNoticeTimer) {
clearTimeout(surveyNoticeTimer);
}
});
const reserveGeneralTurn = (payload: { index: number; action: string; args: Record<string, unknown> }) => {
void dashboard.setGeneralTurn(payload.index, payload.action, payload.args);
};
@@ -153,11 +173,22 @@ watch(
</header>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="frontStatusError" class="front-status-error" role="alert">{{ frontStatusError }}</div>
<div v-if="session.needsGeneral" class="warning">
장수가 아직 생성되지 않았습니다. <RouterLink to="/join">장수 생성/빙의</RouterLink>
</div>
<MainFrontStatus :status="frontStatus" />
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
<div class="survey-notice-title">
<strong>설문조사 안내</strong>
<button type="button" aria-label="설문조사 알림 닫기" @click="dashboard.dismissSurveyNotice">×</button>
</div>
<RouterLink to="/survey">새로운 설문조사가 있습니다.</RouterLink>
</aside>
<section v-if="isMobile" class="layout-mobile">
<div class="mobile-tabs">
<button
@@ -453,11 +484,62 @@ button {
font-size: 0.85rem;
}
.front-status-error {
color: #ff8a80;
font-size: 0.85rem;
}
.warning {
color: #f5d08a;
font-size: 0.85rem;
}
.survey-notice {
position: fixed;
z-index: 1080;
right: 16px;
bottom: 16px;
box-sizing: border-box;
width: min(350px, calc(100vw - 32px));
border: 1px solid rgba(255, 193, 7, 0.75);
border-radius: 4px;
background: rgba(32, 28, 16, 0.96);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 14px;
line-height: 1.3;
}
.survey-notice-title {
display: flex;
min-height: 35px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 193, 7, 0.45);
padding: 8px 12px;
color: #ffc107;
}
.survey-notice-title button {
padding: 0 4px;
cursor: pointer;
font-size: 20px;
line-height: 1;
}
.survey-notice > a {
display: block;
padding: 12px;
color: #fff;
text-decoration: none;
}
.survey-notice > a:hover,
.survey-notice > a:focus-visible {
background: rgba(255, 193, 7, 0.12);
text-decoration: underline;
}
.layout-desktop {
display: grid;
grid-template-columns: minmax(320px, 1.4fr) minmax(320px, 1fr);
+18
View File
@@ -187,6 +187,24 @@ describe('gateway auth flow', () => {
expect(validated?.user.username).toBe('tester');
});
it('revokes the gateway session and every linked game session on logout', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
username: 'logout-user',
password: 'secretpass',
});
const session = await sessions.createSession(user);
const gameSession = await sessions.createGameSession(session.sessionToken, 'che:default');
expect(gameSession).not.toBeNull();
await caller.auth.logout({ sessionToken: session.sessionToken });
expect(await sessions.getSession(session.sessionToken)).toBeNull();
expect(
gameSession ? await sessions.getGameSession(gameSession.profile, gameSession.gameToken) : undefined
).toBeNull();
});
});
describe('account self service', () => {
+135
View File
@@ -0,0 +1,135 @@
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({
error: {
message,
code: -32603,
data: {
code: 'INTERNAL_SERVER_ERROR',
httpStatus: 500,
path,
},
},
});
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const installLobbyFixture = async (page: Page, options: { failLogout?: boolean } = {}) => {
let loggedOut = false;
let logoutBody = '';
await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'playwright-session');
window.localStorage.setItem('sammo-game-token', 'playwright-game-session');
window.localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/gateway/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'me') {
return response(
loggedOut
? null
: {
id: 'user-1',
username: 'tester',
displayName: '테스터',
roles: [],
createdAt: '2026-07-26T00:00:00.000Z',
}
);
}
if (operation === 'lobby.notice') {
return response('');
}
if (operation === 'lobby.profiles') {
return response([]);
}
if (operation === 'auth.logout') {
logoutBody = route.request().postData() ?? '';
if (options.failLogout) {
return errorResponse(operation, '로그아웃 서버가 응답하지 않습니다.');
}
loggedOut = true;
return response({ ok: true });
}
throw new Error(`Unhandled tRPC operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
return {
logoutBody: () => logoutBody,
};
};
test('logs out through the server before clearing all browser session state', async ({ page }) => {
const fixture = await installLobbyFixture(page);
await page.goto('lobby');
const logout = page.locator('#btn_logout');
await expect(logout).toBeVisible();
const before = await logout.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
width: rect.width,
height: rect.height,
backgroundColor: style.backgroundColor,
border: style.border,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
};
});
expect(before).toMatchObject({
width: 200,
height: 48,
backgroundColor: 'rgb(48, 48, 48)',
border: '0px none rgb(255, 255, 255)',
fontSize: '16px',
lineHeight: '24px',
});
await logout.hover();
await logout.click();
await expect(page).toHaveURL(/\/gateway\/$/);
expect(fixture.logoutBody()).toContain('playwright-session');
await expect
.poll(() =>
page.evaluate(() => ({
session: window.localStorage.getItem('sammo-session-token'),
game: window.localStorage.getItem('sammo-game-token'),
profile: window.localStorage.getItem('sammo-game-profile'),
}))
)
.toEqual({ session: null, game: null, profile: null });
});
test('keeps the lobby and every token when server logout fails', async ({ page }) => {
await installLobbyFixture(page, { failLogout: true });
await page.goto('lobby');
await page.locator('#btn_logout').click();
await expect(page).toHaveURL(/\/gateway\/lobby$/);
await expect(page.getByRole('alert')).toContainText('로그아웃 서버가 응답하지 않습니다.');
await expect
.poll(() =>
page.evaluate(() => ({
session: window.localStorage.getItem('sammo-session-token'),
game: window.localStorage.getItem('sammo-game-token'),
profile: window.localStorage.getItem('sammo-game-profile'),
}))
)
.toEqual({
session: 'playwright-session',
game: 'playwright-game-session',
profile: 'che:default',
});
});
@@ -6,7 +6,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../.
export default defineConfig({
testDir: '.',
testMatch: ['server-operations.spec.ts', 'lobby-admin-navigation.spec.ts'],
testMatch: ['server-operations.spec.ts', 'lobby-admin-navigation.spec.ts', 'logout.spec.ts'],
fullyParallel: false,
workers: 1,
timeout: 30_000,
+68 -12
View File
@@ -28,14 +28,13 @@ const profiles = ref<LobbyProfile[]>([]);
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
const entryLoading = ref<Record<string, boolean>>({});
const logoutLoading = ref(false);
const logoutError = ref('');
const canAccessAdmin = computed(
() =>
me.value?.roles.some(
(role) =>
role === 'superuser' ||
role === 'admin' ||
role === 'admin.superuser' ||
role.startsWith('admin.')
role === 'superuser' || role === 'admin' || role === 'admin.superuser' || role.startsWith('admin.')
) ?? false
);
@@ -82,9 +81,28 @@ onMounted(async () => {
});
const handleLogout = async () => {
// TODO: Implement logout mutation in gateway-api
// await trpc.auth.logout.mutation();
await router.push('/');
if (logoutLoading.value) {
return;
}
logoutError.value = '';
const sessionToken = window.localStorage.getItem('sammo-session-token');
if (!sessionToken) {
await router.replace('/');
return;
}
logoutLoading.value = true;
try {
await trpc.auth.logout.mutate({ sessionToken });
window.localStorage.removeItem('sammo-session-token');
window.localStorage.removeItem('sammo-game-token');
window.localStorage.removeItem('sammo-game-profile');
me.value = null;
await router.replace('/');
} catch (error) {
logoutError.value = error instanceof Error ? error.message : '로그아웃에 실패했습니다.';
} finally {
logoutLoading.value = false;
}
};
const resolveGameUrl = (path: string, profileName: string, gameToken: string): string | null => {
@@ -335,9 +353,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
{{ profileDetails[profile.profileName]?.turnTerm ?? '-' }}
</div>
</div>
<div v-else class="text-xs text-zinc-500 py-8 text-center">
지도를 불러오는 ...
</div>
<div v-else class="text-xs text-zinc-500 py-8 text-center">지도를 불러오는 ...</div>
</div>
<div v-else class="text-xs text-zinc-600 py-8 text-center">- -</div>
</div>
@@ -359,10 +375,12 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
비밀번호 & 전콘 & 탈퇴
</RouterLink>
<button
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
id="btn_logout"
class="legacy-logout-button"
:disabled="logoutLoading"
@click="handleLogout"
>
{{ logoutLoading ? '로그아웃 중…' : '로 그 아 웃' }}
</button>
<RouterLink
v-if="canAccessAdmin"
@@ -372,7 +390,45 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
관리자 페이지
</RouterLink>
</div>
<p v-if="logoutError" class="px-6 pb-4 text-center text-sm text-red-400" role="alert">
{{ logoutError }}
</p>
</div>
</div>
</DefaultLayout>
</template>
<style scoped>
.legacy-logout-button {
box-sizing: border-box;
width: 200px;
height: 48px;
border: 0;
border-radius: 6px;
background: #303030;
color: #fff;
cursor: pointer;
font-family: Pretendard, sans-serif;
font-size: 16px;
font-weight: 700;
line-height: 24px;
padding: 10px;
}
.legacy-logout-button:hover,
.legacy-logout-button:focus,
.legacy-logout-button:active {
background: #303030;
color: #fff;
}
.legacy-logout-button:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
.legacy-logout-button:disabled {
cursor: default;
opacity: 0.65;
}
</style>
+2
View File
@@ -34,6 +34,8 @@ export interface DatabaseClient {
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
boardPost: GamePrisma.BoardPostDelegate;
boardComment: GamePrisma.BoardCommentDelegate;
votePoll: GamePrisma.VotePollDelegate;
vote: GamePrisma.VoteDelegate;
inputEvent: GamePrisma.InputEventDelegate;
turnDaemonLease: GamePrisma.TurnDaemonLeaseDelegate;
}
@@ -0,0 +1,54 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig, devices } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const frontendPort = 15112;
const apiPort = 15113;
export default defineConfig({
testDir: '.',
testMatch: ['main-front-status.spec.ts'],
fullyParallel: false,
workers: 1,
timeout: 60_000,
expect: { timeout: 10_000 },
reporter: [['list']],
outputDir: process.env.SAMMO_TEST_OUTPUT_DIR ?? resolve(repositoryRoot, 'test-results/main-front-status-live'),
use: {
...devices['Desktop Chrome'],
baseURL: `http://127.0.0.1:${frontendPort}/che/`,
colorScheme: 'dark',
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'UTC',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: [
{
command:
`GAME_API_HOST=127.0.0.1 GAME_API_PORT=${apiPort} ` +
'GAME_TRPC_PATH=/che/api/trpc GAME_API_EVENTS_PATH=/che/api/events ' +
'PROFILE=che SCENARIO=default GAME_PROFILE_NAME=che:default node app/game-api/dist/index.js',
cwd: repositoryRoot,
url: `http://127.0.0.1:${apiPort}/che/api/trpc/health.ping?input=%7B%22json%22%3Anull%7D`,
reuseExistingServer: false,
timeout: 120_000,
},
{
command:
`VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=http://127.0.0.1:${apiPort}/che/api/trpc ` +
`VITE_GAME_SSE_URL=http://127.0.0.1:${apiPort}/che/api/events ` +
'pnpm --filter @sammo-ts/game-frontend build && ' +
`VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=http://127.0.0.1:${apiPort}/che/api/trpc ` +
`VITE_GAME_SSE_URL=http://127.0.0.1:${apiPort}/che/api/events ` +
`pnpm --filter @sammo-ts/game-frontend preview --host 127.0.0.1 --port ${frontendPort}`,
cwd: repositoryRoot,
url: `http://127.0.0.1:${frontendPort}/che/`,
reuseExistingServer: false,
timeout: 120_000,
},
],
});
@@ -0,0 +1,341 @@
import { randomUUID } from 'node:crypto';
import { expect, test, type Page, type Route } from '@playwright/test';
import {
createGamePostgresConnector,
createRedisConnector,
GamePrisma,
resolvePostgresConfigFromEnv,
resolveRedisConfigFromEnv,
type GamePrismaClient,
type RedisConnector,
} from '../../packages/infra/src/index.js';
const marker = `main-front-status-live-${randomUUID()}`;
const accessToken = `ga_${randomUUID()}`;
let postgres: ReturnType<typeof createGamePostgresConnector>;
let redis: RedisConnector;
let prisma: GamePrismaClient;
let fixture: { generalId: number; nationId: number; userId: string; voteId: number } | null = null;
const accessKey = (token: string) => `sammo:game:access:che:default:${token}`;
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({
error: {
message,
code: -32000,
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path },
},
});
const operationNames = (route: Route): string[] => {
const pathname = decodeURIComponent(new URL(route.request().url()).pathname);
return pathname.slice(pathname.lastIndexOf('/trpc/') + 6).split(',');
};
test.beforeAll(async () => {
postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: 'che' }));
redis = createRedisConnector(resolveRedisConfigFromEnv());
await postgres.connect();
await redis.connect();
prisma = postgres.prisma;
const world = await prisma.worldState.findFirst({ orderBy: { id: 'asc' }, select: { id: true } });
if (!world) {
throw new Error('The live che profile needs a world state.');
}
const [generalMax, nationMax] = await Promise.all([
prisma.general.aggregate({ _max: { id: true } }),
prisma.nation.aggregate({ _max: { id: true } }),
]);
const nationId = (nationMax._max.id ?? 0) + 10_000;
const generalId = (generalMax._max.id ?? 0) + 10_000;
const userId = `main-front-status-${randomUUID()}`;
await prisma.$executeRaw(
GamePrisma.sql`
INSERT INTO nation (id, name, color, meta)
VALUES (
${nationId},
${'검증국'},
${'#336699'},
${JSON.stringify({ notice: `<p>${marker} 국가 방침</p>` })}::jsonb
)
`
);
await prisma.$executeRaw(
GamePrisma.sql`
INSERT INTO general (id, user_id, name, nation_id, city_id, turn_time)
VALUES (${generalId}, ${userId}, ${'현황검증장수'}, ${nationId}, ${0}, ${new Date()})
`
);
await prisma.generalAccessLog.create({
data: {
generalId,
userId,
lastRefresh: new Date(),
refresh: 1,
refreshTotal: 1,
refreshScore: 1,
refreshScoreTotal: 1,
},
});
const poll = await prisma.votePoll.create({
data: {
title: '검증 설문',
body: '',
options: ['찬성', '반대'],
multipleOptions: 1,
revealMode: 'after_vote',
openerGeneralId: generalId,
openerName: '현황검증장수',
startAt: new Date(Date.now() - 60_000),
endAt: new Date(Date.now() + 3_600_000),
},
select: { id: true },
});
fixture = { generalId, nationId, userId, voteId: poll.id };
const issuedAt = new Date();
await redis.client.set(
accessKey(accessToken),
JSON.stringify({
version: 1,
profile: 'che:default',
issuedAt: issuedAt.toISOString(),
expiresAt: new Date(issuedAt.getTime() + 30 * 60 * 1000).toISOString(),
sessionId: `main-front-status-${randomUUID()}`,
user: {
id: userId,
username: 'main-front-status-live',
displayName: '현황검증장수',
roles: [],
},
sanctions: {},
}),
{ EX: 1800 }
);
});
test.afterAll(async () => {
if (fixture) {
await prisma.votePoll.deleteMany({ where: { id: fixture.voteId } });
await prisma.generalAccessLog.deleteMany({ where: { generalId: fixture.generalId } });
await prisma.$executeRaw(GamePrisma.sql`DELETE FROM general WHERE id = ${fixture.generalId}`);
await prisma.$executeRaw(GamePrisma.sql`DELETE FROM nation WHERE id = ${fixture.nationId}`);
const [generalCount, nationCount, voteCount, accessCount] = await Promise.all([
prisma.general.count({ where: { id: fixture.generalId } }),
prisma.nation.count({ where: { id: fixture.nationId } }),
prisma.votePoll.count({ where: { id: fixture.voteId } }),
prisma.generalAccessLog.count({ where: { generalId: fixture.generalId } }),
]);
expect([generalCount, nationCount, voteCount, accessCount]).toEqual([0, 0, 0, 0]);
}
await redis.client.del(accessKey(accessToken));
await redis.disconnect();
await postgres.disconnect();
});
const installMainFixture = async (
page: Page,
liveStatus: {
onlineUserCount: number;
onlineNations: string;
onlineGenerals: string;
nationNotice: string;
lastExecuted: string | null;
latestVote: { id: number; title: string; hasVoted: boolean } | null;
},
failStatus: () => boolean
) => {
await page.addInitScript(
({ token }) => {
window.localStorage.setItem('sammo-game-token', token);
window.localStorage.setItem('sammo-game-profile', 'che:default');
},
{ token: accessToken }
);
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'general.me') {
return response({
general: {
id: fixture?.generalId ?? 1,
name: '현황검증장수',
npcState: 0,
nationId: fixture?.nationId ?? 1,
cityId: 1,
troopId: 0,
picture: null,
imageServer: 0,
officerLevel: 0,
stats: { leadership: 55, strength: 55, intelligence: 55 },
gold: 1000,
rice: 1000,
crew: 0,
train: 0,
atmos: 0,
injury: 0,
experience: 0,
dedication: 0,
items: { horse: null, weapon: null, book: null, item: null },
},
city: null,
nation: null,
settings: {},
penalties: {},
});
}
if (operation === 'general.getFrontStatus') {
return failStatus()
? errorResponse(operation, '상단 현황을 불러오지 못했습니다.')
: response(liveStatus);
}
if (operation === 'general.getRecentRecords') return response({ global: [], general: [], history: [] });
if (operation === 'lobby.info') {
return response({
year: 190,
month: 1,
userCnt: 1,
npcCnt: 0,
nationCnt: 1,
maxUserCnt: 50,
turnTerm: 60,
fictionMode: '가상',
otherTextInfo: '',
starttime: '0190-01-01T00:00:00.000Z',
myGeneral: { name: '현황검증장수', picture: null },
});
}
if (operation === 'world.getMapLayout') {
return response({
mapName: 'che',
cityList: [{ id: 1, name: '낙양', level: 7, region: 1, x: 350, y: 245, path: [] }],
regionMap: { 1: '사예' },
levelMap: { 7: '수도' },
});
}
if (operation === 'world.getMap') {
return response({
year: 190,
month: 1,
startYear: 190,
cityList: [[1, 7, 0, 0, 1, 1]],
nationList: [],
spyList: {},
shownByGeneralList: [],
myCity: 1,
myNation: fixture?.nationId ?? 1,
});
}
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
return response([]);
}
if (operation === 'messages.getRecent') {
return response({
private: [],
public: [],
national: [],
diplomacy: [],
permission: 0,
latestRead: { private: 0, diplomacy: 0 },
canRespondDiplomacy: false,
});
}
if (operation === 'messages.getContacts') return response([]);
if (operation === 'board.getAccess') {
return response({ canMeeting: false, canSecret: false, permission: 0 });
}
if (operation === 'tournament.getState') return response({ stage: 0 });
if (operation === 'public.recordAccess') return response({ recorded: true });
throw new Error(`Unhandled main front status operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
};
test('renders actual online, nation policy, and survey data with ref geometry and preserves it on error', async ({
page,
}) => {
const liveResponse = await page.request.get(
'http://127.0.0.1:15113/che/api/trpc/general.getFrontStatus?input=%7B%22json%22%3Anull%7D',
{ headers: { authorization: `Bearer ${accessToken}` } }
);
expect(liveResponse.ok()).toBe(true);
const liveStatus = (
(await liveResponse.json()) as {
result: {
data: {
onlineUserCount: number;
onlineNations: string;
onlineGenerals: string;
nationNotice: string;
lastExecuted: string | null;
latestVote: { id: number; title: string; hasVoted: boolean } | null;
};
};
}
).result.data;
expect(liveStatus.onlineGenerals).toContain('현황검증장수');
expect(liveStatus.nationNotice).toContain(marker);
expect(liveStatus.latestVote).toMatchObject({ title: '검증 설문', hasVoted: false });
let failStatus = false;
await installMainFixture(page, liveStatus, () => failStatus);
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('./', { waitUntil: 'networkidle' });
const status = page.locator('.front-status');
await expect(status).toContainText(marker);
await expect(page.locator('.online-users')).toContainText('현황검증장수');
await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.');
const desktop = await status.evaluate((element) => {
const style = getComputedStyle(element);
const onlineRow = element.querySelector<HTMLElement>('.online-nations');
const voteRow = element.querySelector<HTMLElement>('.vote-status');
if (!onlineRow || !voteRow) throw new Error('status row missing');
return {
rect: element.getBoundingClientRect().toJSON(),
fontSize: style.fontSize,
lineHeight: style.lineHeight,
backgroundImage: style.backgroundImage,
onlineRow: {
rect: onlineRow.getBoundingClientRect().toJSON(),
borderTop: getComputedStyle(onlineRow).borderTop,
padding: getComputedStyle(onlineRow).padding,
},
voteRow: voteRow.getBoundingClientRect().toJSON(),
};
});
expect(desktop.rect).toMatchObject({ x: 0, width: 1000 });
expect(desktop).toMatchObject({
fontSize: '14px',
lineHeight: '21px',
onlineRow: {
borderTop: '1px solid rgb(128, 128, 128)',
padding: '7px',
},
});
expect(desktop.onlineRow.rect.height).toBeCloseTo(36, 0);
expect(desktop.voteRow.x).toBeCloseTo(666.67, 0);
expect(desktop.voteRow.width).toBeCloseTo(333.33, 0);
expect(desktop.voteRow.height).toBeCloseTo(36, 0);
expect(desktop.backgroundImage).toContain('/image/game/back_walnut.jpg');
await page.setViewportSize({ width: 500, height: 900 });
await expect(status).toHaveCSS('width', '500px');
await expect(page.locator('.vote-status')).toHaveCSS('width', '250px');
failStatus = true;
await page.getByRole('button', { name: '새로고침', exact: true }).click();
await expect(page.getByRole('alert').filter({ hasText: '상단 현황을 불러오지 못했습니다.' })).toBeVisible();
await expect(status).toContainText(marker);
failStatus = false;
await page.reload({ waitUntil: 'networkidle' });
await expect(page.locator('.front-status')).toContainText(marker);
await expect(page.locator('.survey-notice')).toHaveCount(0);
});
@@ -188,6 +188,16 @@ const installMainFixture = async (page: Page, failRecords = false) => {
? errorResponse(operation, '동향 정보를 불러오지 못했습니다.')
: response(frontRecords);
}
if (operation === 'general.getFrontStatus') {
return response({
onlineUserCount: 1,
onlineNations: '【재야】',
onlineGenerals: '지도 장수',
nationNotice: '',
lastExecuted: '2026-07-26T00:00:00.000Z',
latestVote: null,
});
}
if (operation === 'tournament.getState') return response({ stage: 0 });
if (operation === 'public.recordAccess') return response({ recorded: true });
return errorResponse(operation, `Unhandled main operation: ${operation}`);
@@ -0,0 +1,63 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { chromium } from '@playwright/test';
const baseUrl = process.env.REF_GATEWAY_URL ?? 'http://127.0.0.1:3400/sam/';
const username = process.env.REF_USER_ID ?? 'refuser1';
const passwordFile =
process.env.REF_USER_PASSWORD_FILE ??
'/home/letrhee/sam_rebuild/docker_compose_files/reference/secrets/user1_password';
const password = (await readFile(passwordFile, 'utf8')).trim();
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
colorScheme: 'dark',
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'UTC',
viewport: { width: 1000, height: 900 },
});
const page = await context.newPage();
await page.goto(baseUrl, { waitUntil: 'networkidle' });
const globalSalt = await page.locator('#global_salt').inputValue();
const passwordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
data: { username, password: passwordHash },
});
if (!login.ok()) {
throw new Error(`reference login failed: HTTP ${login.status()}`);
}
await page.goto(baseUrl, { waitUntil: 'networkidle' });
const logout = page.locator('#btn_logout');
await logout.waitFor({ state: 'visible' });
const inspect = async () =>
logout.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: rect.toJSON(),
backgroundColor: style.backgroundColor,
border: style.border,
borderRadius: style.borderRadius,
color: style.color,
cursor: style.cursor,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
lineHeight: style.lineHeight,
padding: style.padding,
};
});
const normal = await inspect();
await logout.hover();
const hover = await inspect();
await logout.focus();
const focus = await inspect();
console.log(JSON.stringify({ viewport: page.viewportSize(), normal, hover, focus }));
} finally {
await browser.close();
}
@@ -0,0 +1,74 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { chromium } from '@playwright/test';
const baseUrl = process.env.REF_MAIN_URL ?? 'http://127.0.0.1:3400/sam/';
const username = process.env.REF_USER_ID ?? 'refuser1';
const passwordFile =
process.env.REF_USER_PASSWORD_FILE ??
'/home/letrhee/sam_rebuild/docker_compose_files/reference/secrets/user1_password';
const password = (await readFile(passwordFile, 'utf8')).trim();
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
colorScheme: 'dark',
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'UTC',
});
const page = await context.newPage();
await page.goto(baseUrl, { waitUntil: 'networkidle' });
const globalSalt = await page.locator('#global_salt').inputValue();
const passwordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
data: { username, password: passwordHash },
});
if (!login.ok()) {
throw new Error(`reference login failed: HTTP ${login.status()}`);
}
for (const viewport of [
{ name: 'desktop', width: 1000, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
await page.locator('.onlineNations').waitFor({ state: 'visible' });
const measurement = await page.evaluate(() => {
const inspect = (selector) => {
const element = document.querySelector(selector);
if (!(element instanceof HTMLElement)) {
throw new Error(`missing ${selector}`);
}
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: rect.toJSON(),
backgroundColor: style.backgroundColor,
backgroundImage: style.backgroundImage,
borderTop: style.borderTop,
color: style.color,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
lineHeight: style.lineHeight,
padding: style.padding,
};
};
return {
container: inspect('#container'),
onlineNations: inspect('.onlineNations'),
onlineUsers: inspect('.onlineUsers'),
nationNotice: inspect('.nationNotice'),
voteStatus: inspect('.subVoteState'),
};
});
console.log(JSON.stringify({ viewport, measurement }));
}
} finally {
await browser.close();
}
@@ -0,0 +1,57 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const pm2Candidates = [
'/home/letrhee/.nvm/versions/node/v22.15.1/bin/pm2',
'/home/letrhee/.nvm/versions/node/v24.13.0/bin/pm2',
];
let processes;
for (const candidate of pm2Candidates) {
try {
const output = execFileSync(candidate, ['jlist'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
processes = JSON.parse(output.slice(output.indexOf('[')));
break;
} catch {
// Try the next locally installed PM2 client.
}
}
if (!processes) {
throw new Error('Unable to read the local PM2 process list.');
}
const source = processes.find((process) => process.name === 'sammo-verify-che-api')?.pm2_env;
if (!source) {
throw new Error('sammo-verify-che-api is required as the live environment source.');
}
const allowed = /^(DATABASE_URL|POSTGRES_|REDIS_|GAME_|GATEWAY_|PROFILE$|SCENARIO$)/;
const liveEnvironment = Object.fromEntries(
Object.entries(source).filter(([key, value]) => allowed.test(key) && typeof value === 'string')
);
const result = spawnSync(
'pnpm',
[
'exec',
'playwright',
'test',
'main-front-status.spec.ts',
'--config',
'tools/frontend-legacy-parity/main-front-status.playwright.config.mjs',
],
{
cwd: repositoryRoot,
env: {
...process.env,
...liveEnvironment,
SAMMO_TEST_OUTPUT_DIR: '/dev/shm/sammo-main-front-status-live',
},
stdio: 'inherit',
}
);
process.exitCode = result.status ?? 1;