merge: 최신 원격 main을 Vue 드래그 리팩터링에 통합
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../turns/commandTable.js';
|
||||
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
||||
import {
|
||||
buildEquipmentTradeItemOptions,
|
||||
parseReservedTurnArgs,
|
||||
TURN_COMMAND_NATION_COLORS,
|
||||
type TurnCommandInputOptions,
|
||||
@@ -283,29 +284,12 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
cityNames: new Map(cities.map((entry) => [entry.id, entry.name])),
|
||||
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
|
||||
});
|
||||
const items: TurnCommandInputOptions['items'] = {
|
||||
horse: [{ value: 'None', label: '판매/해제' }],
|
||||
weapon: [{ value: 'None', label: '판매/해제' }],
|
||||
book: [{ value: 'None', label: '판매/해제' }],
|
||||
item: [{ value: 'None', label: '판매/해제' }],
|
||||
};
|
||||
for (const item of moduleBundle.itemModules) {
|
||||
if (item.buyable) {
|
||||
const cost = item.cost ?? 0;
|
||||
const currentSecurity = city?.security ?? 0;
|
||||
const availability =
|
||||
currentSecurity < item.reqSecu
|
||||
? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요`
|
||||
: general.gold < cost
|
||||
? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요`
|
||||
: '현재 구입 가능';
|
||||
items[item.slot].push({
|
||||
value: item.key,
|
||||
label: item.name,
|
||||
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const items = buildEquipmentTradeItemOptions({
|
||||
configConst: asRecord(asRecord(worldState.config).const),
|
||||
itemModules: moduleBundle.itemModules,
|
||||
currentSecurity: city?.security ?? 0,
|
||||
generalGold: general.gold,
|
||||
});
|
||||
const inputOptions: TurnCommandInputOptions = {
|
||||
cities: cities.map((entry) => ({
|
||||
value: entry.id,
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
type NationTurnCommandSpec,
|
||||
} from '@sammo-ts/logic';
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
||||
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { loadTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
|
||||
@@ -103,6 +105,49 @@ export interface TurnCommandInputOptions {
|
||||
};
|
||||
}
|
||||
|
||||
type EquipmentTradeItemModule = Pick<ItemModule, 'key' | 'slot' | 'name' | 'info' | 'cost' | 'reqSecu' | 'buyable'>;
|
||||
|
||||
const plainLegacyInfo = (value: string): string =>
|
||||
value
|
||||
.replace(/<br\s*\/?>/giu, ' · ')
|
||||
.replace(/<[^>]+>/gu, '')
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim();
|
||||
|
||||
export const buildEquipmentTradeItemOptions = (options: {
|
||||
configConst: Record<string, unknown>;
|
||||
itemModules: readonly EquipmentTradeItemModule[];
|
||||
currentSecurity: number;
|
||||
generalGold: number;
|
||||
}): TurnCommandInputOptions['items'] => {
|
||||
const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst);
|
||||
const items: TurnCommandInputOptions['items'] = {
|
||||
horse: [{ value: 'None', label: '판매/해제' }],
|
||||
weapon: [{ value: 'None', label: '판매/해제' }],
|
||||
book: [{ value: 'None', label: '판매/해제' }],
|
||||
item: [{ value: 'None', label: '판매/해제' }],
|
||||
};
|
||||
|
||||
for (const item of options.itemModules) {
|
||||
if (!item.buyable || !purchasableItemKeys.has(item.key)) {
|
||||
continue;
|
||||
}
|
||||
const cost = item.cost ?? 0;
|
||||
const availability =
|
||||
options.currentSecurity < item.reqSecu
|
||||
? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요`
|
||||
: options.generalGold < cost
|
||||
? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요`
|
||||
: '현재 구입 가능';
|
||||
items[item.slot].push({
|
||||
value: item.key,
|
||||
label: item.name,
|
||||
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
|
||||
export const TURN_COMMAND_NATION_COLORS = [
|
||||
'#FF0000',
|
||||
|
||||
@@ -6,7 +6,24 @@ import {
|
||||
} from '@sammo-ts/logic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTurnCommandInputFields, parseReservedTurnArgs } from '../src/turns/commandInput.js';
|
||||
import {
|
||||
buildEquipmentTradeItemOptions,
|
||||
buildTurnCommandInputFields,
|
||||
parseReservedTurnArgs,
|
||||
} from '../src/turns/commandInput.js';
|
||||
|
||||
const buildShopItem = (key: string, name: string) => ({
|
||||
key,
|
||||
rawName: name,
|
||||
name,
|
||||
info: `${name}<br>설명`,
|
||||
slot: 'item' as const,
|
||||
cost: 100,
|
||||
buyable: true,
|
||||
consumable: false,
|
||||
reqSecu: 3000,
|
||||
unique: false,
|
||||
});
|
||||
|
||||
describe('turn command argument input', () => {
|
||||
it('builds supported fields for every argument-bearing command module', async () => {
|
||||
@@ -80,4 +97,35 @@ describe('turn command argument input', () => {
|
||||
});
|
||||
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
|
||||
});
|
||||
|
||||
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
|
||||
const items = buildEquipmentTradeItemOptions({
|
||||
configConst: {},
|
||||
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
|
||||
currentSecurity: 5000,
|
||||
generalGold: 1000,
|
||||
});
|
||||
|
||||
expect(items.item.map((item) => item.value)).toEqual(['None', 'che_치료_환약']);
|
||||
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
|
||||
});
|
||||
|
||||
it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
|
||||
const items = buildEquipmentTradeItemOptions({
|
||||
configConst: {
|
||||
allItems: {
|
||||
item: {
|
||||
che_치료_환약: 1,
|
||||
event_전투특기_격노: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
|
||||
currentSecurity: 2000,
|
||||
generalGold: 50,
|
||||
});
|
||||
|
||||
expect(items.item.map((item) => item.value)).toEqual(['None', 'event_전투특기_격노']);
|
||||
expect(items.item[1]?.description).toContain('현재 구입 불가: 치안 3,000 필요');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
loadActionModuleBundle,
|
||||
} from '@sammo-ts/logic';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import { createRuntimeTrace } from './runtimeTrace.js';
|
||||
|
||||
// legacy GameConstBase 기본값
|
||||
@@ -146,6 +147,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
||||
['maxResourceActionAmount'],
|
||||
DEFAULT_MAX_RESOURCE_ACTION_AMOUNT
|
||||
),
|
||||
purchasableItemKeys: resolveLegacyPurchasableItemKeys(constValues),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js';
|
||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||
|
||||
type LoadedScenario = Awaited<ReturnType<typeof loadScenarioDefinitionById>>;
|
||||
|
||||
@@ -77,4 +78,18 @@ describe('tracked scenario resources', () => {
|
||||
expect(readItemSlot(moreEffectBlank, 'horse').che_명마_07_백마).toBe(4);
|
||||
expect(readItemSlot(composedAddon, 'horse').che_명마_07_백마).toBe(2);
|
||||
});
|
||||
|
||||
it('projects ordinary and explicit secret-item scenario pools into command execution', async () => {
|
||||
const [ordinaryBlank, secretScenario] = await Promise.all(
|
||||
[1, 2701].map((scenarioId) => loadScenarioDefinitionById(scenarioId))
|
||||
);
|
||||
const ordinaryKeys = buildCommandEnv(ordinaryBlank.config).purchasableItemKeys;
|
||||
const secretScenarioKeys = buildCommandEnv(secretScenario.config).purchasableItemKeys;
|
||||
|
||||
expect(ordinaryKeys?.size).toBe(24);
|
||||
expect(ordinaryKeys?.has('che_치료_환약')).toBe(true);
|
||||
expect([...ordinaryKeys!].filter((key) => key.startsWith('event_전투특기_'))).toEqual([]);
|
||||
expect([...secretScenarioKeys!].filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20);
|
||||
expect(secretScenarioKeys?.has('event_전투특기_격노')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ type NavigationFixture = {
|
||||
accessLimitAfterCalls?: number;
|
||||
largeCommandTable?: boolean;
|
||||
draftCommandTable?: boolean;
|
||||
equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>;
|
||||
refCommandCategories?: boolean;
|
||||
currentYear?: number;
|
||||
currentMonth?: number;
|
||||
@@ -235,6 +236,7 @@ const draftCommandGroups = [
|
||||
options: [
|
||||
{ value: 'horse', label: '명마' },
|
||||
{ value: 'weapon', label: '무기' },
|
||||
{ value: 'item', label: '도구' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -250,7 +252,13 @@ const draftCommandGroups = [
|
||||
},
|
||||
];
|
||||
|
||||
const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false, draftCommands = false) => ({
|
||||
const commandTableFixture = (
|
||||
large: boolean,
|
||||
blockedCount = 0,
|
||||
refCategories = false,
|
||||
draftCommands = false,
|
||||
equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>
|
||||
) => ({
|
||||
general: draftCommands
|
||||
? draftCommandGroups
|
||||
: refCategories
|
||||
@@ -309,6 +317,7 @@ const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = f
|
||||
{ value: 'None', label: '없음' },
|
||||
{ value: '청룡언월도', label: '청룡언월도' },
|
||||
],
|
||||
item: equipmentItemOptions ?? [{ value: 'None', label: '없음' }],
|
||||
}
|
||||
: {},
|
||||
context: draftCommands
|
||||
@@ -595,7 +604,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
state.largeCommandTable === true,
|
||||
state.commandBlockedCount,
|
||||
state.refCommandCategories === true,
|
||||
state.draftCommandTable === true
|
||||
state.draftCommandTable === true,
|
||||
state.equipmentItemOptions
|
||||
),
|
||||
}
|
||||
: input.known.commandTable === currentCommandTableRevision
|
||||
@@ -3455,6 +3465,52 @@ for (const viewport of [
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1200, height: 900 },
|
||||
{ name: 'mobile', width: 500, height: 900 },
|
||||
] as const) {
|
||||
test(`renders only scenario-scoped equipment items on ${viewport.name}`, async ({ page }) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
nationLevel: 3,
|
||||
stage: 0,
|
||||
npcMode: 1,
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
draftCommandTable: true,
|
||||
equipmentItemOptions: [
|
||||
{ value: 'None', label: '판매/해제' },
|
||||
{
|
||||
value: 'che_치료_환약',
|
||||
label: '환약',
|
||||
description: '현재 구입 가능 · 가격 100 · 부상 회복',
|
||||
},
|
||||
],
|
||||
reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })),
|
||||
};
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await waitForMain(page);
|
||||
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: '국가', exact: true }).click();
|
||||
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
|
||||
await picker.getByLabel('장비 종류', { exact: true }).selectOption('item');
|
||||
|
||||
const itemSelect = picker.getByLabel('장비', { exact: true });
|
||||
await expect(itemSelect.locator('option')).toHaveText(['판매/해제', '환약']);
|
||||
await expect(itemSelect.locator('option', { hasText: '비급' })).toHaveCount(0);
|
||||
await itemSelect.selectOption('che_치료_환약');
|
||||
await expect(picker).toContainText('현재 구입 가능 · 가격 100 · 부상 회복');
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
|
||||
.toBeLessThanOrEqual(viewport.width);
|
||||
await picker.screenshot({ path: test.info().outputPath(`scenario-item-shop-${viewport.name}.png`) });
|
||||
});
|
||||
}
|
||||
|
||||
test('realtime read-model events skip clock-only work, merge bursts, patch in place, and stop off-route', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -8,29 +8,44 @@ import {
|
||||
type ProcessDefinition,
|
||||
} from './processManager.js';
|
||||
|
||||
type Pm2Module = typeof Pm2;
|
||||
export interface Pm2Client {
|
||||
connect(callback: (error?: Error) => void): void;
|
||||
disconnect(): void;
|
||||
list(callback: (error: Error | null, list?: Pm2.ProcessDescription[]) => void): void;
|
||||
start(options: Pm2.StartOptions, callback: (error?: Error) => void): void;
|
||||
stop(name: string, callback: (error?: Error) => void): void;
|
||||
delete(name: string, callback: (error?: Error) => void): void;
|
||||
}
|
||||
|
||||
export interface Pm2ProcessManagerOptions {
|
||||
loadPm2?: () => Pm2Client;
|
||||
connectTimeoutMs?: number;
|
||||
listTimeoutMs?: number;
|
||||
mutationTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const loadPm2 = (): Pm2Module => require('pm2') as Pm2Module;
|
||||
const loadPm2 = (): Pm2Client => require('pm2') as Pm2Client;
|
||||
const DEFAULT_PM2_CONNECT_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_PM2_LIST_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_PM2_MUTATION_TIMEOUT_MS = 30_000;
|
||||
|
||||
const withPm2 = async <T>(handler: (pm2: Pm2Module) => Promise<T>): Promise<T> => {
|
||||
const pm2 = loadPm2();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
pm2.connect((error) => {
|
||||
if (error) {
|
||||
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs);
|
||||
timer.unref();
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
);
|
||||
});
|
||||
try {
|
||||
return await handler(pm2);
|
||||
} finally {
|
||||
pm2.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
|
||||
name: definition.name,
|
||||
@@ -47,8 +62,52 @@ export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
|
||||
});
|
||||
|
||||
export class Pm2ProcessManager implements ProcessManager {
|
||||
private readonly loadPm2: () => Pm2Client;
|
||||
private readonly connectTimeoutMs: number;
|
||||
private readonly listTimeoutMs: number;
|
||||
private readonly mutationTimeoutMs: number;
|
||||
private sessionTail: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(options: Pm2ProcessManagerOptions = {}) {
|
||||
this.loadPm2 = options.loadPm2 ?? loadPm2;
|
||||
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_PM2_CONNECT_TIMEOUT_MS;
|
||||
this.listTimeoutMs = options.listTimeoutMs ?? DEFAULT_PM2_LIST_TIMEOUT_MS;
|
||||
this.mutationTimeoutMs = options.mutationTimeoutMs ?? DEFAULT_PM2_MUTATION_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
private withPm2<T>(label: string, timeoutMs: number, handler: (pm2: Pm2Client) => Promise<T>): Promise<T> {
|
||||
const task = this.sessionTail.then(async () => {
|
||||
const pm2 = this.loadPm2();
|
||||
try {
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
pm2.connect((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
this.connectTimeoutMs,
|
||||
'PM2 connect'
|
||||
);
|
||||
return await withTimeout(handler(pm2), timeoutMs, label);
|
||||
} finally {
|
||||
pm2.disconnect();
|
||||
}
|
||||
});
|
||||
this.sessionTail = task.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return task;
|
||||
}
|
||||
|
||||
async list(): Promise<ManagedProcessInfo[]> {
|
||||
return withPm2(
|
||||
return this.withPm2(
|
||||
'PM2 list',
|
||||
this.listTimeoutMs,
|
||||
(pm2) =>
|
||||
new Promise<ManagedProcessInfo[]>((resolve, reject) => {
|
||||
pm2.list((error, list) => {
|
||||
@@ -72,7 +131,9 @@ export class Pm2ProcessManager implements ProcessManager {
|
||||
}
|
||||
|
||||
async start(definition: ProcessDefinition): Promise<void> {
|
||||
await withPm2(
|
||||
await this.withPm2(
|
||||
`PM2 start ${definition.name}`,
|
||||
this.mutationTimeoutMs,
|
||||
(pm2) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
pm2.list((listError, list) => {
|
||||
@@ -100,7 +161,9 @@ export class Pm2ProcessManager implements ProcessManager {
|
||||
}
|
||||
|
||||
async stop(name: string): Promise<void> {
|
||||
await withPm2(
|
||||
await this.withPm2(
|
||||
`PM2 stop ${name}`,
|
||||
this.mutationTimeoutMs,
|
||||
(pm2) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
pm2.stop(name, (error) => {
|
||||
@@ -115,7 +178,9 @@ export class Pm2ProcessManager implements ProcessManager {
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
await withPm2(
|
||||
await this.withPm2(
|
||||
`PM2 delete ${name}`,
|
||||
this.mutationTimeoutMs,
|
||||
(pm2) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
pm2.delete(name, (error) => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildPm2StartOptions } from '../src/orchestrator/pm2ProcessManager.js';
|
||||
import {
|
||||
buildPm2StartOptions,
|
||||
Pm2ProcessManager,
|
||||
type Pm2Client,
|
||||
} from '../src/orchestrator/pm2ProcessManager.js';
|
||||
|
||||
describe('buildPm2StartOptions', () => {
|
||||
it('enforces bounded restart policy and strips inherited PM2 identity at the PM2 boundary', () => {
|
||||
@@ -56,3 +60,118 @@ describe('buildPm2StartOptions', () => {
|
||||
expect(options.env).not.toHaveProperty('args');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pm2ProcessManager session recovery', () => {
|
||||
it('serializes concurrent PM2 sessions so one disconnect cannot interrupt another request', async () => {
|
||||
const events: string[] = [];
|
||||
let listCall = 0;
|
||||
let releaseFirstList: (() => void) | undefined;
|
||||
const pm2 = {
|
||||
connect(callback: Parameters<Pm2Client['connect']>[0]) {
|
||||
events.push('connect');
|
||||
callback();
|
||||
},
|
||||
disconnect() {
|
||||
events.push('disconnect');
|
||||
},
|
||||
list(callback: Parameters<Pm2Client['list']>[0]) {
|
||||
listCall += 1;
|
||||
const currentCall = listCall;
|
||||
events.push(`list:${currentCall}`);
|
||||
if (currentCall === 1) {
|
||||
releaseFirstList = () => callback(null, []);
|
||||
return;
|
||||
}
|
||||
callback(null, []);
|
||||
},
|
||||
start() {
|
||||
throw new Error('unused');
|
||||
},
|
||||
stop() {
|
||||
throw new Error('unused');
|
||||
},
|
||||
delete() {
|
||||
throw new Error('unused');
|
||||
},
|
||||
} satisfies Pm2Client;
|
||||
const manager = new Pm2ProcessManager({ loadPm2: () => pm2 });
|
||||
|
||||
const first = manager.list();
|
||||
const second = manager.list();
|
||||
await vi.waitFor(() => expect(events).toEqual(['connect', 'list:1']));
|
||||
|
||||
releaseFirstList?.();
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([[], []]);
|
||||
expect(events).toEqual(['connect', 'list:1', 'disconnect', 'connect', 'list:2', 'disconnect']);
|
||||
});
|
||||
|
||||
it('times out a lost PM2 callback and lets the next queued session proceed', async () => {
|
||||
let listCall = 0;
|
||||
const pm2 = {
|
||||
connect(callback: Parameters<Pm2Client['connect']>[0]) {
|
||||
callback();
|
||||
},
|
||||
disconnect() {},
|
||||
list(callback: Parameters<Pm2Client['list']>[0]) {
|
||||
listCall += 1;
|
||||
if (listCall === 1) {
|
||||
return;
|
||||
}
|
||||
callback(null, []);
|
||||
},
|
||||
start() {
|
||||
throw new Error('unused');
|
||||
},
|
||||
stop() {
|
||||
throw new Error('unused');
|
||||
},
|
||||
delete() {
|
||||
throw new Error('unused');
|
||||
},
|
||||
} satisfies Pm2Client;
|
||||
const manager = new Pm2ProcessManager({
|
||||
loadPm2: () => pm2,
|
||||
listTimeoutMs: 10,
|
||||
});
|
||||
|
||||
await expect(manager.list()).rejects.toThrow('PM2 list timed out after 10ms.');
|
||||
await expect(manager.list()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('disconnects a timed-out PM2 connection before releasing the serialized session', async () => {
|
||||
let connectCall = 0;
|
||||
let disconnectCall = 0;
|
||||
const pm2 = {
|
||||
connect(callback: Parameters<Pm2Client['connect']>[0]) {
|
||||
connectCall += 1;
|
||||
if (connectCall > 1) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
disconnect() {
|
||||
disconnectCall += 1;
|
||||
},
|
||||
list(callback: Parameters<Pm2Client['list']>[0]) {
|
||||
callback(null, []);
|
||||
},
|
||||
start() {
|
||||
throw new Error('unused');
|
||||
},
|
||||
stop() {
|
||||
throw new Error('unused');
|
||||
},
|
||||
delete() {
|
||||
throw new Error('unused');
|
||||
},
|
||||
} satisfies Pm2Client;
|
||||
const manager = new Pm2ProcessManager({
|
||||
loadPm2: () => pm2,
|
||||
connectTimeoutMs: 10,
|
||||
});
|
||||
|
||||
await expect(manager.list()).rejects.toThrow('PM2 connect timed out after 10ms.');
|
||||
expect(disconnectCall).toBe(1);
|
||||
await expect(manager.list()).resolves.toEqual([]);
|
||||
expect(disconnectCall).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -240,6 +240,112 @@ test('desktop administrator sidebar follows the navbar away and then sticks to t
|
||||
await writeFile(testInfo.outputPath('admin-sidebar-scroll-geometry.json'), JSON.stringify(measurements, null, 2));
|
||||
});
|
||||
|
||||
test('keeps mobile account actions on clean rows for users and administrators', async ({ browser }, testInfo) => {
|
||||
const measureAccountActions = async (page: Page) => {
|
||||
const actions = page.locator('.account-actions');
|
||||
await expect(actions).toBeVisible();
|
||||
return actions.evaluate((element) => {
|
||||
const rect = (target: Element) => {
|
||||
const value = target.getBoundingClientRect();
|
||||
return {
|
||||
top: value.top,
|
||||
right: value.right,
|
||||
bottom: value.bottom,
|
||||
left: value.left,
|
||||
width: value.width,
|
||||
height: value.height,
|
||||
};
|
||||
};
|
||||
const items = Array.from(element.children).map((child) => ({
|
||||
...rect(child),
|
||||
clientWidth: (child as HTMLElement).clientWidth,
|
||||
scrollWidth: (child as HTMLElement).scrollWidth,
|
||||
whiteSpace: getComputedStyle(child).whiteSpace,
|
||||
}));
|
||||
return {
|
||||
container: rect(element),
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth: window.innerWidth,
|
||||
items,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const userContext = await browser.newContext({ viewport: { width: 500, height: 900 } });
|
||||
const userPage = await userContext.newPage();
|
||||
await installGatewayFixture(userPage, []);
|
||||
await userPage.goto('lobby');
|
||||
const userGeometry = await measureAccountActions(userPage);
|
||||
expect(userGeometry.documentWidth).toBe(userGeometry.viewportWidth);
|
||||
expect(userGeometry.items).toHaveLength(2);
|
||||
expect(userGeometry.items[0]?.top).toBeCloseTo(userGeometry.items[1]?.top ?? 0, 0);
|
||||
expect(userGeometry.items[0]?.bottom).toBeCloseTo(userGeometry.items[1]?.bottom ?? 0, 0);
|
||||
expect(userGeometry.items[0]?.width).toBeCloseTo(userGeometry.items[1]?.width ?? 0, 0);
|
||||
expect(userGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
|
||||
await userPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-user-500px.png'), fullPage: true });
|
||||
await userContext.close();
|
||||
|
||||
const adminContext = await browser.newContext({ viewport: { width: 500, height: 900 } });
|
||||
const adminPage = await adminContext.newPage();
|
||||
await installGatewayFixture(adminPage, ['superuser']);
|
||||
await adminPage.goto('lobby');
|
||||
const adminGeometry = await measureAccountActions(adminPage);
|
||||
expect(adminGeometry.documentWidth).toBe(adminGeometry.viewportWidth);
|
||||
expect(adminGeometry.items).toHaveLength(3);
|
||||
const [account, logout, admin] = adminGeometry.items;
|
||||
expect(account?.top).toBeCloseTo(logout?.top ?? 0, 0);
|
||||
expect(account?.bottom).toBeCloseTo(logout?.bottom ?? 0, 0);
|
||||
expect(account?.width).toBeCloseTo(logout?.width ?? 0, 0);
|
||||
expect(admin?.top).toBeCloseTo((logout?.bottom ?? 0) + 16, 0);
|
||||
expect(admin?.left).toBeCloseTo(account?.left ?? 0, 0);
|
||||
expect(admin?.right).toBeCloseTo(logout?.right ?? 0, 0);
|
||||
expect(adminGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
|
||||
expect(account?.whiteSpace).toBe('nowrap');
|
||||
expect(admin?.whiteSpace).toBe('nowrap');
|
||||
|
||||
const adminLink = adminPage.getByRole('link', { name: '관리자 페이지' });
|
||||
const baseBackground = await adminLink.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
await adminLink.hover();
|
||||
await expect.poll(() => adminLink.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe(
|
||||
baseBackground
|
||||
);
|
||||
await adminLink.focus();
|
||||
await expect(adminLink).toBeFocused();
|
||||
await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-500px.png'), fullPage: true });
|
||||
|
||||
await adminPage.setViewportSize({ width: 390, height: 844 });
|
||||
const adminNarrowGeometry = await measureAccountActions(adminPage);
|
||||
expect(adminNarrowGeometry.documentWidth).toBe(adminNarrowGeometry.viewportWidth);
|
||||
expect(adminNarrowGeometry.items[0]?.top).toBeCloseTo(adminNarrowGeometry.items[1]?.top ?? 0, 0);
|
||||
expect(adminNarrowGeometry.items[2]?.top).toBeCloseTo(
|
||||
(adminNarrowGeometry.items[1]?.bottom ?? 0) + 16,
|
||||
0
|
||||
);
|
||||
expect(adminNarrowGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
|
||||
await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-390px.png'), fullPage: true });
|
||||
|
||||
await adminPage.setViewportSize({ width: 360, height: 800 });
|
||||
const adminSmallGeometry = await measureAccountActions(adminPage);
|
||||
expect(adminSmallGeometry.documentWidth).toBe(adminSmallGeometry.viewportWidth);
|
||||
expect(adminSmallGeometry.items[1]?.top).toBeCloseTo((adminSmallGeometry.items[0]?.bottom ?? 0) + 16, 0);
|
||||
expect(adminSmallGeometry.items[2]?.top).toBeCloseTo((adminSmallGeometry.items[1]?.bottom ?? 0) + 16, 0);
|
||||
expect(adminSmallGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
|
||||
await writeFile(
|
||||
testInfo.outputPath('gateway-account-actions-geometry.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
user: userGeometry,
|
||||
admin: adminGeometry,
|
||||
adminNarrow: adminNarrowGeometry,
|
||||
adminSmall: adminSmallGeometry,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
await adminContext.close();
|
||||
});
|
||||
|
||||
test('legacy server operations URL keeps query parameters and redirects to the server list', async ({ page }) => {
|
||||
await installGatewayFixture(page, ['superuser']);
|
||||
|
||||
|
||||
@@ -826,10 +826,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
>
|
||||
계 정 관 리
|
||||
</div>
|
||||
<div class="p-6 flex justify-center space-x-4">
|
||||
<div class="account-actions p-6">
|
||||
<RouterLink
|
||||
to="/account"
|
||||
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
|
||||
class="account-navigation-button bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
|
||||
>
|
||||
비밀번호 & 전콘 & 탈퇴
|
||||
</RouterLink>
|
||||
@@ -844,7 +844,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
<RouterLink
|
||||
v-if="canAccessAdmin"
|
||||
to="/admin"
|
||||
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
|
||||
class="account-navigation-button account-admin-button bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
|
||||
>
|
||||
관리자 페이지
|
||||
</RouterLink>
|
||||
@@ -858,6 +858,20 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.account-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.account-navigation-button {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.legacy-logout-button {
|
||||
box-sizing: border-box;
|
||||
width: 200px;
|
||||
@@ -1094,4 +1108,38 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
cursor: default;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.account-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.account-actions > * {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-navigation-button {
|
||||
height: 48px;
|
||||
padding: 10px 4px;
|
||||
font-size: clamp(12px, 3.2vw, 16px);
|
||||
line-height: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-admin-button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.account-actions {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.account-admin-button {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -128,3 +128,13 @@ README에 정의된 경로에서 다음 순서로 확인하며, `down --volumes`
|
||||
기능이 어긋날 수 있으므로, 일반 배포의 manifest protocol 검사를 우회하지
|
||||
마세요. Self-upgrade CLI만 다음 protocol을 허용하며 schema head와 component는
|
||||
동일하게 검증합니다.
|
||||
|
||||
Gateway 릴리스가 terminal인데 후속 profile 작업이 `QUEUED`, `attempts=0`에서
|
||||
두 poll 주기 이상 움직이지 않으면 build process를 종료할 문제가 아니라 profile
|
||||
orchestrator poll 자체를 조사합니다. Gateway 전환 직후 reconcile과 worktree cleanup이
|
||||
겹쳐도 PM2 Node client session은 직렬화되며, `connect/list` callback이 5초 안에 오지
|
||||
않으면 해당 scheduled task를 실패시켜 다음 poll로 자동 복구합니다. 이 timeout 전후에
|
||||
PM2 mutation을 수동 반복하지 말고 active Gateway release row가 없는지, orchestrator
|
||||
started 로그와 profile operation attempts가 그대로인지 먼저 확인합니다. 제한 복구가
|
||||
필요하면 현재 PM2 definition의 `sammo:gateway-orchestrator`만 재시작하고 Gateway
|
||||
API/frontend, release-controller, profile daemon과 container는 유지합니다.
|
||||
|
||||
Reference in New Issue
Block a user