Files
Investidor-V2/tests/frontend/helpers/crm-task-preparation.mjs
T

506 lines
16 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 leadId = '10000000-0000-4000-8000-000000000001';
export function props(patch = {}) {
return {
lead: {
id: leadId,
version_no: 1,
title: 'SYNTHETIC lead',
status: 'new',
owner_user_id: 1,
tasks: [],
},
contacts: [],
owners: [
{ id: 1, name: 'SYNTHETIC manager' },
{ id: 2, name: 'SYNTHETIC colleague' },
],
nextAction: { future_tasks: 0 },
taskCreation: {
lead_id: leadId,
actor_id: 1,
commands: [],
preparation: {
installed: true,
version: 0,
snapshot: null,
operation_key: null,
updated_at: null,
unavailable: false,
},
},
...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)
: '';
}
const clone = (value) =>
Array.isArray(value)
? value.map(clone)
: value && Object.getPrototypeOf(value) === Object.prototype
? Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, clone(v)]),
)
: value;
export function harness(initial = props()) {
const draftRequests = [],
timers = new Map();
let timerSequence = 0;
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,
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;
},
};
const mocks = {
react: hooks,
'@inertiajs/react': {
usePage: () => ({
props: { auth: { user: { id: current.actorId ?? 1 } } },
}),
Link: 'a',
router: {
reload(options) {
reloads.push(options);
},
on(name, callback) {
if (name === 'before') before.add(callback);
return () => before.delete(callback);
},
post(url, data) {
sent.push({ method: 'post', url, data, options: {} });
},
},
useForm(initialData) {
const i = cursor++;
if (active.cells[i]) return active.cells[i];
const form = {
data: clone(initialData),
defaults: clone(initialData),
errors: {},
processing: false,
setData(key, value) {
this.data =
typeof key === 'function'
? key(this.data)
: typeof key === 'object'
? key
: { ...this.data, [key]: value };
changed = true;
},
setDefaults(data) {
this.defaults = clone(data);
},
clearErrors() {
this.errors = {};
changed = true;
},
reset(...keys) {
for (const key of keys)
this.data[key] = this.defaults[key];
changed = true;
},
transform(callback) {
this.transformer = callback;
},
};
for (const method of ['post', 'put', 'patch'])
form[method] = (url, options) => {
form.processing = true;
changed = true;
sent.push({
method,
url,
options,
data: clone(
form.transformer
? form.transformer(form.data)
: form.data,
),
form,
});
};
active.cells[i] = form;
return form;
},
},
'../management/shared': {
Management: 'main',
Field: 'label',
button: '',
control: '',
},
'@/pages/management/shared': {
Management: 'main',
Field: 'label',
button: '',
control: '',
},
'@/components/crm-edit-comparison': { default: () => null },
'@/lib/crm-record-edit': {
useCrmRecordEdit: ({ values }) => ({
form: { processing: false },
values,
error: () => undefined,
blocked: false,
}),
},
'./types': {
leadStates: { new: 'Novo', qualified: 'Qualificado' },
taskStates: { open: 'Aberta', done: 'Conclu?da' },
},
};
const operation = {
createOperationKey: () =>
`00000000-0000-4000-8000-${String(++sequence).padStart(12, '0')}`,
};
mocks['./operation-key'] = operation;
mocks['@/lib/operation-key'] = operation;
function load(path) {
const code = ts.transpileModule(
fs.readFileSync(
new URL('../../../' + path, import.meta.url),
'utf8',
),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
jsx: ts.JsxEmit.ReactJSX,
target: ts.ScriptTarget.ES2022,
},
},
).outputText;
const module = { exports: {} };
compileFunction(code, [
'require',
'module',
'exports',
'setTimeout',
'clearTimeout',
])(
(name) => mocks[name] ?? require(name),
module,
module.exports,
(callback) => {
const id = ++timerSequence;
timers.set(id, callback);
return id;
},
(id) => timers.delete(id),
);
return module.exports;
}
mocks['./profile-json'] = {
...load('resources/js/lib/profile-json.ts'),
profileJson: (url, data, method = 'POST') =>
new Promise((resolve, reject) =>
draftRequests.push({ url, data, method, resolve, reject }),
),
};
mocks['./crm-task-preparation'] = load(
'resources/js/lib/crm-task-preparation.ts',
);
for (const [name, path] of [
['@/lib/crm-task-creation', 'resources/js/lib/crm-task-creation.ts'],
[
'@/components/crm-task-creation',
'resources/js/components/crm-task-creation.tsx',
],
])
if (fs.existsSync(new URL('../../../' + path, import.meta.url)))
mocks[name] = load(path);
const Page = load('resources/js/pages/crm/lead.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();
return {
draftRequests,
async validationDraft(fields, request = draftRequests.at(-1)) {
request.reject(
new mocks['./profile-json'].ProfileValidationError(
'SYNTHETIC validation',
fields,
),
);
await new Promise((resolve) => setImmediate(resolve));
render();
},
async flush() {
await new Promise((resolve) => setImmediate(resolve));
render();
},
async tick() {
const queue = [...timers.values()];
timers.clear();
queue.forEach((fn) => fn());
await Promise.resolve();
render();
},
async respond(value, request = draftRequests.at(-1)) {
request.resolve(value);
await new Promise((resolve) => setImmediate(resolve));
render();
},
async rejectDraft(request = draftRequests.at(-1)) {
request.reject(new Error('SYNTHETIC network failure'));
await new Promise((resolve) => setImmediate(resolve));
render();
},
sent,
reloads,
render,
get props() {
return current;
},
get tree() {
return tree;
},
all: () => nodes(tree),
form(name) {
return nodes(tree).find((node) => node.props?.id === name);
},
button(label, scope = tree) {
return nodes(scope).find(
(node) => node.type === 'button' && text(node) === label,
);
},
field(name, label) {
const root = this.form(name);
const field = nodes(root).find(
(node) =>
node.type === 'label' &&
(node.props.label === label ||
text(node).startsWith(label)),
);
return nodes(field).find((node) =>
['input', 'select', 'textarea'].includes(node.type),
);
},
change(name, label, value, rerender = true) {
const field = this.field(name, label);
if (!field) throw Error('Missing field ' + name + '/' + label);
field.props.onChange({
target: { value, checked: value, files: value ? [value] : [] },
});
if (rerender) render();
},
submit(name) {
const form = this.form(name);
form.props.onSubmit({ preventDefault() {} });
render();
return sent.at(-1);
},
click(label, scope = tree) {
const button = this.button(label, scope);
if (!button) throw Error('Missing button ' + label);
button.props.onClick();
render();
return sent.at(-1);
},
success(next, request = sent.at(-1)) {
render(next);
request.options.onSuccess?.({ props: next });
request.form.processing = false;
render(next);
},
validation(errors, request = sent.at(-1)) {
request.form.errors = errors;
request.options.onError?.(errors);
request.form.processing = false;
render();
},
fail(request = sent.at(-1), status = null) {
if (status === null) request.options.onNetworkError();
else request.options.onHttpException({ status });
request.form.processing = false;
render();
},
refreshed(next, request = reloads.at(-1)) {
render(next);
request.onSuccess?.({ props: next });
request.onFinish?.();
render(next);
},
leave(preserveState = false) {
let prevented = false;
const previousWindow = globalThis.window;
globalThis.window = windowMock;
try {
for (const fn of before)
fn({
detail: { visit: { method: 'get', preserveState } },
preventDefault() {
prevented = true;
},
});
} finally {
globalThis.window = previousWindow;
}
return prevented;
},
unload() {
let prevented = false;
for (const fn of unload)
fn({
preventDefault() {
prevented = true;
},
returnValue: null,
});
return prevented;
},
get confirmCalls() {
return confirmCalls;
},
};
}