574 lines
19 KiB
TypeScript
574 lines
19 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
|
|
|
const TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS = 20 * 60 * 1000;
|
|
const TEST_SERVER_DEPLOYMENT_LOG_LIMIT = 4000;
|
|
const TEST_SERVER_DEPLOYMENT_CLEANUP_DELAY_MS = 15_000;
|
|
|
|
export type TestServerDeploymentStepKey = 'commit-main-worktree' | 'push-origin-main' | 'build-test-app' | 'deploy-test-server';
|
|
export type TestServerDeploymentStepStatus = 'pending' | 'running' | 'completed' | 'failed';
|
|
|
|
export type TestServerDeploymentStepSnapshot = {
|
|
key: TestServerDeploymentStepKey;
|
|
status: TestServerDeploymentStepStatus;
|
|
detail: string | null;
|
|
updatedAt: string | null;
|
|
};
|
|
|
|
export type TestServerDeploymentPhase =
|
|
| 'idle'
|
|
| 'commit-main-worktree'
|
|
| 'push-origin-main'
|
|
| 'build-test-app'
|
|
| 'deploy-test-server'
|
|
| 'completed'
|
|
| 'failed';
|
|
|
|
export type TestServerDeploymentSnapshot = {
|
|
status: 'idle' | 'running' | 'completed' | 'failed';
|
|
phase: TestServerDeploymentPhase;
|
|
summary: string | null;
|
|
startedAt: string | null;
|
|
updatedAt: string | null;
|
|
completedAt: string | null;
|
|
lastError: string | null;
|
|
logExcerpt: string | null;
|
|
steps: TestServerDeploymentStepSnapshot[];
|
|
};
|
|
|
|
type TestServerDeploymentStateFilePayload = {
|
|
status?: unknown;
|
|
phase?: unknown;
|
|
summary?: unknown;
|
|
startedAt?: unknown;
|
|
updatedAt?: unknown;
|
|
completedAt?: unknown;
|
|
lastError?: unknown;
|
|
logExcerpt?: unknown;
|
|
steps?: unknown;
|
|
};
|
|
|
|
type RestartLockPayload = {
|
|
startedAt: string;
|
|
key: 'test';
|
|
pid: number;
|
|
};
|
|
|
|
const TEST_SERVER_DEPLOYMENT_STEP_KEYS: TestServerDeploymentStepKey[] = [
|
|
'commit-main-worktree',
|
|
'push-origin-main',
|
|
'build-test-app',
|
|
'deploy-test-server',
|
|
];
|
|
|
|
function normalizeDateTimeValue(value: string | null | undefined) {
|
|
const normalized = value?.trim();
|
|
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = new Date(normalized);
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
}
|
|
|
|
function trimPreview(value: string | null | undefined, maxLength = 220) {
|
|
const normalized = value?.replace(/\s+/g, ' ').trim() ?? '';
|
|
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized;
|
|
}
|
|
|
|
function getTestServerDeploymentStatePath() {
|
|
return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'test-deployment-state.json');
|
|
}
|
|
|
|
function getTestServerDeploymentLockPath() {
|
|
return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'test-deployment-in-progress.json');
|
|
}
|
|
|
|
function buildEmptyTestServerDeploymentSnapshot(): TestServerDeploymentSnapshot {
|
|
return {
|
|
status: 'idle',
|
|
phase: 'idle',
|
|
summary: null,
|
|
startedAt: null,
|
|
updatedAt: null,
|
|
completedAt: null,
|
|
lastError: null,
|
|
logExcerpt: null,
|
|
steps: TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => ({
|
|
key,
|
|
status: 'pending',
|
|
detail: null,
|
|
updatedAt: null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function normalizeTestServerDeploymentStepKey(value: unknown): TestServerDeploymentStepKey | null {
|
|
return TEST_SERVER_DEPLOYMENT_STEP_KEYS.includes(value as TestServerDeploymentStepKey)
|
|
? (value as TestServerDeploymentStepKey)
|
|
: null;
|
|
}
|
|
|
|
function normalizeTestServerDeploymentPhase(value: unknown): TestServerDeploymentPhase {
|
|
return value === 'commit-main-worktree'
|
|
|| value === 'push-origin-main'
|
|
|| value === 'build-test-app'
|
|
|| value === 'deploy-test-server'
|
|
|| value === 'completed'
|
|
|| value === 'failed'
|
|
? value
|
|
: 'idle';
|
|
}
|
|
|
|
function normalizeTestServerDeploymentStatus(value: unknown): TestServerDeploymentSnapshot['status'] {
|
|
return value === 'running' || value === 'completed' || value === 'failed' ? value : 'idle';
|
|
}
|
|
|
|
function normalizeTestServerDeploymentSteps(value: unknown) {
|
|
const fallback = buildEmptyTestServerDeploymentSnapshot().steps;
|
|
|
|
if (!Array.isArray(value)) {
|
|
return fallback;
|
|
}
|
|
|
|
const normalizedByKey = new Map<TestServerDeploymentStepKey, TestServerDeploymentStepSnapshot>();
|
|
|
|
value.forEach((item) => {
|
|
if (!item || typeof item !== 'object') {
|
|
return;
|
|
}
|
|
|
|
const candidate = item as Record<string, unknown>;
|
|
const key = normalizeTestServerDeploymentStepKey(candidate.key);
|
|
|
|
if (!key) {
|
|
return;
|
|
}
|
|
|
|
normalizedByKey.set(key, {
|
|
key,
|
|
status:
|
|
candidate.status === 'running'
|
|
|| candidate.status === 'completed'
|
|
|| candidate.status === 'failed'
|
|
|| candidate.status === 'pending'
|
|
? candidate.status
|
|
: 'pending',
|
|
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
|
|
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
|
|
});
|
|
});
|
|
|
|
return TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => normalizedByKey.get(key) ?? fallback.find((item) => item.key === key)!);
|
|
}
|
|
|
|
function normalizeTestServerDeploymentSnapshot(value: unknown): TestServerDeploymentSnapshot {
|
|
if (!value || typeof value !== 'object') {
|
|
return buildEmptyTestServerDeploymentSnapshot();
|
|
}
|
|
|
|
const candidate = value as TestServerDeploymentStateFilePayload;
|
|
|
|
return {
|
|
status: normalizeTestServerDeploymentStatus(candidate.status),
|
|
phase: normalizeTestServerDeploymentPhase(candidate.phase),
|
|
summary: typeof candidate.summary === 'string' ? candidate.summary : null,
|
|
startedAt: normalizeDateTimeValue(typeof candidate.startedAt === 'string' ? candidate.startedAt : null),
|
|
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
|
|
completedAt: normalizeDateTimeValue(typeof candidate.completedAt === 'string' ? candidate.completedAt : null),
|
|
lastError: typeof candidate.lastError === 'string' ? candidate.lastError : null,
|
|
logExcerpt: typeof candidate.logExcerpt === 'string' ? candidate.logExcerpt : null,
|
|
steps: normalizeTestServerDeploymentSteps(candidate.steps),
|
|
};
|
|
}
|
|
|
|
export async function readTestServerDeploymentState(): Promise<TestServerDeploymentSnapshot | null> {
|
|
try {
|
|
const raw = await readFile(getTestServerDeploymentStatePath(), 'utf8');
|
|
const snapshot = normalizeTestServerDeploymentSnapshot(JSON.parse(raw));
|
|
return await resolveStaleRunningTestDeployment(snapshot);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function writeTestServerDeploymentState(snapshot: TestServerDeploymentSnapshot) {
|
|
const statePath = getTestServerDeploymentStatePath();
|
|
await mkdir(path.dirname(statePath), { recursive: true });
|
|
await writeFile(statePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
|
|
}
|
|
|
|
async function clearTestServerDeploymentState() {
|
|
await rm(getTestServerDeploymentStatePath(), { force: true }).catch(() => undefined);
|
|
}
|
|
|
|
function buildStaleTestServerDeploymentFailure(snapshot: TestServerDeploymentSnapshot) {
|
|
const stalledAt = snapshot.updatedAt ?? snapshot.startedAt;
|
|
const stalledLabel = stalledAt ? `마지막 상태 갱신 ${stalledAt}` : '상태 갱신 시각 확인 불가';
|
|
return trimPreview(`TEST 배포 상태가 오래 갱신되지 않았고 잠금 파일도 없어 중단된 배포로 처리했습니다. ${stalledLabel}`, 500)
|
|
?? 'TEST 배포 상태가 오래 갱신되지 않아 중단된 배포로 처리했습니다.';
|
|
}
|
|
|
|
async function finalizeStaleRunningTestDeployment(snapshot: TestServerDeploymentSnapshot) {
|
|
const failureMessage = buildStaleTestServerDeploymentFailure(snapshot);
|
|
const now = new Date().toISOString();
|
|
const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key;
|
|
|
|
snapshot.status = 'failed';
|
|
snapshot.phase = 'failed';
|
|
snapshot.summary = buildTestServerDeploymentSummary('failed');
|
|
snapshot.completedAt = now;
|
|
snapshot.updatedAt = now;
|
|
snapshot.lastError = failureMessage;
|
|
|
|
if (activeStep) {
|
|
updateTestServerDeploymentStep(snapshot, activeStep, 'failed', failureMessage);
|
|
}
|
|
|
|
await writeTestServerDeploymentState(snapshot);
|
|
return snapshot;
|
|
}
|
|
|
|
async function resolveStaleRunningTestDeployment(snapshot: TestServerDeploymentSnapshot) {
|
|
if (snapshot.status !== 'running') {
|
|
return snapshot;
|
|
}
|
|
|
|
const freshnessSource = snapshot.updatedAt ?? snapshot.startedAt;
|
|
if (!freshnessSource) {
|
|
return snapshot;
|
|
}
|
|
|
|
const staleForMs = Date.now() - Date.parse(freshnessSource);
|
|
if (!Number.isFinite(staleForMs) || staleForMs < TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) {
|
|
return snapshot;
|
|
}
|
|
|
|
const lockPath = getTestServerDeploymentLockPath();
|
|
const lockStat = await stat(lockPath).catch(() => null);
|
|
|
|
if (lockStat?.isFile()) {
|
|
const lockFreshnessSource = normalizeDateTimeValue(lockStat.mtime.toISOString() ?? null);
|
|
if (lockFreshnessSource && Date.now() - Date.parse(lockFreshnessSource) < TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) {
|
|
return snapshot;
|
|
}
|
|
}
|
|
|
|
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
return finalizeStaleRunningTestDeployment(snapshot);
|
|
}
|
|
|
|
async function acquireTestServerDeploymentLock() {
|
|
const lockPath = getTestServerDeploymentLockPath();
|
|
await mkdir(path.dirname(lockPath), { recursive: true });
|
|
const startedAt = new Date().toISOString();
|
|
|
|
try {
|
|
const handle = await open(lockPath, 'wx');
|
|
|
|
try {
|
|
await handle.writeFile(JSON.stringify({ startedAt, key: 'test', pid: process.pid } satisfies RestartLockPayload) + '\n', 'utf8');
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
|
|
return lockPath;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
throw error;
|
|
}
|
|
|
|
let existingStartedAt: string | null = null;
|
|
|
|
try {
|
|
const raw = await readFile(lockPath, 'utf8');
|
|
const parsed = JSON.parse(raw) as Partial<RestartLockPayload>;
|
|
existingStartedAt = normalizeDateTimeValue(typeof parsed.startedAt === 'string' ? parsed.startedAt : null);
|
|
const lockStat = await stat(lockPath).catch(() => null);
|
|
const freshnessSource = existingStartedAt ?? normalizeDateTimeValue(lockStat?.mtime.toISOString() ?? null);
|
|
|
|
if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) {
|
|
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
return acquireTestServerDeploymentLock();
|
|
}
|
|
} catch {
|
|
// ignore read failures and keep conflict response below
|
|
}
|
|
|
|
const conflictError = new Error(
|
|
existingStartedAt
|
|
? `TEST 배포가 이미 진행 중입니다. 시작 시각 ${existingStartedAt}`
|
|
: 'TEST 배포가 이미 진행 중입니다.',
|
|
);
|
|
(conflictError as Error & { statusCode?: number }).statusCode = 409;
|
|
throw conflictError;
|
|
}
|
|
}
|
|
|
|
function buildTestServerDeploymentSummary(phase: TestServerDeploymentPhase) {
|
|
switch (phase) {
|
|
case 'commit-main-worktree':
|
|
return 'main 작업트리 커밋 진행 중';
|
|
case 'push-origin-main':
|
|
return 'origin/main 푸시 진행 중';
|
|
case 'build-test-app':
|
|
return '테스트 앱 빌드 진행 중';
|
|
case 'deploy-test-server':
|
|
return '테스트 서버 배포 진행 중';
|
|
case 'completed':
|
|
return 'origin/main 푸시, 테스트 빌드, 테스트 배포가 완료되었습니다.';
|
|
case 'failed':
|
|
return 'TEST 배포에 실패했습니다.';
|
|
default:
|
|
return '테스트 배포 준비 중';
|
|
}
|
|
}
|
|
|
|
function buildTestDeploymentFailureMessage(
|
|
snapshot: Pick<TestServerDeploymentSnapshot, 'logExcerpt'>,
|
|
error: unknown,
|
|
) {
|
|
const failure = error instanceof Error ? (error as Error & { code?: number | string; signal?: string | null }) : null;
|
|
const exitInfo = [
|
|
failure?.code != null ? `exit:${String(failure.code)}` : null,
|
|
failure?.signal ? `signal:${String(failure.signal)}` : null,
|
|
].filter(Boolean).join(' ');
|
|
const logLines = (snapshot.logExcerpt ?? '')
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
const lastMeaningfulLog = logLines.length > 0 ? logLines[logLines.length - 1] : null;
|
|
|
|
return trimPreview([
|
|
lastMeaningfulLog && lastMeaningfulLog !== failure?.message ? lastMeaningfulLog : null,
|
|
failure?.message || null,
|
|
exitInfo || null,
|
|
].filter(Boolean).join(' | '), 500) ?? 'TEST 배포에 실패했습니다.';
|
|
}
|
|
|
|
function appendTestServerDeploymentLog(previous: string | null, chunk: string) {
|
|
const normalizedChunk = chunk.trim();
|
|
|
|
if (!normalizedChunk) {
|
|
return previous;
|
|
}
|
|
|
|
const combined = [previous, normalizedChunk].filter(Boolean).join('\n');
|
|
return combined.length > TEST_SERVER_DEPLOYMENT_LOG_LIMIT
|
|
? combined.slice(combined.length - TEST_SERVER_DEPLOYMENT_LOG_LIMIT)
|
|
: combined;
|
|
}
|
|
|
|
function updateTestServerDeploymentStep(
|
|
snapshot: TestServerDeploymentSnapshot,
|
|
key: TestServerDeploymentStepKey,
|
|
status: TestServerDeploymentStepStatus,
|
|
detail?: string | null,
|
|
) {
|
|
const now = new Date().toISOString();
|
|
snapshot.steps = snapshot.steps.map((step) => {
|
|
if (step.key !== key) {
|
|
return step;
|
|
}
|
|
|
|
return {
|
|
...step,
|
|
status,
|
|
detail: detail === undefined ? step.detail : detail,
|
|
updatedAt: now,
|
|
};
|
|
});
|
|
snapshot.updatedAt = now;
|
|
}
|
|
|
|
function markPreviousRunningStepCompleted(snapshot: TestServerDeploymentSnapshot, nextKey: TestServerDeploymentStepKey) {
|
|
const previousRunning = snapshot.steps.find((step) => step.status === 'running' && step.key !== nextKey);
|
|
if (previousRunning) {
|
|
updateTestServerDeploymentStep(snapshot, previousRunning.key, 'completed');
|
|
}
|
|
}
|
|
|
|
function scheduleTestServerDeploymentCleanup(completedAt: string) {
|
|
const timer = setTimeout(() => {
|
|
void (async () => {
|
|
const snapshot = await readTestServerDeploymentState();
|
|
if (snapshot?.status === 'completed' && snapshot.completedAt === completedAt) {
|
|
await clearTestServerDeploymentState();
|
|
}
|
|
})();
|
|
}, TEST_SERVER_DEPLOYMENT_CLEANUP_DELAY_MS);
|
|
|
|
if (typeof timer === 'object' && timer && 'unref' in timer && typeof timer.unref === 'function') {
|
|
timer.unref();
|
|
}
|
|
}
|
|
|
|
async function runTestServerDeployment(
|
|
lockPath: string,
|
|
snapshot: TestServerDeploymentSnapshot,
|
|
persist: () => Promise<void>,
|
|
) {
|
|
const mainProjectRoot = resolveMainProjectRoot();
|
|
const deployScript = path.join(mainProjectRoot, 'etc', 'commands', 'server-command', 'deploy-test.sh');
|
|
|
|
const moveToStep = (key: TestServerDeploymentStepKey) => {
|
|
markPreviousRunningStepCompleted(snapshot, key);
|
|
snapshot.phase = key;
|
|
snapshot.summary = buildTestServerDeploymentSummary(key);
|
|
updateTestServerDeploymentStep(snapshot, key, 'running');
|
|
void persist();
|
|
};
|
|
|
|
const appendOutput = (line: string) => {
|
|
snapshot.logExcerpt = appendTestServerDeploymentLog(snapshot.logExcerpt, line);
|
|
snapshot.updatedAt = new Date().toISOString();
|
|
void persist();
|
|
};
|
|
|
|
const fail = async (message: string) => {
|
|
const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key ?? 'commit-main-worktree';
|
|
snapshot.status = 'failed';
|
|
snapshot.phase = 'failed';
|
|
snapshot.summary = buildTestServerDeploymentSummary('failed');
|
|
snapshot.lastError = message;
|
|
snapshot.updatedAt = new Date().toISOString();
|
|
updateTestServerDeploymentStep(snapshot, activeStep, 'failed', message);
|
|
await persist();
|
|
};
|
|
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const child = spawn('sh', [deployScript], {
|
|
cwd: mainProjectRoot,
|
|
env: {
|
|
...process.env,
|
|
MAIN_PROJECT_ROOT: mainProjectRoot,
|
|
REPO_ROOT: mainProjectRoot,
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
let stdoutBuffer = '';
|
|
let stderrBuffer = '';
|
|
|
|
const processLine = (line: string) => {
|
|
const trimmed = line.trim();
|
|
const marker = trimmed.match(/^::step::([a-z-]+)$/);
|
|
|
|
if (marker) {
|
|
const nextStep = normalizeTestServerDeploymentStepKey(marker[1]);
|
|
if (nextStep) {
|
|
moveToStep(nextStep);
|
|
}
|
|
return;
|
|
}
|
|
|
|
appendOutput(line);
|
|
};
|
|
|
|
const flushBufferedLines = (buffer: string) => {
|
|
const normalized = buffer.replace(/\r$/, '').trim();
|
|
if (normalized) {
|
|
processLine(normalized);
|
|
}
|
|
};
|
|
|
|
const attachReader = (stream: NodeJS.ReadableStream | null, target: 'stdout' | 'stderr') => {
|
|
if (!stream) {
|
|
return;
|
|
}
|
|
|
|
stream.setEncoding('utf8');
|
|
stream.on('data', (chunk: string) => {
|
|
if (target === 'stdout') {
|
|
stdoutBuffer += chunk;
|
|
while (stdoutBuffer.includes('\n')) {
|
|
const index = stdoutBuffer.indexOf('\n');
|
|
const line = stdoutBuffer.slice(0, index).replace(/\r$/, '');
|
|
stdoutBuffer = stdoutBuffer.slice(index + 1);
|
|
processLine(line);
|
|
}
|
|
return;
|
|
}
|
|
|
|
stderrBuffer += chunk;
|
|
while (stderrBuffer.includes('\n')) {
|
|
const index = stderrBuffer.indexOf('\n');
|
|
const line = stderrBuffer.slice(0, index).replace(/\r$/, '');
|
|
stderrBuffer = stderrBuffer.slice(index + 1);
|
|
processLine(line);
|
|
}
|
|
});
|
|
};
|
|
|
|
attachReader(child.stdout, 'stdout');
|
|
attachReader(child.stderr, 'stderr');
|
|
|
|
child.once('error', reject);
|
|
child.once('close', (code, signal) => {
|
|
flushBufferedLines(stdoutBuffer);
|
|
flushBufferedLines(stderrBuffer);
|
|
|
|
if (code === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
reject(Object.assign(new Error(`deploy-test exited with ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}`), {
|
|
code,
|
|
signal,
|
|
}));
|
|
});
|
|
});
|
|
|
|
const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key;
|
|
if (activeStep) {
|
|
updateTestServerDeploymentStep(snapshot, activeStep, 'completed');
|
|
}
|
|
const completedAt = new Date().toISOString();
|
|
snapshot.status = 'completed';
|
|
snapshot.phase = 'completed';
|
|
snapshot.summary = buildTestServerDeploymentSummary('completed');
|
|
snapshot.completedAt = completedAt;
|
|
snapshot.updatedAt = completedAt;
|
|
snapshot.lastError = null;
|
|
await persist();
|
|
scheduleTestServerDeploymentCleanup(completedAt);
|
|
} catch (error) {
|
|
const message = buildTestDeploymentFailureMessage(snapshot, error);
|
|
await fail(message);
|
|
} finally {
|
|
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
export async function startTestServerDeployment() {
|
|
const lockPath = await acquireTestServerDeploymentLock();
|
|
const startedAt = new Date().toISOString();
|
|
const snapshot = buildEmptyTestServerDeploymentSnapshot();
|
|
snapshot.status = 'running';
|
|
snapshot.phase = 'commit-main-worktree';
|
|
snapshot.summary = buildTestServerDeploymentSummary('commit-main-worktree');
|
|
snapshot.startedAt = startedAt;
|
|
snapshot.updatedAt = startedAt;
|
|
updateTestServerDeploymentStep(snapshot, 'commit-main-worktree', 'running', 'main 작업트리 변경을 커밋합니다.');
|
|
await writeTestServerDeploymentState(snapshot);
|
|
|
|
let persistQueue = Promise.resolve();
|
|
const persist = async () => {
|
|
persistQueue = persistQueue.then(() => writeTestServerDeploymentState(snapshot)).catch(() => undefined);
|
|
await persistQueue;
|
|
};
|
|
|
|
void runTestServerDeployment(lockPath, snapshot, persist);
|
|
return snapshot;
|
|
}
|