403 lines
13 KiB
JavaScript
403 lines
13 KiB
JavaScript
import fs from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { compileFunction } from 'node:vm';
|
|
import ts from 'typescript';
|
|
const require = createRequire(import.meta.url);
|
|
export const companyId = '10000000-0000-4000-8000-000000000001';
|
|
export function props(patch = {}) {
|
|
return {
|
|
actorId: 1,
|
|
account: {
|
|
version: 1,
|
|
payload: {
|
|
residence_country_code: 'PT',
|
|
phone: 'SYNTHETIC phone',
|
|
country: 'SYNTHETIC historical',
|
|
},
|
|
},
|
|
companies: [
|
|
{
|
|
id: companyId,
|
|
version: 1,
|
|
payload: {
|
|
name: 'SYNTHETIC company',
|
|
incorporation_country_code: 'MZ',
|
|
role: 'SYNTHETIC role',
|
|
},
|
|
},
|
|
],
|
|
countries: [
|
|
{ code: 'PT', name: 'Portugal', name_en: 'Portugal' },
|
|
{ code: 'FR', name: 'France', name_en: 'France' },
|
|
{ code: 'MZ', name: 'Mo\u00e7ambique', name_en: 'Mozambique' },
|
|
{ code: 'ZA', name: '\u00c1frica do Sul', name_en: 'South Africa' },
|
|
],
|
|
organizations: [],
|
|
organizationCountryAvailable: true,
|
|
...patch,
|
|
};
|
|
}
|
|
export function nodes(tree) {
|
|
return Array.isArray(tree)
|
|
? tree.flatMap(nodes)
|
|
: tree && typeof tree === 'object'
|
|
? [tree, ...nodes(tree.props?.children)]
|
|
: [];
|
|
}
|
|
export function text(tree) {
|
|
return Array.isArray(tree)
|
|
? tree.map(text).join('')
|
|
: tree?.props
|
|
? text(tree.props.children)
|
|
: typeof tree === 'string' || typeof tree === 'number'
|
|
? String(tree)
|
|
: '';
|
|
}
|
|
export function harness(initial = props()) {
|
|
const frames = new Map(),
|
|
sent = [],
|
|
reloads = [],
|
|
before = new Set(),
|
|
unload = new Set();
|
|
let active,
|
|
cursor = 0,
|
|
changed = false,
|
|
effects = [],
|
|
current = initial,
|
|
tree,
|
|
sequence = 0,
|
|
confirmCalls = 0;
|
|
const windowMock = {
|
|
confirm() {
|
|
confirmCalls++;
|
|
return false;
|
|
},
|
|
addEventListener(name, callback) {
|
|
if (name === 'beforeunload') unload.add(callback);
|
|
},
|
|
removeEventListener(name, callback) {
|
|
if (name === 'beforeunload') unload.delete(callback);
|
|
},
|
|
};
|
|
const state = (initialValue) => {
|
|
const frame = active,
|
|
i = cursor++;
|
|
if (!(i in frame.cells))
|
|
frame.cells[i] =
|
|
typeof initialValue === 'function'
|
|
? initialValue()
|
|
: initialValue;
|
|
return [
|
|
frame.cells[i],
|
|
(value) => {
|
|
const next =
|
|
typeof value === 'function' ? value(frame.cells[i]) : value;
|
|
if (!Object.is(next, frame.cells[i])) {
|
|
frame.cells[i] = next;
|
|
changed = true;
|
|
}
|
|
},
|
|
];
|
|
};
|
|
const hooks = {
|
|
useState: state,
|
|
useId() {
|
|
const i = cursor++;
|
|
active.cells[i] ??= 'SYNTHETIC-id-' + ++sequence;
|
|
return active.cells[i];
|
|
},
|
|
useRef(value) {
|
|
const i = cursor++;
|
|
active.cells[i] ??= { current: value };
|
|
return active.cells[i];
|
|
},
|
|
useEffect(callback, deps) {
|
|
const frame = active,
|
|
i = cursor++,
|
|
prior = frame.cells[i];
|
|
if (!prior || !deps.every((value, j) => value === prior.deps[j])) {
|
|
frame.cells[i] = { deps };
|
|
effects.push(() => {
|
|
prior?.cleanup?.();
|
|
frame.cells[i].cleanup = callback();
|
|
});
|
|
}
|
|
},
|
|
useCallback(callback, deps) {
|
|
const i = cursor++,
|
|
prior = active.cells[i];
|
|
if (!prior || !deps.every((value, j) => value === prior.deps[j]))
|
|
active.cells[i] = { deps, callback };
|
|
return active.cells[i].callback;
|
|
},
|
|
};
|
|
class ApiError extends Error {
|
|
constructor(status, errors = {}) {
|
|
super('SYNTHETIC HTTP ' + status);
|
|
this.status = status;
|
|
this.errors = errors;
|
|
}
|
|
}
|
|
const mocks = {
|
|
react: hooks,
|
|
'@inertiajs/react': {
|
|
Link: 'a',
|
|
router: {
|
|
reload: (options) => reloads.push(options),
|
|
on(name, fn) {
|
|
if (name === 'before') before.add(fn);
|
|
return () => before.delete(fn);
|
|
},
|
|
},
|
|
},
|
|
'./shared': {
|
|
Frame: 'main',
|
|
Field: 'label',
|
|
input: '',
|
|
button: '',
|
|
card: '',
|
|
},
|
|
'@/pages/investment/shared': {
|
|
Field: 'label',
|
|
input: '',
|
|
button: '',
|
|
card: '',
|
|
},
|
|
'@/components/partnership-source-links': { default: () => null },
|
|
'@/pages/management/shared': { button: '', control: '' },
|
|
};
|
|
mocks['./portal-api'] = {
|
|
PortalApiError: ApiError,
|
|
portalRequest: (url, options = {}) =>
|
|
new Promise((resolve, reject) =>
|
|
sent.push({
|
|
url,
|
|
data: options.body ? JSON.parse(options.body) : undefined,
|
|
method: options.method ?? 'GET',
|
|
resolve,
|
|
reject,
|
|
}),
|
|
),
|
|
};
|
|
mocks['./operation-key'] = {
|
|
createOperationKey: () =>
|
|
`00000000-0000-4000-8000-${String(++sequence).padStart(12, '0')}`,
|
|
};
|
|
mocks['@/lib/portal-api'] = mocks['./portal-api'];
|
|
function load(path) {
|
|
const code = ts.transpileModule(
|
|
fs.readFileSync(
|
|
new URL('../../../' + path, import.meta.url),
|
|
'utf8',
|
|
),
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
jsx: ts.JsxEmit.ReactJSX,
|
|
},
|
|
},
|
|
).outputText;
|
|
const module = { exports: {} };
|
|
compileFunction(code, ['require', 'module', 'exports'])(
|
|
(name) => mocks[name] ?? require(name),
|
|
module,
|
|
module.exports,
|
|
);
|
|
return module.exports;
|
|
}
|
|
mocks['@/lib/page-preparation-guard'] = load(
|
|
'resources/js/lib/page-preparation-guard.ts',
|
|
);
|
|
mocks['@/lib/investment-profile-editor'] = load(
|
|
'resources/js/lib/investment-profile-editor.ts',
|
|
);
|
|
mocks['./country-select'] = load(
|
|
'resources/js/components/country-select.tsx',
|
|
);
|
|
mocks['@/components/country-select'] = mocks['./country-select'];
|
|
mocks['@/lib/organization-country-reuse'] = load(
|
|
'resources/js/lib/organization-country-reuse.ts',
|
|
);
|
|
mocks['./organization-country-reuse'] = load(
|
|
'resources/js/components/organization-country-reuse.tsx',
|
|
);
|
|
mocks['@/components/investment-profile-editor'] = load(
|
|
'resources/js/components/investment-profile-editor.tsx',
|
|
);
|
|
mocks['@/components/organization-country-editor'] = load(
|
|
'resources/js/components/organization-country-editor.tsx',
|
|
);
|
|
const Page = load('resources/js/pages/investment/profile.tsx').default;
|
|
function expand(value, path, seen) {
|
|
if (Array.isArray(value))
|
|
return value.map((child, i) =>
|
|
expand(child, path + '/' + (child?.key ?? i), seen),
|
|
);
|
|
if (!value || typeof value !== 'object' || !value.props) return value;
|
|
if (typeof value.type === 'function') {
|
|
const id = path + '/' + value.type.name + ':' + (value.key ?? '');
|
|
seen.add(id);
|
|
const frame = frames.get(id) ?? { cells: [] };
|
|
frames.set(id, frame);
|
|
const previous = active,
|
|
priorCursor = cursor;
|
|
active = frame;
|
|
cursor = 0;
|
|
const rendered = value.type(value.props);
|
|
active = previous;
|
|
cursor = priorCursor;
|
|
return {
|
|
type: 'component',
|
|
props: {
|
|
name: value.type.name,
|
|
children: expand(rendered, id, seen),
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
...value,
|
|
props: {
|
|
...value.props,
|
|
children: expand(
|
|
value.props.children,
|
|
path + '/children',
|
|
seen,
|
|
),
|
|
},
|
|
};
|
|
}
|
|
function render(next = current) {
|
|
current = next;
|
|
const previousWindow = globalThis.window,
|
|
previousDocument = globalThis.document;
|
|
globalThis.window = windowMock;
|
|
globalThis.document = {
|
|
getElementById: () => ({ querySelector: () => ({ focus() {} }) }),
|
|
};
|
|
try {
|
|
let loops = 0;
|
|
do {
|
|
changed = false;
|
|
const seen = new Set();
|
|
tree = expand(
|
|
{ type: Page, key: 'page', props: current },
|
|
'root',
|
|
seen,
|
|
);
|
|
for (const [id, frame] of frames)
|
|
if (!seen.has(id)) {
|
|
frame.cells.forEach((cell) => cell?.cleanup?.());
|
|
frames.delete(id);
|
|
}
|
|
const queue = effects;
|
|
effects = [];
|
|
queue.forEach((effect) => effect());
|
|
if (++loops > 12) throw Error('Component state did not settle');
|
|
} while (changed);
|
|
return tree;
|
|
} finally {
|
|
globalThis.window = previousWindow;
|
|
globalThis.document = previousDocument;
|
|
}
|
|
}
|
|
render();
|
|
return {
|
|
sent,
|
|
reloads,
|
|
render,
|
|
get props() {
|
|
return current;
|
|
},
|
|
get tree() {
|
|
return tree;
|
|
},
|
|
get confirmCalls() {
|
|
return confirmCalls;
|
|
},
|
|
form(label) {
|
|
return nodes(tree).find(
|
|
(n) => n.type === 'form' && n.props['aria-label'] === label,
|
|
);
|
|
},
|
|
field(label, formLabel) {
|
|
return nodes(formLabel ? this.form(formLabel) : tree).find(
|
|
(n) =>
|
|
['input', 'textarea', 'select'].includes(n.type) &&
|
|
n.props['aria-label'] === label,
|
|
);
|
|
},
|
|
value(label, formLabel) {
|
|
return this.field(label, formLabel)?.props.value;
|
|
},
|
|
change(label, value, formLabel) {
|
|
const field = this.field(label, formLabel);
|
|
if (!field) throw Error('Missing field ' + label);
|
|
field.props.onChange({ target: { value } });
|
|
render();
|
|
},
|
|
button(label, formLabel) {
|
|
return nodes(formLabel ? this.form(formLabel) : tree).find(
|
|
(n) => n.type === 'button' && text(n) === label,
|
|
);
|
|
},
|
|
submit(formLabel) {
|
|
const form = this.form(formLabel);
|
|
if (!form) throw Error('Missing form ' + formLabel);
|
|
form.props.onSubmit({ preventDefault() {} });
|
|
render();
|
|
return sent.at(-1);
|
|
},
|
|
click(label, formLabel) {
|
|
const node = this.button(label, formLabel);
|
|
if (!node) throw Error('Missing button ' + label);
|
|
const priorWindow = globalThis.window;
|
|
globalThis.window = windowMock;
|
|
try {
|
|
node.props.onClick();
|
|
} finally {
|
|
globalThis.window = priorWindow;
|
|
}
|
|
render();
|
|
return sent.at(-1);
|
|
},
|
|
async reply(value, request = sent.at(-1)) {
|
|
request.resolve(value);
|
|
for (let i = 0; i < 6; i++) await Promise.resolve();
|
|
render();
|
|
},
|
|
async fail(status = null, request = sent.at(-1), errors = {}) {
|
|
request.reject(
|
|
status === null
|
|
? new Error('SYNTHETIC network loss')
|
|
: new ApiError(status, errors),
|
|
);
|
|
for (let i = 0; i < 6; i++) await Promise.resolve();
|
|
render();
|
|
},
|
|
refreshed(next, request = reloads.at(-1)) {
|
|
render(next);
|
|
request.onSuccess?.({ props: next });
|
|
request.onFinish?.();
|
|
render(next);
|
|
},
|
|
leave(preserveState = false) {
|
|
let prevented = false;
|
|
const old = globalThis.window;
|
|
globalThis.window = windowMock;
|
|
try {
|
|
for (const fn of before)
|
|
fn({
|
|
detail: { visit: { method: 'get', preserveState } },
|
|
preventDefault() {
|
|
prevented = true;
|
|
},
|
|
});
|
|
} finally {
|
|
globalThis.window = old;
|
|
}
|
|
return prevented;
|
|
},
|
|
};
|
|
}
|