Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
416 changes: 209 additions & 207 deletions packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions qa-contract/contract-id.testnet.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"network": "testnet",
"contractId": "67ctgcKJgCs7U4hhAxGj1QQUVq15xkkvMk88CT2AbjCF",
"ownerId": "85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH",
"contractId": "9tshSfq5TU3gAEJCVa6KWxmb7cS7kkzRSmSSTmteKGcU",
"ownerId": "2YBjQKEP6APyu9JADk46yoELL2aKAcRW7yCLTCV2d4dD",
"documentTypes": [
"app",
"tier",
"category",
"testCase",
"testRun"
],
"schemaSha": "28818bca69425d87",
"planCommit": "45fdf33901",
"registeredAt": "2026-06-16T03:41:27.236Z"
"schemaSha": "ed751b6e708d0089",
"planCommit": "fb3cfd2740",
"registeredAt": "2026-07-05T05:30:13.704Z"
}
10 changes: 8 additions & 2 deletions qa-contract/schema/qa-contract.documents.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
"minLength": 1,
"maxLength": 32,
"position": 1,
"description": "Feature-area / Domain name (Core, Identity, DPNS, Token, Shielded, MultiWallet, ...)."
"description": "Feature-area / Domain name (Core, Identity, DPNS, Token, Shielded, DashPay, System, ...). Modalities like multi-wallet / group are tags, not categories."
}
},
"required": ["code", "name"],
Expand Down Expand Up @@ -185,11 +185,17 @@
"maxLength": 64,
"position": 10,
"description": "git commit of the source plan this testCase was seeded from."
},
"tags": {
"type": "string",
"maxLength": 200,
"position": 11,
"description": "Comma-separated cross-cutting labels orthogonal to category (e.g. \"multiwallet,contested\"). Drawn from the canonical TAGS vocabulary in src/codes.mjs; stored as a string because DPP document schemas only support byte arrays, not typed arrays. The dashboard splits on ',' and filters client-side."
}
},
"required": ["testId", "app", "tier", "category", "title", "layer", "implStatus"],
"additionalProperties": false,
"description": "A single test definition, mirroring one row of an app's test plan. Unique per (testId, app); tier/category/app are integer foreign keys into the lookup document types. Mutable; owner-only creation (v1 single QA identity)."
"description": "A single test definition, mirroring one row of an app's test plan. Unique per (testId, app); tier/category/app are integer foreign keys into the lookup document types; tags are cross-cutting string labels. Mutable; owner-only creation (single QA-owner identity)."
},
"testRun": {
"type": "object",
Expand Down
79 changes: 73 additions & 6 deletions qa-contract/src/codes.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
// Canonical integer codes for the app / tier / category lookup document types.
// Canonical integer codes for the app / tier / category lookup document types,
// plus the canonical `tags` vocabulary and the v4 -> v5 testId remap.
//
// These are the foreign-key values stored on testCase (app, tier, category) and
// testRun (app). Codes are STABLE — append new entries with the next id; never
// renumber an existing one (it would orphan already-stored references).
// testRun (app). v5 starts a FRESH contract, so the category codes were
// renumbered after dissolving MultiWallet and Group into the `tags` vocabulary
// (a test's *domain* is its category; its *modality* is a tag). Within v5 these
// codes are STABLE — append new entries with the next id; never renumber an
// existing one (it would orphan already-stored references).

export const APPS = [
{ code: 0, name: 'SwiftExampleApp', platform: 'iOS', description: 'Dash Platform iOS example wallet (Core SPV + Platform).' },
Expand All @@ -16,6 +21,9 @@ export const TIERS = [
{ code: 5, name: 'Unspecified' },
];

// Feature-area domains. MultiWallet and Group are NOT categories in v5 — they are
// tags (see TAGS): a multi-wallet document test lives under `Document` + the
// `multiwallet` tag; a group-authorized token action under `Token` + `group`.
export const CATEGORIES = [
{ code: 0, name: 'Core' },
{ code: 1, name: 'Identity' },
Expand All @@ -27,11 +35,53 @@ export const CATEGORIES = [
{ code: 7, name: 'Token' },
{ code: 8, name: 'Shielded' },
{ code: 9, name: 'DashPay' },
{ code: 10, name: 'Group' },
{ code: 11, name: 'System' },
{ code: 12, name: 'MultiWallet' },
{ code: 10, name: 'System' },
];

// Canonical cross-cutting tag vocabulary stored on testCase.tags (a string array,
// orthogonal to category). Keep lowercase + hyphenated. Add an entry here before
// using it in TEST_PLAN.md — checkTags() rejects unknown tags at seed time.
export const TAGS = [
'multiwallet', // exercises >=2 on-device wallets / identities
'group', // multi-party group-authorized action (propose / co-sign)
'contested', // contested resource (premium DPNS name, masternode vote, race)
'withdrawal', // moves value off-platform to an L1 Core address
'distribution', // token distribution mechanism (pre-programmed / perpetual / claim)
'aggregation', // count / sum / average / group_by query
'read-only', // pure query, no state-transition broadcast
'regression', // pins a specific previously-fixed bug
'proof', // specifically validates cryptographic proof verification
'freeze', // freeze / unfreeze / destroy-frozen balances
'funding', // requires asset-lock / faucet funding to run
'masternode', // requires a masternode key
];

// v4 -> v5 testId remap. MultiWallet (MW-*) and Group (GRP-*) ids were folded into
// their domain category's sequence. GRP-03 (token group propose / co-sign) is
// MERGED into the existing TOK-15 / TOK-16 pair, so its historical runs attach to
// TOK-15. Ids absent here are unchanged (identity mapping). Used by
// migrate-runs.mjs to re-point historical testRun.testId; testCase ids are
// renumbered directly in TEST_PLAN.md.
export const TESTID_REMAP = {
'MW-01': 'ID-14',
'MW-02': 'TOK-17',
'MW-03': 'DP-11', // DashPay grew to DP-10 (label / QR / contactInfo / backfill); the loop lands at DP-11
'MW-04': 'DOC-15',
'MW-05': 'DPNS-08',
'MW-06': 'SH-14',
'MW-07': 'SH-15',
'MW-08': 'SYS-07',
'MW-09': 'SYS-08',
'MW-10': 'ID-15',
'MW-11': 'SH-16',
'GRP-01': 'TOK-18',
'GRP-02': 'TOK-19',
'GRP-03': 'TOK-15', // merged into the TOK-15 / TOK-16 group-action pair
'GRP-04': 'TOK-20',
};

export const remapTestId = (id) => TESTID_REMAP[id] || id;

// The app the iOS TEST_PLAN.md belongs to.
export const DEFAULT_APP = 'SwiftExampleApp';

Expand All @@ -41,6 +91,7 @@ const byCodeMap = (rows) => Object.fromEntries(rows.map((r) => [r.code, r.name])
const APP_BY_NAME = byNameMap(APPS);
const TIER_BY_NAME = byNameMap(TIERS);
const CATEGORY_BY_NAME = byNameMap(CATEGORIES);
const TAG_SET = new Set(TAGS);

export const APP_BY_CODE = byCodeMap(APPS);
export const TIER_BY_CODE = byCodeMap(TIERS);
Expand All @@ -57,3 +108,19 @@ function lookup(map, kind, name) {
export const appCode = (name) => lookup(APP_BY_NAME, 'app', name);
export const tierCode = (name) => lookup(TIER_BY_NAME, 'tier', name);
export const categoryCode = (name) => lookup(CATEGORY_BY_NAME, 'category', name);

// Validate + de-dupe a list of tags against the canonical vocabulary. Throws on
// an unknown tag so a typo in TEST_PLAN.md fails the seed loudly rather than
// silently storing an unfilterable tag.
export function checkTags(tags, testId = '') {
const out = [];
for (const raw of tags || []) {
const t = String(raw).trim().toLowerCase();
if (!t) continue;
if (!TAG_SET.has(t)) {
throw new Error(`Unknown tag '${t}'${testId ? ` on ${testId}` : ''}. Add it to TAGS in src/codes.mjs.`);
}
if (!out.includes(t)) out.push(t);
}
return out;
}
204 changes: 204 additions & 0 deletions qa-contract/src/migrate-runs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// Migrate historical testRun records from the previous QA contract (v4) onto the
// freshly-registered v5 contract, remapping testId via the v4->v5 table in
// codes.mjs (MultiWallet / Group ids folded into their domain categories; GRP-03
// merged into TOK-15).
//
// Reading the old contract is public (no key); writing the new runs uses the v5
// owner from .env (QA_IDENTITY_ID / QA_PRIVATE_KEY). Migrated runs get a FRESH
// $createdAt — the original run time is intentionally not preserved.
//
// Run AFTER register.mjs + seed.mjs (the v5 testCases must already exist).
// Idempotent: a run already present on v5 for the same (testId, result, buildRef)
// is skipped, so re-running won't duplicate.
//
// Usage:
// QA_IDENTITY_ID=... QA_PRIVATE_KEY=... node src/migrate-runs.mjs [--dry-run]
// [--from <oldContractId>] [--from-owner <oldOwnerId>] [--app SwiftExampleApp]

import { parseArgs } from 'node:util';
import { randomBytes } from 'node:crypto';
import { loadDotEnv, connect, loadOwnerAuth, readConfig } from './sdk.mjs';
import { appCode, DEFAULT_APP, remapTestId } from './codes.mjs';

// Previous (v4) QA contract on testnet + its owner identity (public, read-only).
const DEFAULT_OLD_CONTRACT = '67ctgcKJgCs7U4hhAxGj1QQUVq15xkkvMk88CT2AbjCF';
const DEFAULT_OLD_OWNER = '85KjYZLZXA7YZBPyFEjiMaH36xcQpBBZisKGBHF3uKuH';

// v4 also had Group (10) and MultiWallet (12) category codes that no longer exist
// in v5; enumerate old testCases across a generous code range so none are missed
// regardless of the old numbering.
const OLD_CATEGORY_CODES = Array.from({ length: 16 }, (_, i) => i);

function entropy() { return Uint8Array.from(randomBytes(32)); }

const PAGE = 100;

// Page through every match, not just the first PAGE, using `startAfter` on the
// last document's $id. `documents.query` caps a page at PAGE rows, so a full
// page means there may be more. Callers that can exceed PAGE rows (the per-test
// run history + idempotency lookup) pass a stable `orderBy` so paging is
// deterministic; the equality-only testCase scan is bounded well under PAGE.
async function queryAll(sdk, dataContractId, documentTypeName, where, orderBy) {
const out = [];
let startAfter;
for (;;) {
const res = await sdk.documents.query({
dataContractId,
documentTypeName,
where,
...(orderBy ? { orderBy } : {}),
...(startAfter ? { startAfter } : {}),
limit: PAGE,
});
const raw = [...res.values()];
for (const doc of raw) if (doc) out.push(doc.toJSON());
if (raw.length < PAGE) break;
const lastId = raw[raw.length - 1]?.toJSON().$id;
if (!lastId) break; // can't advance the cursor — stop rather than loop forever
startAfter = lastId;
}
return out;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function main() {
loadDotEnv();
const { values } = parseArgs({
options: {
'dry-run': { type: 'boolean', default: false },
from: { type: 'string' },
'from-owner': { type: 'string' },
app: { type: 'string' },
},
});

const dryRun = values['dry-run'];
const appName = values.app || DEFAULT_APP;
const app = appCode(appName);
const oldContractId = (values.from || process.env.OLD_CONTRACT_ID || DEFAULT_OLD_CONTRACT).trim();

let { sdk, mod, network } = await connect();
if (network !== 'testnet') {
console.warn(`Warning: network is '${network}', but the default old contract is a testnet id.`);
}

const cfg = readConfig(network);
if (!cfg?.contractId) throw new Error(`No v5 contract registered for ${network}. Run register.mjs first.`);
const newContractId = cfg.contractId;
if (newContractId === oldContractId) {
throw new Error(`Refusing to migrate: the registered contract (${newContractId}) equals --from. Register the v5 contract first.`);
}

// Resolve the old owner (testRun indices are $ownerId-prefixed).
let oldOwner = (values['from-owner'] || process.env.OLD_OWNER_ID || '').trim();
if (!oldOwner) {
try {
const oldContract = await sdk.contracts.fetch(oldContractId);
oldOwner = oldContract ? String(oldContract.ownerId) : '';
} catch { /* fall through to default */ }
}
if (!oldOwner) oldOwner = DEFAULT_OLD_OWNER;

let { ownerId: newOwner, signer, identityKey } = await loadOwnerAuth(sdk, mod, network);
let { Document } = mod;

// Reconnect a fresh SDK + re-derive auth. A fresh instance re-fetches the
// identity-contract nonce from chain, recovering from the SDK's
// "identity nonce too far in future" drift that cascades during bulk creates.
async function reconnect() {
({ sdk, mod, network } = await connect());
({ ownerId: newOwner, signer, identityKey } = await loadOwnerAuth(sdk, mod, network));
({ Document } = mod);
}

console.log('Migrating runs:');
console.log(` from ${oldContractId} (owner ${oldOwner})`);
console.log(` to ${newContractId} (owner ${newOwner})`);
console.log(` app '${appName}' (code ${app})${dryRun ? ' [DRY RUN]' : ''}`);

// 1. Enumerate old testIds (across every old category code so MW/GRP are caught).
const oldTestIds = new Set();
for (const c of OLD_CATEGORY_CODES) {
const cases = await queryAll(sdk, oldContractId, 'testCase', [['app', '==', app], ['category', '==', c]]);
for (const tc of cases) if (tc.testId) oldTestIds.add(tc.testId);
}
console.log(`Found ${oldTestIds.size} old testCase ids.`);

// 2. For each old testId, read its runs and re-create them under the new id.
let migrated = 0; let skipped = 0; let failed = 0; let runsSeen = 0;
for (const oldId of oldTestIds) {
const newId = remapTestId(oldId);
const oldRuns = await queryAll(
sdk, oldContractId, 'testRun',
[['$ownerId', '==', oldOwner], ['app', '==', app], ['testId', '==', oldId]],
[['$createdAt', 'asc']],
);
if (!oldRuns.length) continue;

// Idempotency: skip a run already present on v5 for (newId, result, buildRef).
const existing = await queryAll(
sdk, newContractId, 'testRun',
[['$ownerId', '==', newOwner], ['app', '==', app], ['testId', '==', newId]],
[['$createdAt', 'asc']],
);
const present = new Set(existing.map((r) => `${r.result}|${r.buildRef}`));

for (const run of oldRuns) {
runsSeen += 1;
const label = oldId === newId ? newId : `${oldId}->${newId}`;
if (present.has(`${run.result}|${run.buildRef}`)) { skipped += 1; continue; }

const props = { testId: newId, app, result: run.result, network: run.network, buildRef: run.buildRef };
for (const f of ['device', 'evidence', 'notes', 'blockerReason']) {
if (run[f] !== undefined && run[f] !== null && run[f] !== '') props[f] = run[f];
}
if (dryRun) {
console.log(` [dry] ${label} ${run.result} ${run.buildRef}`);
migrated += 1;
continue;
}
for (let attempt = 1; attempt <= 6; attempt += 1) {
try {
const doc = new Document({
ownerId: newOwner, dataContractId: newContractId, documentTypeName: 'testRun', properties: props, entropy: entropy(),
});
await sdk.documents.create({ document: doc, identityKey, signer });
migrated += 1;
console.log(` + ${label} ${run.result} ${run.buildRef}`);
break;
} catch (e) {
const msg = e?.message || String(e);
// Recover from identity-contract-nonce drift: a fresh SDK re-syncs
// the nonce from chain. Retry non-fatally up to a few times.
if (/nonce/i.test(msg) && attempt < 6) {
console.error(` ~ ${label} nonce desync (attempt ${attempt}) — reconnecting + retrying`);
await new Promise((r) => setTimeout(r, 2500));
await reconnect();
// A nonce error can be a false negative — the write may have landed
// before the ack was lost. Re-check before retrying so we don't
// create a duplicate testRun for the same (testId, result, buildRef).
const after = await queryAll(
sdk, newContractId, 'testRun',
[['$ownerId', '==', newOwner], ['app', '==', app], ['testId', '==', newId]],
[['$createdAt', 'asc']],
);
if (after.some((r) => `${r.result}|${r.buildRef}` === `${run.result}|${run.buildRef}`)) {
console.log(` = ${label} (${run.result}/${run.buildRef}) already landed — skipping retry`);
skipped += 1;
break;
}
continue;
}
failed += 1;
console.error(` ! ${label} (${run.result}/${run.buildRef}) failed: ${msg}`);
break;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

console.log(`\nRun migration ${dryRun ? '(dry run) ' : ''}complete: ${runsSeen} source runs; `
+ `${migrated} ${dryRun ? 'would migrate' : 'migrated'}, ${skipped} skipped (already present), ${failed} failed.`);
if (failed) process.exit(1);
}

main().catch((e) => { console.error('migrate-runs failed:', e?.stack || e); process.exit(1); });
Loading
Loading