feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
452
apps/mobile-rn/__tests__/admin-mobile.local.e2e.mjs
Normal file
452
apps/mobile-rn/__tests__/admin-mobile.local.e2e.mjs
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const baseUrl = process.env.D3RO_E2E_SUPABASE_URL;
|
||||
const anonKey = process.env.D3RO_E2E_ANON_KEY;
|
||||
const serviceKey = process.env.D3RO_E2E_SERVICE_KEY;
|
||||
const maestroBin = process.env.D3RO_MAESTRO_BIN;
|
||||
const androidSerial = process.env.ANDROID_SERIAL ?? 'emulator-5554';
|
||||
const dbContainer = process.env.D3RO_LOCAL_DB_CONTAINER ?? 'supabase_db_d3ro-voice';
|
||||
const fixtureOnly = process.env.D3RO_ADMIN_E2E_FIXTURE_ONLY === '1';
|
||||
const contractProbe = process.env.D3RO_ADMIN_E2E_CONTRACT_PROBE === '1';
|
||||
const skipOrdinary = process.env.D3RO_ADMIN_E2E_SKIP_ORDINARY === '1';
|
||||
const skipManager = process.env.D3RO_ADMIN_E2E_SKIP_MANAGER === '1';
|
||||
const repositoryRoot = resolve(import.meta.dirname, '../../..');
|
||||
const flowRoot = resolve(repositoryRoot, 'apps/mobile-rn/.maestro');
|
||||
const outputRoot = resolve(
|
||||
process.env.D3RO_ADMIN_E2E_OUTPUT ?? resolve(repositoryRoot, 'apps/mobile-rn/.maestro-output/admin-local'),
|
||||
);
|
||||
|
||||
if (baseUrl !== 'http://127.0.0.1:55321') {
|
||||
throw new Error('D3RO_E2E_SUPABASE_URL must be the disposable local stack at http://127.0.0.1:55321');
|
||||
}
|
||||
if (!anonKey?.startsWith('sb_publishable_') || !serviceKey?.startsWith('sb_secret_')) {
|
||||
throw new Error('local publishable and secret Supabase keys are required');
|
||||
}
|
||||
if (!maestroBin) throw new Error('D3RO_MAESTRO_BIN is required');
|
||||
|
||||
mkdirSync(outputRoot, { recursive: true });
|
||||
|
||||
let assertions = 0;
|
||||
let fixture = null;
|
||||
const cleanupUserIds = new Set();
|
||||
const evidence = {
|
||||
localOnly: true,
|
||||
endpoint: baseUrl,
|
||||
startedAt: new Date().toISOString(),
|
||||
stages: [],
|
||||
fixtureIds: {},
|
||||
cleanup: null,
|
||||
};
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(`assertion_failed: ${message}`);
|
||||
assertions += 1;
|
||||
}
|
||||
|
||||
function stage(name, details = {}) {
|
||||
evidence.stages.push({ name, at: new Date().toISOString(), ...details });
|
||||
console.log(`[admin-mobile-e2e] ${name}`);
|
||||
}
|
||||
|
||||
function serviceHeaders(extra = {}) {
|
||||
return {
|
||||
apikey: serviceKey,
|
||||
Authorization: `Bearer ${serviceKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function userHeaders(token, extra = {}) {
|
||||
return {
|
||||
apikey: anonKey,
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function jsonRequest(path, init = {}, expected = [200]) {
|
||||
const retryableRead = (init.method ?? 'GET') === 'GET';
|
||||
let response;
|
||||
let lastNetworkError = null;
|
||||
for (let attempt = 1; attempt <= (retryableRead ? 3 : 1); attempt += 1) {
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, init);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastNetworkError = error;
|
||||
if (attempt < 3) await new Promise((resolvePromise) => setTimeout(resolvePromise, attempt * 200));
|
||||
}
|
||||
}
|
||||
if (!response) throw lastNetworkError;
|
||||
const text = await response.text();
|
||||
if (!expected.includes(response.status)) {
|
||||
throw new Error(`${init.method ?? 'GET'} ${path} -> ${response.status}: ${text.slice(0, 400)}`);
|
||||
}
|
||||
return text.length === 0 ? null : JSON.parse(text);
|
||||
}
|
||||
|
||||
async function createAuthUser(key, role, tier, password, nonce) {
|
||||
const email = `mobile-admin-${key}-${nonce}@example.invalid`;
|
||||
const name = `Admin E2E ${key} ${nonce}`;
|
||||
const account = await jsonRequest('/auth/v1/admin/users', {
|
||||
method: 'POST',
|
||||
headers: serviceHeaders(),
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
email_confirm: true,
|
||||
user_metadata: { name, locale: 'en' },
|
||||
app_metadata: { role },
|
||||
}),
|
||||
}, [200, 201]);
|
||||
if (typeof account.id === 'string') cleanupUserIds.add(account.id);
|
||||
assert(typeof account.id === 'string', `${key} auth user was created`);
|
||||
|
||||
await waitForProfile(account.id);
|
||||
await jsonRequest(`/rest/v1/profiles?id=eq.${account.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: serviceHeaders({ Prefer: 'return=representation' }),
|
||||
body: JSON.stringify({ name, role, tier, locale: 'en' }),
|
||||
}, [200]);
|
||||
await jsonRequest(`/auth/v1/admin/users/${account.id}`, {
|
||||
method: 'PUT',
|
||||
headers: serviceHeaders(),
|
||||
body: JSON.stringify({ app_metadata: { role } }),
|
||||
}, [200]);
|
||||
return { id: account.id, email, name, role, tier };
|
||||
}
|
||||
|
||||
async function waitForProfile(userId) {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
const rows = await jsonRequest(`/rest/v1/profiles?id=eq.${userId}&select=id`, {
|
||||
headers: serviceHeaders(),
|
||||
});
|
||||
if (rows.length === 1) return;
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
|
||||
}
|
||||
throw new Error(`profile trigger did not create ${userId}`);
|
||||
}
|
||||
|
||||
async function setSubscription(userId, tier, credits, note) {
|
||||
const periodStart = new Date(Date.now() - 60_000).toISOString();
|
||||
const periodEnd = new Date(Date.now() + 86_400_000).toISOString();
|
||||
const rows = await jsonRequest(`/rest/v1/subscriptions?user_id=eq.${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: serviceHeaders({ Prefer: 'return=representation' }),
|
||||
body: JSON.stringify({
|
||||
tier,
|
||||
status: 'active',
|
||||
provider: tier === 'free' ? 'none' : 'admin',
|
||||
payment_provider: 'none',
|
||||
current_period_start: periodStart,
|
||||
current_period_end: periodEnd,
|
||||
cancel_at: null,
|
||||
overage_credits: credits,
|
||||
admin_note: note,
|
||||
}),
|
||||
}, [200]);
|
||||
assert(rows.length === 1 && rows[0].tier === tier, `${userId} subscription tier is ${tier}`);
|
||||
}
|
||||
|
||||
async function setRole(account, role) {
|
||||
const profiles = await jsonRequest(`/rest/v1/profiles?id=eq.${account.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: serviceHeaders({ Prefer: 'return=representation' }),
|
||||
body: JSON.stringify({ role }),
|
||||
}, [200]);
|
||||
assert(profiles.length === 1 && profiles[0].role === role, `${account.id} profile role is ${role}`);
|
||||
const user = await jsonRequest(`/auth/v1/admin/users/${account.id}`, {
|
||||
method: 'PUT',
|
||||
headers: serviceHeaders(),
|
||||
body: JSON.stringify({ app_metadata: { role } }),
|
||||
}, [200]);
|
||||
assert(user.app_metadata?.role === role, `${account.id} auth role is ${role}`);
|
||||
account.role = role;
|
||||
}
|
||||
|
||||
async function login(account, password) {
|
||||
const session = await jsonRequest('/auth/v1/token?grant_type=password', {
|
||||
method: 'POST',
|
||||
headers: { apikey: anonKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: account.email, password }),
|
||||
});
|
||||
assert(session.user?.id === account.id, `${account.role} fixture login returned its own account`);
|
||||
return session;
|
||||
}
|
||||
|
||||
function redacted(value) {
|
||||
if (!fixture) return value;
|
||||
let output = value;
|
||||
for (const account of Object.values(fixture.accounts)) {
|
||||
output = output.replaceAll(account.email, '[fixture-email]');
|
||||
}
|
||||
return output.replaceAll(fixture.password, '[fixture-password]');
|
||||
}
|
||||
|
||||
function runMaestro(flowName, actor, extraEnv = {}) {
|
||||
const flowPath = resolve(flowRoot, flowName);
|
||||
const result = spawnSync(maestroBin, ['test', flowPath], {
|
||||
cwd: outputRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
ANDROID_SERIAL: androidSerial,
|
||||
MAESTRO_ACTOR_EMAIL: actor.email,
|
||||
MAESTRO_ACTOR_PASSWORD: fixture.password,
|
||||
MAESTRO_TARGET_ID: fixture.accounts.target.id,
|
||||
MAESTRO_TARGET_NAME: fixture.accounts.target.name,
|
||||
...extraEnv,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
shell: true,
|
||||
timeout: 600_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const combined = redacted(`${result.stdout ?? ''}\n${result.stderr ?? ''}`);
|
||||
writeFileSync(resolve(outputRoot, `${flowName}.log`), combined, 'utf8');
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Maestro ${flowName} failed (${result.status}); see sanitized log`);
|
||||
}
|
||||
assert(true, `${flowName} passed`);
|
||||
}
|
||||
|
||||
function adb(...args) {
|
||||
return execFileSync('adb', ['-s', androidSerial, ...args], {
|
||||
encoding: 'utf8',
|
||||
timeout: 60_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
function dumpUi(name) {
|
||||
adb('shell', 'uiautomator', 'dump', '/sdcard/d3ro-admin-e2e.xml');
|
||||
const xml = adb('exec-out', 'cat', '/sdcard/d3ro-admin-e2e.xml');
|
||||
writeFileSync(resolve(outputRoot, `${name}.xml`), redacted(xml), 'utf8');
|
||||
return xml;
|
||||
}
|
||||
|
||||
function assertRoleOptionPermissions(xml) {
|
||||
for (const [role, expectedEnabled] of [
|
||||
['user', 'true'],
|
||||
['manager', 'true'],
|
||||
['admin', 'false'],
|
||||
['super_admin', 'false'],
|
||||
]) {
|
||||
const node = xml.match(new RegExp(`<node[^>]*(?:resource-id|content-desc)="[^"]*admin-role-option-${role}[^"]*"[^>]*>`))?.[0];
|
||||
assert(node !== undefined, `admin ${role} role option is present in the native accessibility tree`);
|
||||
assert(node.includes(`enabled="${expectedEnabled}"`), `admin ${role} role option enabled=${expectedEnabled}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function subscriptionFor(userId) {
|
||||
const rows = await jsonRequest(`/rest/v1/subscriptions?user_id=eq.${userId}&select=*`, {
|
||||
headers: serviceHeaders(),
|
||||
});
|
||||
assert(rows.length === 1, `${userId} has exactly one subscription`);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function profileFor(userId) {
|
||||
const rows = await jsonRequest(`/rest/v1/profiles?id=eq.${userId}&select=id,role,tier`, {
|
||||
headers: serviceHeaders(),
|
||||
});
|
||||
assert(rows.length === 1, `${userId} has exactly one profile`);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
function edgeLogsSince(since) {
|
||||
const result = spawnSync('docker', ['logs', '--since', since, 'supabase_edge_runtime_d3ro-voice'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status !== 0) throw new Error('failed to read local edge runtime logs');
|
||||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
}
|
||||
|
||||
async function createFixture() {
|
||||
const nonce = `${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const password = `D3roAdminLocal${randomUUID().replaceAll('-', '')}A7`;
|
||||
const definitions = [
|
||||
['user', 'user', 'free'],
|
||||
['manager', 'manager', 'pro'],
|
||||
['admin', 'admin', 'pro'],
|
||||
['super', 'super_admin', 'pro'],
|
||||
['target', 'user', 'pro'],
|
||||
];
|
||||
const accounts = {};
|
||||
for (const [key, role, tier] of definitions) {
|
||||
accounts[key] = await createAuthUser(key, role, tier, password, nonce);
|
||||
}
|
||||
await setSubscription(accounts.user.id, 'free', 0, 'local ordinary user fixture');
|
||||
await setSubscription(accounts.manager.id, 'pro', 0, 'local manager fixture');
|
||||
await setSubscription(accounts.admin.id, 'pro', 3, 'local paid admin fixture');
|
||||
await setSubscription(accounts.super.id, 'pro', 0, 'local super admin fixture');
|
||||
await setSubscription(accounts.target.id, 'pro', 5, 'local manager update target');
|
||||
for (const account of Object.values(accounts)) {
|
||||
const profile = await profileFor(account.id);
|
||||
assert(profile.role === account.role, `${account.role} profile role readback matches`);
|
||||
}
|
||||
return { nonce, password, accounts };
|
||||
}
|
||||
|
||||
function actorIdsSql(ids) {
|
||||
for (const id of ids) {
|
||||
assert(/^[0-9a-f-]{36}$/i.test(id), 'cleanup id is a UUID');
|
||||
}
|
||||
return ids.map((id) => `'${id}'::uuid`).join(', ');
|
||||
}
|
||||
|
||||
async function cleanupFixture() {
|
||||
const ids = [...cleanupUserIds];
|
||||
if (ids.length === 0) return { operationRows: 0, auditRows: 0, profiles: 0, authUsers: 0 };
|
||||
const sqlIds = actorIdsSql(ids);
|
||||
execFileSync('docker', [
|
||||
'exec', dbContainer, 'psql', '-U', 'postgres', '-d', 'postgres', '-v', 'ON_ERROR_STOP=1',
|
||||
'-c', `BEGIN; DELETE FROM public.admin_operation_requests WHERE actor_id IN (${sqlIds}); DELETE FROM public.audit_log WHERE admin_id IN (${sqlIds}); DELETE FROM auth.users WHERE id IN (${sqlIds}); COMMIT;`,
|
||||
], { encoding: 'utf8', timeout: 30_000, windowsHide: true });
|
||||
const counts = execFileSync('docker', [
|
||||
'exec', dbContainer, 'psql', '-U', 'postgres', '-d', 'postgres', '-v', 'ON_ERROR_STOP=1',
|
||||
'-Atc', `SELECT (SELECT count(*) FROM auth.users WHERE id IN (${sqlIds})) || ',' || (SELECT count(*) FROM public.profiles WHERE id IN (${sqlIds})) || ',' || (SELECT count(*) FROM public.admin_operation_requests WHERE actor_id IN (${sqlIds})) || ',' || (SELECT count(*) FROM public.audit_log WHERE admin_id IN (${sqlIds}));`,
|
||||
], { encoding: 'utf8', timeout: 30_000, windowsHide: true }).trim().split(',').map(Number);
|
||||
assert(counts.length === 4 && counts.every(Number.isSafeInteger), 'cleanup readback returned four integer counts');
|
||||
return {
|
||||
authUsers: counts[0],
|
||||
profiles: counts[1],
|
||||
operationRows: counts[2],
|
||||
auditRows: counts[3],
|
||||
};
|
||||
}
|
||||
|
||||
let primaryError = null;
|
||||
try {
|
||||
stage('fixture-create');
|
||||
fixture = await createFixture();
|
||||
evidence.fixtureIds = Object.fromEntries(
|
||||
Object.entries(fixture.accounts).map(([key, account]) => [key, account.id]),
|
||||
);
|
||||
|
||||
if (fixtureOnly) {
|
||||
stage('fixture-only-readback-complete');
|
||||
} else if (contractProbe) {
|
||||
const managerSession = await login(fixture.accounts.manager, fixture.password);
|
||||
const userPayload = await jsonRequest(
|
||||
`/functions/v1/admin-users?userId=${fixture.accounts.target.id}`,
|
||||
{ headers: userHeaders(managerSession.access_token) },
|
||||
);
|
||||
const paymentPayload = await jsonRequest(
|
||||
`/functions/v1/admin-payments?userId=${fixture.accounts.target.id}&source=db`,
|
||||
{ headers: userHeaders(managerSession.access_token) },
|
||||
);
|
||||
evidence.contractProbe = {
|
||||
adminUsersTopLevelKeys: Object.keys(userPayload).sort(),
|
||||
adminUsersSubscriptionKeys: Object.keys(userPayload.subscription ?? {}).sort(),
|
||||
adminPaymentsTopLevelKeys: Object.keys(paymentPayload).sort(),
|
||||
adminPaymentsSubscriptionKeys: Object.keys(paymentPayload.subscription ?? {}).sort(),
|
||||
};
|
||||
stage('admin-contract-probe-complete');
|
||||
} else {
|
||||
if (skipManager) {
|
||||
await setSubscription(
|
||||
fixture.accounts.target.id,
|
||||
'pro',
|
||||
17,
|
||||
'local manager update target',
|
||||
);
|
||||
}
|
||||
const staleManagerSession = await login(fixture.accounts.manager, fixture.password);
|
||||
|
||||
if (skipOrdinary) {
|
||||
stage('ordinary-user-stage-skipped-after-prior-pass');
|
||||
} else {
|
||||
stage('ordinary-user-hidden-admin-and-test-ad');
|
||||
const userFlowStartedAt = new Date().toISOString();
|
||||
runMaestro('admin-local-ordinary.yaml', fixture.accounts.user);
|
||||
runMaestro('admin-local-ordinary-settings.yaml', fixture.accounts.user);
|
||||
const userLogs = edgeLogsSince(userFlowStartedAt);
|
||||
const ordinaryAdminRequests = (userLogs.match(/supabase\/functions\/admin-/g) ?? []).length;
|
||||
assert(ordinaryAdminRequests === 0, 'ordinary user emitted no admin Edge Function request');
|
||||
evidence.stages.at(-1).adminRequestCount = ordinaryAdminRequests;
|
||||
}
|
||||
|
||||
if (skipManager) {
|
||||
stage('manager-stage-skipped-after-prior-pass');
|
||||
} else {
|
||||
stage('manager-read-and-subscription-update');
|
||||
runMaestro('admin-local-manager.yaml', fixture.accounts.manager);
|
||||
runMaestro('admin-local-manager-read.yaml', fixture.accounts.manager);
|
||||
runMaestro('admin-local-manager-update.yaml', fixture.accounts.manager);
|
||||
const afterManager = await subscriptionFor(fixture.accounts.target.id);
|
||||
assert(afterManager.tier === 'pro' && afterManager.status === 'active', 'manager preserved target entitlement');
|
||||
assert(afterManager.overage_credits === 17, 'manager updated target overage credits');
|
||||
assert(afterManager.admin_note === 'local manager update target', 'manager preserved the target admin note');
|
||||
evidence.stages.at(-1).subscription = {
|
||||
tier: afterManager.tier,
|
||||
status: afterManager.status,
|
||||
overageCredits: afterManager.overage_credits,
|
||||
};
|
||||
}
|
||||
|
||||
stage('admin-paid-hidden-ad-and-role-boundary');
|
||||
runMaestro('admin-local-admin-open.yaml', fixture.accounts.admin);
|
||||
assertRoleOptionPermissions(dumpUi('admin-role-modal-accessibility'));
|
||||
runMaestro('admin-local-admin-complete.yaml', fixture.accounts.admin);
|
||||
const afterAdminProfile = await profileFor(fixture.accounts.target.id);
|
||||
const afterAdminSubscription = await subscriptionFor(fixture.accounts.target.id);
|
||||
assert(afterAdminProfile.role === 'manager', 'admin changed an ordinary user only to manager');
|
||||
assert(afterAdminSubscription.status === 'active', 'canceling destructive confirmation preserved subscription');
|
||||
assert(afterAdminSubscription.overage_credits === 17, 'canceling destructive confirmation preserved subscription data');
|
||||
evidence.stages.at(-1).targetRole = afterAdminProfile.role;
|
||||
evidence.stages.at(-1).subscriptionStatusAfterCancel = afterAdminSubscription.status;
|
||||
|
||||
stage('super-admin-privileged-role-change');
|
||||
runMaestro('admin-local-super.yaml', fixture.accounts.super);
|
||||
const afterSuperProfile = await profileFor(fixture.accounts.target.id);
|
||||
assert(afterSuperProfile.role === 'admin', 'super admin changed manager to admin');
|
||||
evidence.stages.at(-1).targetRole = afterSuperProfile.role;
|
||||
|
||||
stage('stale-manager-open');
|
||||
runMaestro('admin-local-stale-open.yaml', fixture.accounts.manager);
|
||||
await setRole(fixture.accounts.manager, 'user');
|
||||
const staleResponse = await fetch(`${baseUrl}/functions/v1/admin-users?page=1&limit=20`, {
|
||||
headers: userHeaders(staleManagerSession.access_token),
|
||||
});
|
||||
const staleResponseText = await staleResponse.text();
|
||||
assert(staleResponse.status === 403, 'stale manager JWT receives an immediate 403 from current-role verification');
|
||||
assert(!staleResponseText.includes(fixture.accounts.target.id), '403 response contains no target state');
|
||||
evidence.stages.at(-1).staleHttpStatus = staleResponse.status;
|
||||
runMaestro('admin-local-stale-check.yaml', fixture.accounts.manager);
|
||||
const staleUi = dumpUi('stale-manager-purged-state');
|
||||
assert(staleUi.includes('admin-error'), 'stale manager UI renders the fail-closed admin error');
|
||||
assert(!staleUi.includes(fixture.accounts.target.id), 'stale manager UI purged the target identifier');
|
||||
|
||||
stage('role-e2e-complete', { assertions });
|
||||
}
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
evidence.failure = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
stage('fixture-cleanup');
|
||||
try {
|
||||
evidence.cleanup = await cleanupFixture();
|
||||
assert(
|
||||
Object.values(evidence.cleanup).every((count) => count === 0),
|
||||
'all disposable local fixture rows and auth users were removed',
|
||||
);
|
||||
} catch (cleanupError) {
|
||||
evidence.cleanup = { error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) };
|
||||
if (!primaryError) primaryError = cleanupError;
|
||||
}
|
||||
evidence.finishedAt = new Date().toISOString();
|
||||
evidence.assertions = assertions;
|
||||
writeFileSync(resolve(outputRoot, 'evidence.json'), `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
if (primaryError) throw primaryError;
|
||||
console.log(`[admin-mobile-e2e] ${assertions} assertions passed; fixture cleanup verified`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue