test: verify inheritance parity and permissions

This commit is contained in:
2026-07-26 05:04:51 +00:00
parent 430193ef88
commit 7d41bfa957
5 changed files with 588 additions and 16 deletions
+266
View File
@@ -0,0 +1,266 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 7,
userId: 'user-1',
name: '유비',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 70,
strength: 45,
intel: 85,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 1,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: new Date('2026-07-26T00:00:00Z'),
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'che_선봉',
lastTurn: {},
meta: {},
penalty: {},
createdAt: new Date('2026-07-26T00:00:00Z'),
updatedAt: new Date('2026-07-26T00:00:00Z'),
...overrides,
});
const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: `session-${userId}`,
user: {
id: userId,
username: userId,
displayName: userId,
roles: [],
},
sanctions: {},
});
const worldState = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 4,
tickSeconds: 3600,
config: {
const: {
availableSpecialWar: ['che_선봉'],
allItems: {
weapon: {
che_무기_12_칠성검: 1,
che_무기_01_단도: 0,
},
},
},
},
meta: { hiddenSeed: 'test-seed', isUnited: 0, season: 1 },
updatedAt: new Date('2026-07-26T00:00:00Z'),
};
const buildContext = (options: {
auth?: GameSessionTokenPayload | null;
general?: GeneralRow | null;
target?: GeneralRow | null;
inheritancePoint?: number;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
const target =
options.target === undefined
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
: options.target;
const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({
type: command.type,
ok: true,
generalId: command.generalId,
}));
const pointUpsert = vi.fn(async () => ({}));
const logCreate = vi.fn(async () => ({}));
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
const db = {
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
worldState: {
findFirst: vi.fn(async () => worldState),
},
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
general?.userId === where.userId ? general : null
),
findMany,
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
target?.id === where.id ? target : null
),
},
inheritancePoint: {
upsert: pointUpsert,
},
inheritanceLog: {
create: logCreate,
findMany: vi.fn(async () => []),
},
inheritanceUserState: {
findUnique: vi.fn(async () => null),
upsert: vi.fn(async () => ({})),
},
};
const accessTokenStore = new RedisAccessTokenStore(
{
get: async () => null,
set: async () => null,
},
'che:default'
);
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: {} as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore,
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { context, requestCommand, pointUpsert, logCreate, findMany };
};
describe('inherit router actor and permission boundaries', () => {
it('rejects unauthenticated status and mutations', async () => {
const fixture = buildContext({ auth: null });
const caller = appRouter.createCaller(fixture.context);
await expect(caller.inherit.getStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('builds status only from the authenticated user general and filters target generals like ref', async () => {
const fixture = buildContext({});
const status = await appRouter.createCaller(fixture.context).inherit.getStatus();
expect(status.currentStat).toEqual({ leadership: 70, strength: 45, intel: 85 });
expect(status.availableTargetGenerals).toEqual([{ id: 8, name: '조조' }]);
expect(status.availableUnique).toEqual([
expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }),
]);
expect(status.buffLevels).toHaveProperty('domesticSuccessProb', 0);
expect(fixture.findMany).toHaveBeenCalledWith({
where: { id: { not: 7 }, npcState: { lt: 2 }, userId: { not: null } },
select: { id: true, name: true },
orderBy: { id: 'asc' },
});
});
it('does not dispatch or charge when the authenticated user owns no general', async () => {
const fixture = buildContext({
auth: buildAuth('user-2'),
general: buildGeneral({ userId: 'user-1' }),
});
await expect(
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
type: 'domesticSuccessProb',
level: 1,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: '장수가 존재하지 않습니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
});
it('mutates only the authenticated user general and inheritance balance', async () => {
const fixture = buildContext({ inheritancePoint: 1000 });
await expect(
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
type: 'domesticSuccessProb',
level: 1,
})
).resolves.toEqual({ ok: true, remainPoint: 800 });
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
type: 'patchGeneral',
generalId: 7,
patch: expect.objectContaining({
meta: expect.objectContaining({
inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }),
}),
}),
})
);
expect(fixture.pointUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 800 },
})
);
});
it('reveals a target owner to the caller without using the caller general id from input', async () => {
const fixture = buildContext({ inheritancePoint: 1500 });
await expect(
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
).resolves.toEqual({
ok: true,
ownerName: '위유저',
targetName: '조조',
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 500 },
})
);
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '1000 포인트로 장수 소유자 확인',
},
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
});
+17 -16
View File
@@ -43,22 +43,23 @@ storage, route guards, and image loading.
## Enforced contracts
| Screen | Ref entry point | Current automated contract |
| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
| Screen | Ref entry point | Current automated contract |
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error |
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
The global game baseline is black, white, Pretendard 14px. Legacy texture
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import type { General } from '../src/domain/entities.js';
import { createInheritBuffModules } from '../src/inheritance/inheritBuff.js';
import { GeneralActionPipeline } from '../src/triggers/general-action.js';
const buildGeneral = (inheritBuff: Record<string, number>): General => ({
id: 1,
name: 'Tester',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
experience: 0,
dedication: 0,
officerLevel: 0,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 0,
rice: 0,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 20,
npcState: 0,
triggerState: {
flags: {},
counters: {},
modifiers: {},
meta: {},
},
meta: { killturn: 24, inheritBuff: JSON.stringify(inheritBuff) },
});
describe('inheritance buff legacy keys', () => {
it('applies the canonical legacy domestic buff names', () => {
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
const context = {
general: buildGeneral({
domesticSuccessProb: 3,
domesticFailProb: 2,
}),
};
expect(pipeline.onCalcDomestic(context, '농업', 'success', 0.5)).toBeCloseTo(0.53);
expect(pipeline.onCalcDomestic(context, '상업', 'fail', 0.2)).toBeCloseTo(0.18);
});
it('continues to read the earlier core success and fail aliases', () => {
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
const context = {
general: buildGeneral({
success: 2,
fail: 1,
}),
};
expect(pipeline.onCalcDomestic(context, '치안', 'success', 0.5)).toBeCloseTo(0.52);
expect(pipeline.onCalcDomestic(context, '성벽', 'fail', 0.2)).toBeCloseTo(0.19);
});
});
@@ -0,0 +1,237 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import { dirname, extname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const imageRoot = resolve(repositoryRoot, '../../image');
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
const response = (data: unknown) => ({ result: { data } });
const operations = (route: Route): string[] => {
const pathname = new URL(route.request().url()).pathname;
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const installImages = async (page: Page): Promise<void> => {
await page.route('**/image/**', async (route) => {
const relative = decodeURIComponent(new URL(route.request().url()).pathname).replace(/^\/image\//, '');
for (const candidate of [
resolve(imageRoot, relative),
resolve(imageRoot, 'game', relative),
resolve(imageRoot, 'icons', '22.jpg'),
]) {
try {
const body = await readFile(candidate);
await route.fulfill({
status: 200,
contentType: extname(candidate).toLowerCase() === '.png' ? 'image/png' : 'image/jpeg',
body,
});
return;
} catch {
// 다음 공개 image root 후보를 확인한다.
}
}
await route.abort('failed');
});
};
const statusFixture = {
items: {
previous: 12_000,
lived_month: 240,
max_domestic_critical: 80,
active_action: 35,
combat: 150,
sabotage: 60,
dex: 42,
unifier: 0,
tournament: 30,
betting: 20,
max_belong: 8,
},
totalPoint: 12_665,
inheritConst: {
minMonthToAllowInheritItem: 4,
inheritBornSpecialPoint: 6000,
inheritBornTurntimePoint: 2500,
inheritBornCityPoint: 1000,
inheritBornStatPoint: 1000,
inheritItemUniqueMinPoint: 5000,
inheritItemRandomPoint: 3000,
inheritBuffPoints: [0, 200, 600, 1200, 2000, 3000],
inheritSpecificSpecialPoint: 4000,
inheritResetAttrPointBase: [1000, 1000, 2000, 3000],
inheritCheckOwnerPoint: 1000,
},
buffLevels: {
warAvoidRatio: 0,
warCriticalRatio: 1,
warMagicTrialProb: 0,
domesticSuccessProb: 0,
domesticFailProb: 0,
warAvoidRatioOppose: 0,
warCriticalRatioOppose: 0,
warMagicTrialProbOppose: 0,
},
resetCosts: { resetSpecialWar: 1000, resetTurnTime: 1000 },
resetLevels: { resetSpecialWar: 0, resetTurnTime: 0 },
availableSpecialWar: [{ key: 'che_선봉', name: '선봉', info: '공격에 유리합니다.' }],
availableUnique: [
{
key: 'che_무기_12_칠성검',
name: '칠성검(+12)',
rawName: '칠성검',
info: '무력을 올려주는 유니크 무기입니다.',
},
],
availableTargetGenerals: [{ id: 8, name: '조조' }],
turnTimeZones: ['00:00'],
isUnited: false,
currentSpecialWar: 'che_선봉',
currentStat: { leadership: 70, strength: 45, intel: 85 },
};
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => {
let buffMutationCount = 0;
await installImages(page);
await page.addInitScript(() => {
window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token');
window.localStorage.setItem('sammo-game-profile', 'che');
});
await page.route('**/che/api/trpc/**', async (route) => {
const names = operations(route);
if (options.failBuff && names.includes('inherit.buyHiddenBuff')) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { message: '의도한 유산 구입 오류' } }),
});
return;
}
const result = names.map((name) => {
if (name === 'inherit.getStatus') return response(statusFixture);
if (name === 'lobby.info') {
return response({
profile: { id: 'che', scenario: 'default', name: '체섭' },
world: { year: 200, month: 4 },
myGeneral: { id: 7, name: '유비', nationId: 1 },
});
}
if (name === 'inherit.getLogs') {
return response([
{
id: 2,
year: 200,
month: 4,
text: '1000 포인트로 장수 소유자 확인',
createdAt: '2026-07-26T00:00:00.000Z',
},
]);
}
if (name === 'join.getConfig') {
return response({ rules: { stat: { total: 200, min: 10, max: 100 } } });
}
if (name === 'inherit.buyHiddenBuff') {
buffMutationCount += 1;
return response({ ok: true, remainPoint: 11_800 });
}
throw new Error(`Unhandled inheritance fixture operation: ${name}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(result),
});
});
return { buffMutationCount: () => buffMutationCount };
};
test.describe('inheritance management legacy parity', () => {
test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => {
await installFixture(page);
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('http://127.0.0.1:15102/che/inherit');
await expect(page.locator('#container')).toBeVisible();
await expect(page.locator('#specific-unique')).toHaveValue('che_무기_12_칠성검');
const desktop = await page.evaluate(() => {
const rect = (selector: string) => {
const box = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
return { x: box.x, width: box.width };
};
const container = getComputedStyle(document.querySelector<HTMLElement>('#container')!);
const title = getComputedStyle(document.querySelector<HTMLElement>('.section-title')!);
const button = getComputedStyle(document.querySelector<HTMLElement>('.buy-button')!);
return {
container: rect('#container'),
firstPoint: rect('#inherit_sum'),
fontFamily: container.fontFamily,
fontSize: container.fontSize,
backgroundImage: container.backgroundImage,
titleBackgroundImage: title.backgroundImage,
buttonBackground: button.backgroundColor,
};
});
expect(desktop.container.width).toBe(1000);
expect(desktop.container.x).toBe(140);
expect(desktop.firstPoint.width).toBeCloseTo(327.3, 0);
expect(desktop.fontFamily).toContain('Pretendard');
expect(desktop.fontSize).toBe('14px');
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
expect(desktop.titleBackgroundImage).toContain('back_green.jpg');
const buyButton = page.locator('.buy-button').first();
const beforeHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
await buyButton.hover();
const afterHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
expect(afterHover).not.toBe(beforeHover);
await buyButton.focus();
await expect(buyButton).toBeFocused();
if (artifactRoot) {
await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-desktop.png'), fullPage: true });
}
await page.setViewportSize({ width: 500, height: 900 });
await page.reload();
await expect(page.locator('#container')).toBeVisible();
const mobile = await page.evaluate(() => {
const container = document.querySelector<HTMLElement>('#container')!.getBoundingClientRect();
const first = document.querySelector<HTMLElement>('#inherit_sum')!.getBoundingClientRect();
const second = document.querySelector<HTMLElement>('#inherit_previous')!.getBoundingClientRect();
return {
containerWidth: container.width,
firstWidth: first.width,
stacked: second.y > first.y,
};
});
expect(mobile.containerWidth).toBe(500);
expect(mobile.firstWidth).toBeCloseTo(482, 0);
expect(mobile.stacked).toBe(true);
});
test('submits a legacy buff purchase and refreshes status and logs', async ({ page }) => {
const fixture = await installFixture(page);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('http://127.0.0.1:15102/che/inherit');
await page.locator('#buff-warAvoidRatio').fill('1');
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
await expect.poll(fixture.buffMutationCount).toBe(1);
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
});
test('keeps controls usable and renders an API mutation error', async ({ page }) => {
await installFixture(page, { failBuff: true });
page.on('dialog', (dialog) => dialog.accept());
await page.goto('http://127.0.0.1:15102/che/inherit');
await page.locator('#buff-warAvoidRatio').fill('1');
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
await expect(page.locator('[role="alert"]')).toBeVisible();
await expect(page.locator('#buff-warAvoidRatio')).toHaveValue('1');
await expect(page.locator('#buff-warAvoidRatio')).toBeEnabled();
});
});
@@ -13,6 +13,7 @@ export default defineConfig({
'public-gaps.spec.ts',
'instant-diplomacy-message.spec.ts',
'tournament-betting.spec.ts',
'inheritance-management.spec.ts',
],
fullyParallel: false,
workers: 1,