实现 Makelore 一键提交审核
需求:让非专业用户在项目操作区一次提交,运营审核通过后直接发布。 实现:由 Electron Main 完成安全打包、自动版本、幂等重试和状态脱敏;补齐友好失败反馈、唯一提交入口及隔离 Electron E2E fixture。
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { closeElectronApp, expect, test } from './fixtures/electron';
|
||||
|
||||
test.describe('NianCode Electron smoke flows', () => {
|
||||
test.describe('Makelore Electron smoke flows', () => {
|
||||
test('shows the setup wizard on a fresh profile', async ({ page }) => {
|
||||
await expect(page.getByTestId('setup-page')).toBeVisible();
|
||||
await expect(page.getByTestId('setup-welcome-step')).toBeVisible();
|
||||
@@ -11,7 +11,7 @@ test.describe('NianCode Electron smoke flows', () => {
|
||||
await expect(page.getByTestId('setup-page')).toBeVisible();
|
||||
await page.getByTestId('setup-skip-button').click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Continue in browser' })).toBeVisible();
|
||||
await expect(page.getByTestId('main-layout')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ test.describe('NianCode Electron smoke flows', () => {
|
||||
const firstWindow = await electronApp.firstWindow();
|
||||
await firstWindow.waitForLoadState('domcontentloaded');
|
||||
await firstWindow.getByTestId('setup-skip-button').click();
|
||||
await expect(firstWindow.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||
await expect(firstWindow.getByRole('button', { name: 'Continue in browser' })).toBeVisible();
|
||||
|
||||
await closeElectronApp(electronApp);
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe('NianCode Electron smoke flows', () => {
|
||||
const relaunchedWindow = await relaunchedApp.firstWindow();
|
||||
await relaunchedWindow.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(relaunchedWindow.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||
await expect(relaunchedWindow.getByRole('button', { name: 'Continue in browser' })).toBeVisible();
|
||||
await expect(relaunchedWindow.getByTestId('setup-page')).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(relaunchedApp);
|
||||
|
||||
273
tests/e2e/fixtures/electron.ts
Normal file
273
tests/e2e/fixtures/electron.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import electronBinaryPath from 'electron';
|
||||
import {
|
||||
_electron as electron,
|
||||
expect,
|
||||
test as base,
|
||||
type ElectronApplication,
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
|
||||
export type LaunchElectronOptions = {
|
||||
skipSetup?: boolean;
|
||||
};
|
||||
|
||||
type ElectronTestResources = {
|
||||
apps: Set<ElectronApplication>;
|
||||
defaultSkipSetup: boolean;
|
||||
homeDir: string;
|
||||
rootDir: string;
|
||||
userDataDir: string;
|
||||
};
|
||||
|
||||
type ElectronFixtures = {
|
||||
electronApp: ElectronApplication;
|
||||
electronTestResources: ElectronTestResources;
|
||||
launchElectronApp: (options?: LaunchElectronOptions) => Promise<ElectronApplication>;
|
||||
page: Page;
|
||||
};
|
||||
|
||||
const repoRoot = resolve(process.cwd());
|
||||
const electronEntry = join(repoRoot, 'dist-electron/main/index.js');
|
||||
const PASSTHROUGH_ENVIRONMENT_KEYS = [
|
||||
'PATH',
|
||||
'Path',
|
||||
'PATHEXT',
|
||||
'SystemRoot',
|
||||
'SYSTEMROOT',
|
||||
'WINDIR',
|
||||
'COMSPEC',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
'TMPDIR',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'DISPLAY',
|
||||
'XAUTHORITY',
|
||||
'WAYLAND_DISPLAY',
|
||||
'XDG_RUNTIME_DIR',
|
||||
'DBUS_SESSION_BUS_ADDRESS',
|
||||
'CI',
|
||||
] as const;
|
||||
|
||||
async function allocatePort(): Promise<number> {
|
||||
return await new Promise((resolvePort, reject) => {
|
||||
const server = createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Failed to allocate an ephemeral port')));
|
||||
return;
|
||||
}
|
||||
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolvePort(address.port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildElectronEnvironment(
|
||||
resources: ElectronTestResources,
|
||||
skipSetup: boolean,
|
||||
hostApiPort: number,
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const key of PASSTHROUGH_ENVIRONMENT_KEYS) {
|
||||
const value = process.env[key];
|
||||
if (value !== undefined) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(env, {
|
||||
HOME: resources.homeDir,
|
||||
USERPROFILE: resources.homeDir,
|
||||
APPDATA: join(resources.homeDir, 'AppData', 'Roaming'),
|
||||
LOCALAPPDATA: join(resources.homeDir, 'AppData', 'Local'),
|
||||
XDG_CONFIG_HOME: join(resources.homeDir, '.config'),
|
||||
NIANCODE_E2E: '1',
|
||||
NIANCODE_USER_DATA_DIR: resources.userDataDir,
|
||||
NIANCODE_PORT_NIANCODE_HOST_API: String(hostApiPort),
|
||||
});
|
||||
|
||||
delete env.NIANCODE_E2E_SKIP_SETUP;
|
||||
if (skipSetup) {
|
||||
env.NIANCODE_E2E_SKIP_SETUP = '1';
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
env.ELECTRON_DISABLE_SANDBOX = '1';
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
async function launchMakeloreElectron(
|
||||
resources: ElectronTestResources,
|
||||
skipSetup: boolean,
|
||||
): Promise<ElectronApplication> {
|
||||
const hostApiPort = await allocatePort();
|
||||
const app = await electron.launch({
|
||||
executablePath: electronBinaryPath as unknown as string,
|
||||
args: [electronEntry],
|
||||
env: buildElectronEnvironment(resources, skipSetup, hostApiPort),
|
||||
timeout: 90_000,
|
||||
});
|
||||
|
||||
resources.apps.add(app);
|
||||
app.once('close', () => {
|
||||
resources.apps.delete(app);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function getStableWindow(app: ElectronApplication): Promise<Page> {
|
||||
const deadline = Date.now() + 30_000;
|
||||
let page = await app.firstWindow();
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const openWindows = app.windows().filter((candidate) => !candidate.isClosed());
|
||||
const currentWindow = openWindows.at(-1) ?? page;
|
||||
|
||||
if (!currentWindow.isClosed()) {
|
||||
try {
|
||||
await currentWindow.waitForLoadState('domcontentloaded', { timeout: 2_000 });
|
||||
return currentWindow;
|
||||
} catch (error) {
|
||||
if (!String(error).includes('has been closed')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
page = await app.waitForEvent('window', { timeout: 2_000 });
|
||||
} catch {
|
||||
// Keep polling until a stable window is available or the deadline expires.
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No stable Electron window became available');
|
||||
}
|
||||
|
||||
export async function closeElectronApp(
|
||||
app: ElectronApplication,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<void> {
|
||||
let closed = false;
|
||||
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
const [closeResult] = await Promise.allSettled([
|
||||
app.waitForEvent('close', { timeout: timeoutMs }),
|
||||
app.evaluate(({ app: electronApp }) => {
|
||||
electronApp.quit();
|
||||
}),
|
||||
]);
|
||||
closed = closeResult.status === 'fulfilled';
|
||||
})(),
|
||||
new Promise((resolveTimeout) => setTimeout(resolveTimeout, timeoutMs)),
|
||||
]);
|
||||
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playwrightClosed = await Promise.race([
|
||||
app.close().then(() => true).catch(() => false),
|
||||
new Promise<boolean>((resolveTimeout) => {
|
||||
setTimeout(() => resolveTimeout(false), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (playwrightClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const childProcess = app.process();
|
||||
try {
|
||||
childProcess.kill('SIGKILL');
|
||||
} catch {
|
||||
// The process may already have exited during teardown.
|
||||
}
|
||||
|
||||
if (childProcess.exitCode === null) {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolveExit) => {
|
||||
childProcess.once('exit', () => resolveExit());
|
||||
}),
|
||||
new Promise<void>((resolveTimeout) => {
|
||||
setTimeout(resolveTimeout, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export const test = base.extend<ElectronFixtures>({
|
||||
electronTestResources: async ({ browserName: _browserName }, provideResources, testInfo) => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'makelore-e2e-'));
|
||||
const homeDir = join(rootDir, 'home');
|
||||
const userDataDir = join(rootDir, 'user-data');
|
||||
await Promise.all([
|
||||
mkdir(join(homeDir, '.config'), { recursive: true }),
|
||||
mkdir(join(homeDir, 'AppData', 'Local'), { recursive: true }),
|
||||
mkdir(join(homeDir, 'AppData', 'Roaming'), { recursive: true }),
|
||||
mkdir(userDataDir, { recursive: true }),
|
||||
]);
|
||||
|
||||
const resources: ElectronTestResources = {
|
||||
apps: new Set(),
|
||||
defaultSkipSetup: ![
|
||||
'app-smoke.spec.ts',
|
||||
'language-russian.spec.ts',
|
||||
].includes(basename(testInfo.file)),
|
||||
homeDir,
|
||||
rootDir,
|
||||
userDataDir,
|
||||
};
|
||||
|
||||
try {
|
||||
await provideResources(resources);
|
||||
} finally {
|
||||
await Promise.allSettled([...resources.apps].map((app) => closeElectronApp(app)));
|
||||
await rm(rootDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 200,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
launchElectronApp: async ({ electronTestResources }, provideLauncher) => {
|
||||
await provideLauncher(async (options = {}) => {
|
||||
const skipSetup = options.skipSetup ?? electronTestResources.defaultSkipSetup;
|
||||
return await launchMakeloreElectron(electronTestResources, skipSetup);
|
||||
});
|
||||
},
|
||||
|
||||
electronApp: async ({ launchElectronApp }, provideElectronApp) => {
|
||||
await provideElectronApp(await launchElectronApp());
|
||||
},
|
||||
|
||||
page: async ({ electronApp }, providePage) => {
|
||||
await providePage(await getStableWindow(electronApp));
|
||||
},
|
||||
});
|
||||
|
||||
export async function completeSetup(page: Page): Promise<void> {
|
||||
const setupPage = page.getByTestId('setup-page');
|
||||
if (await setupPage.isVisible()) {
|
||||
await page.getByTestId('setup-skip-button').click();
|
||||
}
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
}
|
||||
|
||||
export { expect };
|
||||
@@ -52,6 +52,8 @@ test.describe('Russian language localization', () => {
|
||||
|
||||
test('Russian language persists after skipping setup', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp();
|
||||
let relaunchedApp: Awaited<ReturnType<typeof launchElectronApp>> | null = null;
|
||||
let firstAppClosed = false;
|
||||
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
@@ -64,17 +66,27 @@ test.describe('Russian language localization', () => {
|
||||
|
||||
// Skip setup
|
||||
await page.getByTestId('setup-skip-button').click();
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Continue in browser' })).toBeVisible();
|
||||
|
||||
// Navigate to Settings to verify language persistence
|
||||
await openSettingsFromSidebarAccountMenu(page);
|
||||
await expect(page.getByTestId('settings-page')).toBeVisible();
|
||||
await closeElectronApp(app);
|
||||
firstAppClosed = true;
|
||||
|
||||
relaunchedApp = await launchElectronApp({ skipSetup: true });
|
||||
const relaunchedPage = await getStableWindow(relaunchedApp);
|
||||
await expect(relaunchedPage.getByTestId('main-layout')).toBeVisible();
|
||||
|
||||
// Bypass authentication only for the relaunch so the persisted setting
|
||||
// can be verified without weakening the production login gate.
|
||||
await openSettingsFromSidebarAccountMenu(relaunchedPage);
|
||||
await expect(relaunchedPage).toHaveURL(/\/settings$/);
|
||||
await expect(relaunchedPage.getByRole('heading', { name: 'Настройки', exact: true })).toBeVisible();
|
||||
|
||||
// Verify sidebar shows Russian text (not English)
|
||||
// "Настройки" is Russian-only, English is "Settings"
|
||||
await expectSidebarSettingsLabel(page, 'Настройки');
|
||||
await expectSidebarSettingsLabel(relaunchedPage, 'Настройки');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
if (relaunchedApp) await closeElectronApp(relaunchedApp);
|
||||
if (!firstAppClosed) await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -88,11 +100,11 @@ test.describe('Russian language localization', () => {
|
||||
|
||||
// Navigate to Settings (in English by default after skipSetup)
|
||||
await openSettingsFromSidebarAccountMenu(page);
|
||||
await expect(page.getByTestId('settings-page')).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
|
||||
// Click Russian language button
|
||||
const russianButton = page.locator('button', { hasText: 'Русский' });
|
||||
await russianButton.click();
|
||||
// Select Russian from the current settings control.
|
||||
await page.locator('#language').selectOption('ru');
|
||||
await expect(page.getByRole('heading', { name: 'Настройки', exact: true })).toBeVisible();
|
||||
|
||||
// Verify sidebar switched to Russian
|
||||
// "Настройки" is Russian-only, English is "Settings"
|
||||
|
||||
@@ -21,15 +21,16 @@ test.describe('Project-level Superpowers setting', () => {
|
||||
await expect(page.getByLabel('项目路径')).toHaveValue(parentPath);
|
||||
await page.getByRole('button', { name: '确认创建' }).click();
|
||||
|
||||
await expect(page.getByTestId('sidebar-nav-project-config')).toBeVisible();
|
||||
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
|
||||
await expect(page.getByTestId('sidebar-nav-publish')).toHaveCount(0);
|
||||
await expect(page.getByText(/还没有联系人/)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '新增联系人' })).toBeVisible();
|
||||
await expect(page.getByText(/新项目还没有联系人/)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '新增伙伴' })).toBeVisible();
|
||||
await page.getByTestId('resource-card-skills').click();
|
||||
await expect(page.getByRole('switch', { name: '启用 Superpowers' })).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(page.getByText('已关闭')).toBeVisible();
|
||||
await page.getByRole('button', { name: '关闭' }).click();
|
||||
await expect(page.getByTestId('resource-card-publish')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '一键提交审核' })).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await rm(parentPath, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user