335 lines
10 KiB
JavaScript
335 lines
10 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 function props(patch = {}) {
|
|
return {
|
|
opportunity: {
|
|
id: 1,
|
|
title: 'SYNTHETIC resource investment',
|
|
status: 'draft',
|
|
deleted_at: null,
|
|
},
|
|
etag: 'a'.repeat(64),
|
|
resources: { documents: [], images: [], locations: [] },
|
|
existingLocations: [],
|
|
assessment: [],
|
|
imageProcessingAvailable: 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 = [],
|
|
unload = new Set();
|
|
let active,
|
|
cursor = 0,
|
|
changed = false,
|
|
effects = [],
|
|
current = initial,
|
|
tree,
|
|
sequence = 0;
|
|
const windowMock = {
|
|
confirm() {
|
|
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,
|
|
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 Form {
|
|
constructor() {
|
|
this.values = new Map();
|
|
}
|
|
set(k, v) {
|
|
this.values.set(k, v);
|
|
}
|
|
get(k) {
|
|
return this.values.get(k);
|
|
}
|
|
}
|
|
function globals() {
|
|
const saved = {
|
|
document: globalThis.document,
|
|
fetch: globalThis.fetch,
|
|
FormData: globalThis.FormData,
|
|
setTimeout: globalThis.setTimeout,
|
|
};
|
|
globalThis.document = {
|
|
cookie: 'XSRF-TOKEN=SYNTHETIC',
|
|
getElementById: () => ({ focus() {} }),
|
|
};
|
|
globalThis.FormData = Form;
|
|
globalThis.setTimeout = (fn) => {
|
|
fn();
|
|
return 0;
|
|
};
|
|
globalThis.fetch = (url, options) =>
|
|
new Promise((resolve, reject) =>
|
|
sent.push({ url, form: options.body, resolve, reject }),
|
|
);
|
|
return () => Object.assign(globalThis, saved);
|
|
}
|
|
const mocks = {
|
|
react: hooks,
|
|
'@inertiajs/react': {
|
|
Link: 'a',
|
|
usePage: () => ({
|
|
props: {
|
|
basePath: '/minhas-oportunidades',
|
|
countries: [],
|
|
readOnly: false,
|
|
},
|
|
url: '/minhas-oportunidades/1/resources',
|
|
}),
|
|
router: { reload: (o) => reloads.push(o) },
|
|
},
|
|
'@/components/opportunity-library-reuse': { default: () => null },
|
|
'@/components/territory-geometry-preview': { default: () => null },
|
|
'@/components/investment-territory': { TerritoryLevel: () => null },
|
|
'./shared': {
|
|
Management: 'main',
|
|
Field: ({ label, children }) => ({
|
|
type: 'label',
|
|
props: { children: [label, children] },
|
|
}),
|
|
button: '',
|
|
control: '',
|
|
},
|
|
'@/lib/operation-key': {
|
|
createOperationKey: () =>
|
|
`00000000-0000-4000-8000-${String(++sequence).padStart(12, '0')}`,
|
|
},
|
|
};
|
|
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/technical-incident-labels'] = load(
|
|
'resources/js/lib/technical-incident-labels.ts',
|
|
);
|
|
mocks['@/lib/value-text'] = load('resources/js/lib/value-text.ts');
|
|
mocks['@/components/opportunity-technical-incidents'] = load(
|
|
'resources/js/components/opportunity-technical-incidents.tsx',
|
|
);
|
|
const Page = load(
|
|
'resources/js/pages/management/opportunity-resources.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;
|
|
globalThis.window = windowMock;
|
|
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;
|
|
}
|
|
}
|
|
render();
|
|
const field = (label) =>
|
|
nodes(tree).find(
|
|
(n) =>
|
|
['input', 'textarea', 'select'].includes(n.type) &&
|
|
n.props['aria-label'] === label,
|
|
);
|
|
return {
|
|
sent,
|
|
reloads,
|
|
render,
|
|
get tree() {
|
|
return tree;
|
|
},
|
|
get props() {
|
|
return current;
|
|
},
|
|
value(label) {
|
|
return field(label)?.props.value;
|
|
},
|
|
change(label, value) {
|
|
field(label).props.onChange({ target: { value } });
|
|
render();
|
|
},
|
|
choose(label) {
|
|
nodes(tree)
|
|
.find((n) => n.type === 'button' && text(n) === label)
|
|
.props.onClick();
|
|
render();
|
|
},
|
|
attach(value) {
|
|
nodes(tree)
|
|
.find((n) => n.type === 'input' && n.props.type === 'file')
|
|
.props.onChange({ target: { files: [value] } });
|
|
render();
|
|
},
|
|
submit() {
|
|
const restore = globals();
|
|
try {
|
|
nodes(tree)
|
|
.find((n) => n.type === 'form')
|
|
.props.onSubmit({ preventDefault() {} });
|
|
render();
|
|
return sent.at(-1);
|
|
} finally {
|
|
restore();
|
|
}
|
|
},
|
|
async reply(status, data, request = sent.at(-1)) {
|
|
const restore = globals();
|
|
try {
|
|
request.resolve({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: async () => data,
|
|
});
|
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
render();
|
|
} finally {
|
|
restore();
|
|
}
|
|
},
|
|
};
|
|
}
|