Reorganize RSpade directory structure for clarity

Improve Jqhtml_Integration.js documentation with hydration system explanation
Add jqhtml-laravel integration packages for traditional Laravel projects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-11-24 09:41:48 +00:00
parent 0143f6ae9f
commit bd5809fdbd
20716 changed files with 387 additions and 6444 deletions

View File

@@ -2186,7 +2186,7 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
// Transform the file (will use cache if available)
try {
$transformed_code = \App\RSpade\Core\JavaScript\Js_Transformer::transform($file);
$transformed_code = \App\RSpade\Core\JsParsers\Js_Transformer::transform($file);
// Write transformed code to a temp file
$temp_file = storage_path('rsx-tmp/babel_' . md5($file) . '.js');

View File

@@ -26,8 +26,8 @@ class Core_Bundle extends Rsx_Bundle_Abstract
'app/RSpade/Core/Data',
'app/RSpade/Core/Database',
'app/RSpade/Core/SPA',
'app/RSpade/Core/Debug', // Debug components (JS_Tree_Debug_*)
'app/RSpade/Lib',
'app/RSpade/Components',
],
];
}

View File

@@ -0,0 +1,29 @@
<%--
JS_Tree_Debug_Component
A universal "var_dump" style component for debugging JavaScript values.
Renders any JavaScript value as an expandable/collapsible tree, similar to browser DevTools.
Useful for debugging, displaying error metadata, and inspecting ORM model instances.
$data - The JavaScript value to display. Pass directly (unquoted) for objects/arrays:
$data=this.data.myObject (correct - passes object reference)
$data="<%= this.data.myObject %>" (wrong - stringifies the object)
$expand_depth - How many levels deep to expand by default (default: 1)
$root_label - Optional label for the root element
$show_class_names - If true, display class names for object instances in a small
bordered badge (default: false). Shown after { when expanded,
after } when collapsed. Only for named class instances, not
generic Object or Array.
--%>
<Define:JS_Tree_Debug_Component tag="div" class="js-tree-debug">
<% if (JS_Tree_Debug_Node.get_type(this.args.data) !== 'object' && JS_Tree_Debug_Node.get_type(this.args.data) !== 'array') { %>
<span class="js-tree-debug-value js-tree-debug-<%= JS_Tree_Debug_Node.get_type(this.args.data) %>"><%= JS_Tree_Debug_Node.format_value(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data)) %></span>
<% } else { %>
<JS_Tree_Debug_Node
$data=this.args.data
$expand_depth=(this.args.expand_depth ?? 1)
$label=(this.args.root_label || null)
$show_class_names=(this.args.show_class_names ?? false)
/>
<% } %>
</Define:JS_Tree_Debug_Component>

View File

@@ -0,0 +1,3 @@
class JS_Tree_Debug_Component extends Component {
// No special logic needed at root level - just passes data to JS_Tree_Debug_Node
}

View File

@@ -0,0 +1,68 @@
<%--
JS_Tree_Debug_Node (Internal component for JS_Tree_Debug_Component)
Renders a single expandable node in the debug tree.
Not intended for direct use - use JS_Tree_Debug_Component instead.
$data - The object or array to render
$expand_depth - How many levels deep to expand
$label - Optional key/index label for this node
$show_class_names - If true, display class names for named object instances
--%>
<Define:JS_Tree_Debug_Node tag="div" class="js-tree-debug-node">
<%
const class_name = this.args.show_class_names ? JS_Tree_Debug_Node.get_class_name(this.args.data) : null;
const relationships = JS_Tree_Debug_Node.get_object_relationships(this.args.data);
%>
<span class="js-tree-debug-toggle<%= this.is_expanded ? '' : ' js-tree-debug-collapsed' %>" $sid="toggle" @click=this.toggle>
<i class="bi bi-caret-right-fill js-tree-debug-arrow"></i>
</span>
<% if (this.args.label !== null && this.args.label !== undefined) { %>
<span class="js-tree-debug-key"><%= this.args.label %></span><span class="js-tree-debug-colon">: </span>
<% } %>
<span class="js-tree-debug-bracket"><%= Array.isArray(this.args.data) ? '[' : '{' %></span>
<% if (class_name) { %>
<span class="js-tree-debug-class-badge"><span class="js-tree-debug-class-name"><%= class_name %></span><% if (this.args.data && this.args.data.id !== undefined) { %><span class="js-tree-debug-class-paren">(</span><span class="js-tree-debug-class-id"><%= this.args.data.id %></span><span class="js-tree-debug-class-paren">)</span><% } %></span>
<% } %>
<span class="js-tree-debug-preview-collapsed" $sid="preview_collapsed" style="<%= this.is_expanded ? 'display:none' : '' %>"><%= JS_Tree_Debug_Node.get_preview(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data)) %></span>
<div class="js-tree-debug-children" $sid="children" style="<%= this.is_expanded ? '' : 'display:none' %>">
<%-- Regular data entries --%>
<% for (const [key, value] of JS_Tree_Debug_Node.get_entries(this.args.data, JS_Tree_Debug_Node.get_type(this.args.data))) {
const val_type = JS_Tree_Debug_Node.get_type(value);
const is_expandable = val_type === 'object' || val_type === 'array';
%>
<% if (is_expandable) { %>
<JS_Tree_Debug_Node
$data=value
$expand_depth=((this.args.expand_depth ?? 1) - 1)
$label=key
$show_class_names=(this.args.show_class_names ?? false)
/>
<% } else { %>
<div class="js-tree-debug-leaf">
<span class="js-tree-debug-key"><%= key %></span><span class="js-tree-debug-colon">: </span>
<span class="js-tree-debug-value js-tree-debug-<%= val_type %>"><%= JS_Tree_Debug_Node.format_value(value, val_type) %></span>
</div>
<% } %>
<% } %>
<%-- Relationship nodes (lazy-loaded) --%>
<% for (const rel_name of relationships) { %>
<div class="js-tree-debug-node js-tree-debug-relationship" $sid="rel_<%= rel_name %>">
<%
this.handler_toggle_rel = () => this.toggle_relationship(rel_name);
%>
<span class="js-tree-debug-toggle js-tree-debug-collapsed" @click=this.handler_toggle_rel>
<i class="bi bi-caret-right-fill js-tree-debug-arrow"></i>
</span>
<span class="js-tree-debug-key js-tree-debug-rel-key"><%= rel_name %>()</span><span class="js-tree-debug-colon">: </span>
<span class="js-tree-debug-preview-collapsed">...</span>
<div class="js-tree-debug-rel-children js-tree-debug-children" style="display:none">
<div class="js-tree-debug-leaf js-tree-debug-pending">(click to load)</div>
</div>
</div>
<% } %>
</div>
<span class="js-tree-debug-bracket"><%= Array.isArray(this.args.data) ? ']' : '}' %></span>
</Define:JS_Tree_Debug_Node>

View File

@@ -0,0 +1,257 @@
class JS_Tree_Debug_Node extends Component {
on_create() {
this.is_expanded = (this.args.expand_depth ?? 1) > 0;
// Track relationship loading states: { rel_name: 'pending'|'loading'|'loaded'|'error' }
this.state.rel_states = {};
// Store loaded relationship data: { rel_name: data }
this.state.rel_data = {};
// Store error messages: { rel_name: error_message }
this.state.rel_errors = {};
}
on_ready() {
// Relationships are never auto-loaded - they only load when explicitly expanded
}
toggle() {
this.is_expanded = !this.is_expanded;
this.$sid('children').toggle(this.is_expanded);
this.$sid('toggle').toggleClass('js-tree-debug-collapsed', !this.is_expanded);
this.$sid('preview_collapsed').toggle(!this.is_expanded);
// Note: Relationships are NOT auto-loaded here - they have their own toggle handler
}
/**
* Toggle a relationship node and load its data if not already loaded
*/
toggle_relationship(rel_name) {
const $container = this.$sid('rel_' + rel_name);
const $toggle = $container.find('.js-tree-debug-toggle').first();
const $children = $container.find('.js-tree-debug-rel-children').first();
const $preview = $container.find('.js-tree-debug-preview-collapsed').first();
const is_expanded = !$toggle.hasClass('js-tree-debug-collapsed');
if (is_expanded) {
// Collapse
$toggle.addClass('js-tree-debug-collapsed');
$children.hide();
$preview.show();
} else {
// Expand
$toggle.removeClass('js-tree-debug-collapsed');
$children.show();
$preview.hide();
// Load if not already loaded
if (!this.state.rel_states[rel_name] || this.state.rel_states[rel_name] === 'pending') {
this._load_relationship(rel_name);
}
}
}
/**
* Load a relationship and update the UI
*/
async _load_relationship(rel_name) {
// Validate the relationship function exists
const obj = this.args.data;
if (!obj || typeof obj[rel_name] !== 'function') {
return;
}
this.state.rel_states[rel_name] = 'loading';
this._update_relationship_ui(rel_name);
try {
const result = await obj[rel_name]();
this.state.rel_states[rel_name] = 'loaded';
this.state.rel_data[rel_name] = result;
this._update_relationship_ui(rel_name);
} catch (e) {
this.state.rel_states[rel_name] = 'error';
this.state.rel_errors[rel_name] = e.message || 'Error loading relationship';
this._update_relationship_ui(rel_name);
}
}
/**
* Update the UI for a relationship after loading
*/
_update_relationship_ui(rel_name) {
const $container = this.$sid('rel_' + rel_name);
const $children = $container.find('.js-tree-debug-rel-children').first();
const $preview = $container.find('.js-tree-debug-preview-collapsed').first();
const state = this.state.rel_states[rel_name];
const data = this.state.rel_data[rel_name];
const error = this.state.rel_errors[rel_name];
// Update preview text
if (state === 'loading') {
$preview.html('<span class="js-tree-debug-loading">loading...</span>');
} else if (state === 'error') {
$preview.html('<span class="js-tree-debug-error">error</span>');
} else if (state === 'loaded') {
const type = JS_Tree_Debug_Node.get_type(data);
$preview.text(JS_Tree_Debug_Node.get_preview(data, type) || JS_Tree_Debug_Node.format_value(data, type));
}
// Update children content
$children.empty();
if (state === 'loading') {
$children.html('<div class="js-tree-debug-leaf js-tree-debug-loading">Loading...</div>');
} else if (state === 'error') {
$children.html('<div class="js-tree-debug-leaf js-tree-debug-error">"' + this._escape_html(error) + '"</div>');
} else if (state === 'loaded') {
this._render_relationship_result($children, data, rel_name);
}
}
/**
* Render the result of a relationship fetch into the container
* Renders entries directly (not wrapped in another node) so user doesn't have to double-expand
*/
_render_relationship_result($container, data, rel_name) {
const type = JS_Tree_Debug_Node.get_type(data);
// Handle null/undefined
if (type === 'null' || type === 'undefined') {
$container.html('<div class="js-tree-debug-leaf js-tree-debug-empty">(no data)</div>');
return;
}
// Handle empty array
if (type === 'array' && data.length === 0) {
$container.html('<div class="js-tree-debug-leaf js-tree-debug-empty">(empty array)</div>');
return;
}
// Handle empty object
if (type === 'object' && Object.keys(data).length === 0) {
$container.html('<div class="js-tree-debug-leaf js-tree-debug-empty">(empty object)</div>');
return;
}
// Handle primitive values (shouldn't happen but be safe)
if (type !== 'array' && type !== 'object') {
$container.html('<div class="js-tree-debug-leaf"><span class="js-tree-debug-value js-tree-debug-' + type + '">' +
this._escape_html(JS_Tree_Debug_Node.format_value(data, type)) + '</span></div>');
return;
}
// Render entries directly into container (no wrapper node)
const entries = JS_Tree_Debug_Node.get_entries(data, type);
const expand_depth = Math.max(0, (this.args.expand_depth ?? 1) - 1);
const show_class_names = this.args.show_class_names ?? false;
for (const [key, value] of entries) {
const val_type = JS_Tree_Debug_Node.get_type(value);
const is_expandable = val_type === 'object' || val_type === 'array';
if (is_expandable) {
// Create a node for expandable values
const $node = $('<div>');
$container.append($node);
$node.component('JS_Tree_Debug_Node', {
data: value,
expand_depth: expand_depth,
label: key,
show_class_names: show_class_names
});
} else {
// Render leaf value directly
$container.append(
'<div class="js-tree-debug-leaf">' +
'<span class="js-tree-debug-key">' + this._escape_html(String(key)) + '</span>' +
'<span class="js-tree-debug-colon">: </span>' +
'<span class="js-tree-debug-value js-tree-debug-' + val_type + '">' +
this._escape_html(JS_Tree_Debug_Node.format_value(value, val_type)) +
'</span></div>'
);
}
}
}
_escape_html(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Static helpers for template use
static get_type(val) {
if (val === null) return 'null';
if (val === undefined) return 'undefined';
if (is_array(val)) return 'array';
return typeof val;
}
static get_preview(val, type) {
if (type === 'array') return 'Array(' + val.length + ')';
if (type === 'object') {
const keys = Object.keys(val);
if (keys.length === 0) return '{}';
if (keys.length <= 3) return '{' + keys.join(', ') + '}';
return '{' + keys.slice(0, 3).join(', ') + ', ...}';
}
return '';
}
static get_entries(data, type) {
if (type === 'array') return data.map((v, i) => [i, v]);
return Object.entries(data || {}).sort((a, b) => a[0].localeCompare(b[0]));
}
static format_value(val, type) {
if (type === 'string') return '"' + val + '"';
if (type === 'null') return 'null';
if (type === 'undefined') return 'undefined';
return str(val);
}
/**
* Get the class name of an object if it's a named class instance (not generic Object/Array)
* @param {*} val - Value to check
* @returns {string|null} - Class name or null if generic/primitive
*/
static get_class_name(val) {
if (val === null || val === undefined) return null;
if (Array.isArray(val)) return null;
if (typeof val !== 'object') return null;
const name = val.constructor?.name;
if (!name || name === 'Object') return null;
return name;
}
/**
* Get fetchable relationships for an object
* Returns array of relationship names if object's class has get_relationships()
* @param {*} obj - Object to check
* @returns {string[]} - Array of relationship names, or empty array
*/
static get_object_relationships(obj) {
try {
if (!obj || typeof obj !== 'object') return [];
if (Array.isArray(obj)) return [];
// Check if constructor has get_relationships static method
const ctor = obj.constructor;
if (!ctor || typeof ctor.get_relationships !== 'function') return [];
// Get relationships and validate it returns an array
const relationships = ctor.get_relationships();
if (!Array.isArray(relationships)) return [];
// Filter to only relationships that are actually functions on the object
return relationships.filter(name => {
return typeof name === 'string' && typeof obj[name] === 'function';
});
} catch (e) {
// Any error, just return empty array
return [];
}
}
}

View File

@@ -0,0 +1,150 @@
.js-tree-debug {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
font-size: 13px;
line-height: 1.4;
text-align: left;
width: 800px;
max-width: 100%;
height: 250px;
overflow: auto;
resize: both;
padding: 20px;
border: 1px solid #777;
border-radius: 4px;
background: #fafafa;
.js-tree-debug-node {
margin-left: 0;
}
.js-tree-debug-children {
margin-left: 20px;
border-left: 1px solid #e0e0e0;
padding-left: 8px;
}
.js-tree-debug-leaf {
padding: 1px 0;
}
.js-tree-debug-toggle {
display: inline-block;
width: 16px;
cursor: pointer;
user-select: none;
.js-tree-debug-arrow {
font-size: 10px;
color: #666;
transition: transform 0.15s ease; // @SCSS-ANIM-01-EXCEPTION
display: inline-block;
}
&:not(.js-tree-debug-collapsed) .js-tree-debug-arrow {
transform: rotate(90deg);
}
&:hover .js-tree-debug-arrow {
color: #333;
}
}
.js-tree-debug-key {
color: #881391;
}
.js-tree-debug-colon {
color: #666;
}
.js-tree-debug-preview {
color: #666;
}
.js-tree-debug-preview-collapsed {
color: #999;
font-style: italic;
}
.js-tree-debug-bracket-close {
color: #666;
}
// Value type colors
.js-tree-debug-string {
color: #c41a16;
}
.js-tree-debug-number {
color: #1c00cf;
}
.js-tree-debug-boolean {
color: #0d22aa;
}
.js-tree-debug-null,
.js-tree-debug-undefined {
color: #808080;
font-style: italic;
}
.js-tree-debug-value {
word-break: break-word;
}
.js-tree-debug-class-badge {
display: inline-block;
font-size: 10px;
padding: 0 4px;
margin-left: 4px;
border: 1px solid #ccc;
border-radius: 3px;
background: #f5f5f5;
vertical-align: middle;
line-height: 1.4;
.js-tree-debug-class-name {
color: #881391; // Same as keys
}
.js-tree-debug-class-paren {
color: #666; // Same as colons/symbols
}
.js-tree-debug-class-id {
color: #1c00cf; // Same as numbers
}
}
// Relationship nodes (lazy-loaded)
.js-tree-debug-relationship {
.js-tree-debug-rel-key {
color: #0066cc;
font-style: italic;
}
}
// Loading state
.js-tree-debug-loading {
color: #888;
font-style: italic;
}
// Error state
.js-tree-debug-error {
color: #cc0000;
}
// Empty/no data state
.js-tree-debug-empty {
color: #888;
font-style: italic;
}
// Pending (not yet loaded)
.js-tree-debug-pending {
color: #999;
font-style: italic;
}
}

470
app/RSpade/Core/Js/Form_Utils.js Executable file
View File

@@ -0,0 +1,470 @@
/**
* Form utilities for validation and error handling
*/
class Form_Utils {
/**
* Framework initialization hook to register jQuery plugin
* Creates $.fn.ajax_submit() for form elements
* @private
*/
static _on_framework_core_define(params = {}) {
$.fn.ajax_submit = function(options = {}) {
const $element = $(this);
if (!$element.is('form')) {
throw new Error('ajax_submit() can only be called on form elements');
}
const url = $element.attr('action');
if (!url) {
throw new Error('Form must have an action attribute');
}
const { controller, action } = Ajax.ajax_url_to_controller_action(url);
return Form_Utils.ajax_submit($element, controller, action, options);
};
}
/**
* Shows form validation errors
*
* REQUIRED HTML STRUCTURE:
* For inline field errors to display properly, form fields must follow this structure:
*
* <div class="form-group">
* <label class="form-label" for="field-name">Field Label</label>
* <input class="form-control" id="field-name" name="field-name" type="text">
* </div>
*
* Key requirements:
* - Wrap each field in a container with class "form-group" (or "form-check" / "input-group")
* - Input must have a "name" attribute matching the error key
* - Use "form-control" class on inputs for Bootstrap 5 styling
*
* Accepts three formats:
* - String: Single error shown as alert
* - Array of strings: Multiple errors shown as bulleted alert
* - Object: Field names mapped to errors, shown inline (unmatched shown as alert)
*
* @param {string} parent_selector - jQuery selector for parent element
* @param {string|Object|Array} errors - Error messages to display
* @returns {Promise} Promise that resolves when all animations complete
*/
static apply_form_errors(parent_selector, errors) {
console.error(errors);
const $parent = $(parent_selector);
// Reset the form errors before applying new ones
Form_Utils.reset_form_errors(parent_selector);
// Normalize input to standard format
const normalized = Form_Utils._normalize_errors(errors);
return new Promise((resolve) => {
let animations = [];
if (normalized.type === 'string') {
// Single error message
animations = Form_Utils._apply_general_errors($parent, normalized.data);
} else if (normalized.type === 'array') {
// Array of error messages
const deduplicated = Form_Utils._deduplicate_errors(normalized.data);
animations = Form_Utils._apply_general_errors($parent, deduplicated);
} else if (normalized.type === 'fields') {
// Field-specific errors
const result = Form_Utils._apply_field_errors($parent, normalized.data);
animations = result.animations;
// Count matched fields
const matched_count = Object.keys(normalized.data).length - Object.keys(result.unmatched).length;
const unmatched_deduplicated = Form_Utils._deduplicate_errors(result.unmatched);
const unmatched_count = Object.keys(unmatched_deduplicated).length;
// Show summary alert if there are any field errors (matched or unmatched)
if (matched_count > 0 || unmatched_count > 0) {
// Build summary message
let summary_msg = '';
if (matched_count > 0) {
summary_msg = matched_count === 1
? 'Please correct the error highlighted below.'
: 'Please correct the errors highlighted below.';
}
// If there are unmatched errors, add them as a bulleted list
if (unmatched_count > 0) {
const summary_animations = Form_Utils._apply_combined_error($parent, summary_msg, unmatched_deduplicated);
animations.push(...summary_animations);
} else {
// Just the summary message, no unmatched errors
const summary_animations = Form_Utils._apply_general_errors($parent, summary_msg);
animations.push(...summary_animations);
}
}
}
// Resolve the promise once all animations are complete
Promise.all(animations).then(() => {
// Scroll to error container if it exists
const $error_container = $parent.find('[data-id="error_container"]').first();
if ($error_container.length > 0) {
const container_top = $error_container.offset().top;
// Calculate fixed header offset
const fixed_header_height = Form_Utils._get_fixed_header_height();
// Scroll to position error container 20px below any fixed headers
const target_scroll = container_top - fixed_header_height - 20;
$('html, body').animate({
scrollTop: target_scroll
}, 500);
}
resolve();
});
});
}
/**
* Clears form validation errors and resets all form values to defaults
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
*/
static reset(form_selector) {
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
Form_Utils.reset_form_errors(form_selector);
$form.trigger('reset');
}
/**
* Serializes form data into key-value object
* Returns all input elements with name attributes as object properties
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
* @returns {Object} Form data as key-value pairs
*/
static serialize(form_selector) {
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
const data = {};
$form.serializeArray().forEach((item) => {
data[item.name] = item.value;
});
return data;
}
/**
* Submits form to RSX controller action via AJAX
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
* @param {string} controller - Controller class name (e.g., 'User_Controller')
* @param {string} action - Action method name (e.g., 'save_profile')
* @param {Object} options - Optional configuration {on_success: fn, on_error: fn}
* @returns {Promise} Promise that resolves with response data
*/
static async ajax_submit(form_selector, controller, action, options = {}) {
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
const form_data = Form_Utils.serialize($form);
Form_Utils.reset_form_errors(form_selector);
try {
const response = await Ajax.call(controller, action, form_data);
if (options.on_success) {
options.on_success(response);
}
return response;
} catch (error) {
if (error.type === 'form_error' && error.details) {
await Form_Utils.apply_form_errors(form_selector, error.details);
} else {
await Form_Utils.apply_form_errors(form_selector, error.message || 'An error occurred');
}
if (options.on_error) {
options.on_error(error);
}
throw error;
}
}
/**
* Removes form validation errors
* @param {string} parent_selector - jQuery selector for parent element
*/
static reset_form_errors(parent_selector) {
const $parent = $(parent_selector);
// Remove flash messages
$('.flash-messages').remove();
// Remove alert-danger messages
$parent.find('.alert-danger').remove();
// Remove validation error classes and text from form elements
$parent.find('.is-invalid').removeClass('is-invalid');
$parent.find('.invalid-feedback').remove();
}
// ------------------------
/**
* Normalizes error input into standard formats
* @param {string|Object|Array} errors - Raw error input
* @returns {Object} Normalized errors as {type: 'string'|'array'|'fields', data: ...}
* @private
*/
static _normalize_errors(errors) {
// Handle null/undefined
if (!errors) {
return { type: 'string', data: 'An error has occurred' };
}
// Handle string
if (typeof errors === 'string') {
return { type: 'string', data: errors };
}
// Handle array
if (Array.isArray(errors)) {
// Array of strings - general errors
if (errors.every((e) => typeof e === 'string')) {
return { type: 'array', data: errors };
}
// Array with object as first element - extract it
if (errors.length > 0 && typeof errors[0] === 'object') {
return Form_Utils._normalize_errors(errors[0]);
}
// Empty or mixed array
return { type: 'array', data: [] };
}
// Handle object - check for Laravel response wrapper
if (typeof errors === 'object') {
// Unwrap {errors: {...}} or {error: {...}}
const unwrapped = errors.errors || errors.error;
if (unwrapped) {
return Form_Utils._normalize_errors(unwrapped);
}
// Convert Laravel validator format {field: [msg1, msg2]} to {field: msg1}
const normalized = {};
for (const field in errors) {
if (errors.hasOwnProperty(field)) {
const value = errors[field];
if (Array.isArray(value) && value.length > 0) {
normalized[field] = value[0];
} else if (typeof value === 'string') {
normalized[field] = value;
} else {
normalized[field] = String(value);
}
}
}
return { type: 'fields', data: normalized };
}
// Final catch-all*
return { type: 'string', data: String(errors) };
}
/**
* Removes duplicate error messages from array or object values
* @param {Array|Object} errors - Errors to deduplicate
* @returns {Array|Object} Deduplicated errors
* @private
*/
static _deduplicate_errors(errors) {
if (Array.isArray(errors)) {
return [...new Set(errors)];
}
if (typeof errors === 'object') {
const seen = new Set();
const result = {};
for (const key in errors) {
const value = errors[key];
if (!seen.has(value)) {
seen.add(value);
result[key] = value;
}
}
return result;
}
return errors;
}
/**
* Applies field-specific validation errors to form inputs
* @param {jQuery} $parent - Parent element containing form
* @param {Object} field_errors - Object mapping field names to error messages
* @returns {Object} Object containing {animations: Array, unmatched: Object}
* @private
*/
static _apply_field_errors($parent, field_errors) {
const animations = [];
const unmatched = {};
for (const field_name in field_errors) {
const error_message = field_errors[field_name];
const $input = $parent.find(`[name="${field_name}"]`);
if (!$input.length) {
unmatched[field_name] = error_message;
continue;
}
const $error = $('<div class="invalid-feedback"></div>').html(error_message);
const $target = $input.closest('.form-group, .form-check, .input-group');
if (!$target.length) {
unmatched[field_name] = error_message;
continue;
}
$input.addClass('is-invalid');
$error.appendTo($target);
animations.push($error.hide().fadeIn(300).promise());
}
return { animations, unmatched };
}
/**
* Applies combined error message with summary and unmatched field errors
* @param {jQuery} $parent - Parent element containing form
* @param {string} summary_msg - Summary message (e.g., "Please correct the errors below")
* @param {Object} unmatched_errors - Object of field errors that couldn't be matched to fields
* @returns {Array} Array of animation promises
* @private
*/
static _apply_combined_error($parent, summary_msg, unmatched_errors) {
const animations = [];
const $error_container = $parent.find('[data-id="error_container"]').first();
const $target = $error_container.length > 0 ? $error_container : $parent;
// Create alert with summary message and bulleted list of unmatched errors
const $alert = $('<div class="alert alert-danger" role="alert"></div>');
// Add summary message if provided
if (summary_msg) {
$('<p class="mb-2"></p>').text(summary_msg).appendTo($alert);
}
// Add unmatched errors as bulleted list
if (Object.keys(unmatched_errors).length > 0) {
const $list = $('<ul class="mb-0"></ul>');
for (const field_name in unmatched_errors) {
const error_msg = unmatched_errors[field_name];
$('<li></li>').html(error_msg).appendTo($list);
}
$list.appendTo($alert);
}
if ($error_container.length > 0) {
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
} else {
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
}
return animations;
}
/**
* Applies general error messages as alert box
* @param {jQuery} $parent - Parent element to prepend alert to
* @param {string|Array} messages - Error message(s) to display
* @returns {Array} Array of animation promises
* @private
*/
static _apply_general_errors($parent, messages) {
const animations = [];
// Look for a specific error container div (e.g., in Rsx_Form component)
const $error_container = $parent.find('[data-id="error_container"]').first();
const $target = $error_container.length > 0 ? $error_container : $parent;
if (typeof messages === 'string') {
// Single error - simple alert without list
const $alert = $('<div class="alert alert-danger" role="alert"></div>').text(messages);
if ($error_container.length > 0) {
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
} else {
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
}
} else if (Array.isArray(messages) && messages.length > 0) {
// Multiple errors - bulleted list
const $alert = $('<div class="alert alert-danger" role="alert"><ul class="mb-0"></ul></div>');
const $list = $alert.find('ul');
messages.forEach((msg) => {
const text = (msg + '').trim() || 'An error has occurred';
$('<li></li>').html(text).appendTo($list);
});
if ($error_container.length > 0) {
animations.push($alert.hide().appendTo($target).fadeIn(300).promise());
} else {
animations.push($alert.hide().prependTo($target).fadeIn(300).promise());
}
} else if (typeof messages === 'object' && !Array.isArray(messages)) {
// Object of unmatched field errors - convert to array
const error_list = Object.values(messages)
.map((v) => String(v).trim())
.filter((v) => v);
if (error_list.length > 0) {
return Form_Utils._apply_general_errors($parent, error_list);
}
}
return animations;
}
/**
* Calculates the total height of fixed/sticky headers at the top of the page
* @returns {number} Total height in pixels of fixed top elements
* @private
*/
static _get_fixed_header_height() {
let total_height = 0;
// Find all fixed or sticky positioned elements
$('*').each(function() {
const $el = $(this);
const position = $el.css('position');
// Only check fixed or sticky elements
if (position !== 'fixed' && position !== 'sticky') {
return;
}
// Check if element is positioned at or near the top
const top = parseInt($el.css('top')) || 0;
if (top > 50) {
return; // Not a top header
}
// Check if element is visible
if (!$el.is(':visible')) {
return;
}
// Check if element spans significant width (likely a header/navbar)
const width = $el.outerWidth();
const viewport_width = $(window).width();
if (width < viewport_width * 0.5) {
return; // Too narrow to be a header
}
// Add this element's height
total_height += $el.outerHeight();
});
return total_height;
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\RSpade\Core\JavaScript;
namespace App\RSpade\Core\JsParsers;
use RuntimeException;

View File

@@ -5,12 +5,12 @@
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\JavaScript;
namespace App\RSpade\Core\JsParsers;
use Illuminate\Support\Facades\File;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use App\RSpade\Core\JavaScript\Js_Exception;
use App\RSpade\Core\JsParsers\Js_Exception;
class Js_Parser
{
@@ -22,7 +22,7 @@ class Js_Parser
/**
* Node.js RPC server script path
*/
protected const RPC_SERVER_SCRIPT = 'app/RSpade/Core/JavaScript/resource/js-parser-server.js';
protected const RPC_SERVER_SCRIPT = 'app/RSpade/Core/JsParsers/resource/js-parser-server.js';
/**
* Unix socket path for RPC server

View File

@@ -10,7 +10,7 @@
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\JavaScript;
namespace App\RSpade\Core\JsParsers;
use Illuminate\Support\Facades\File;
use Symfony\Component\Process\Process;
@@ -22,12 +22,12 @@ class Js_Transformer
/**
* Node.js transformer script path (RPC server)
*/
protected const RPC_SERVER_SCRIPT = 'app/RSpade/Core/JavaScript/resource/js-transformer-server.js';
protected const RPC_SERVER_SCRIPT = 'app/RSpade/Core/JsParsers/resource/js-transformer-server.js';
/**
* Transformer script path for availability checking (RPC server used for actual transformations)
*/
protected const TRANSFORMER_SCRIPT = 'app/RSpade/Core/JavaScript/resource/js-transformer.js';
protected const TRANSFORMER_SCRIPT = 'app/RSpade/Core/JsParsers/resource/js-transformer.js';
/**
* Cache directory for transformed JavaScript files

View File

@@ -99,7 +99,7 @@ When `start_rpc_server()` is called:
```php
$process = new Process([
'node',
base_path('app/RSpade/Core/JavaScript/resource/js-parser-server.js'),
base_path('app/RSpade/Core/JsParsers/resource/js-parser-server.js'),
'--socket=' . $socket_path
]);
$process->start();
@@ -348,11 +348,11 @@ When implementing RPC server for another operation:
Reference these when implementing pattern elsewhere:
- **PHP Server Management:** `/system/app/RSpade/Core/JavaScript/Js_Parser.php`
- **PHP Server Management:** `/system/app/RSpade/Core/JsParsers/Js_Parser.php`
- Methods: `start_rpc_server()`, `stop_rpc_server()`, `ping_rpc_server()`
- Lines: 528-680
- **Node.js RPC Server:** `/system/app/RSpade/Core/JavaScript/resource/js-parser-server.js`
- **Node.js RPC Server:** `/system/app/RSpade/Core/JsParsers/resource/js-parser-server.js`
- Full example server with line-delimited JSON handling
- Socket cleanup, signal handlers, graceful shutdown
@@ -398,7 +398,7 @@ echo '{"id":1,"method":"ping"}' | nc -U storage/rsx-tmp/js-parser-server.sock
### Manual Server Test
```bash
# Start server manually
node system/app/RSpade/Core/JavaScript/resource/js-parser-server.js \
node system/app/RSpade/Core/JsParsers/resource/js-parser-server.js \
--socket=/tmp/test.sock
# In another terminal:

View File

@@ -51,7 +51,7 @@ The build process in Manifest.php follows these phases:
### Phase 1: File Discovery (__discover_files)
- Scans directories from `config('rsx.manifest.scan_directories')`
- Default: `['rsx', 'app/RSpade/Core/Js', 'app/RSpade/Modules']`
- Default: `['rsx', 'app/RSpade/Core/Js', 'app/RSpade/Core/Manifest/Modules']`
- Excludes: vendor/, node_modules/, .git/, storage/, public/
- Returns array of file paths with basic stats (mtime, size)
@@ -213,7 +213,7 @@ This ensures **fail-fast behavior** - no silent failures.
### Built-in Modules
Located in `/app/RSpade/Modules/`:
Located in `/app/RSpade/Core/Manifest/Modules/`:
- `Php_ManifestModule` - PHP reflection and attribute extraction
- `Blade_ManifestModule` - Blade directive parsing
@@ -310,7 +310,7 @@ When testing manifest functionality:
- Cache file: `storage/rsx-build/manifest_data.php`
- JS stubs: `storage/rsx-build/js-stubs/`
- Model stubs: `storage/rsx-build/js-model-stubs/`
- Default scan dirs: `['rsx', 'app/RSpade/Core/Js', 'app/RSpade/Modules']`
- Default scan dirs: `['rsx', 'app/RSpade/Core/Js', 'app/RSpade/Core/Manifest/Modules']`
## Direct Data Access

View File

@@ -2494,7 +2494,7 @@ class Manifest
case 'js':
console_debug('BUILD', "Parsing JS file: {$file_path}");
$js_metadata = \App\RSpade\Core\JavaScript\Js_Parser::extract_metadata($absolute_path);
$js_metadata = \App\RSpade\Core\JsParsers\Js_Parser::extract_metadata($absolute_path);
$data = array_merge($data, $js_metadata);
break;

View File

@@ -0,0 +1,170 @@
<?php
namespace App\RSpade\Core\Manifest\Modules;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use ReflectionClass;
use App\RSpade\Core\Cache\RsxCache;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
/**
* Support module for extracting database metadata for Rsx_Model_Abstract classes
* This runs after the primary manifest is built to add model metadata
*/
class Model_ManifestSupport extends ManifestSupport_Abstract
{
/**
* Process the manifest and add model database metadata
*
* @param array &$manifest_data Reference to the manifest data array
* @return void
*/
public static function process(array &$manifest_data): void
{
// Initialize models key if it doesn't exist
if (!isset($manifest_data['data']['models'])) {
$manifest_data['data']['models'] = [];
}
// All PHP files should already be loaded in Phase 3 of manifest processing
// Get all classes extending Rsx_Model_Abstract
$model_entries = Manifest::php_get_extending('Rsx_Model_Abstract');
foreach ($model_entries as $model_entry) {
if (!isset($model_entry['fqcn'])) {
continue;
}
$fqcn = $model_entry['fqcn'];
$class_name = $model_entry['class'] ?? '';
$cachekey = 'Model_ManifestSupport_' . $model_entry['file'] . '__' . $model_entry['hash'];
$cache = RsxCache::get_persistent($cachekey);
if (!empty($cache)) {
$manifest_data['data']['models'][$class_name] = $cache;
}
include_once($model_entry['file']);
// Check if class is abstract
if (\App\RSpade\Core\Manifest\Manifest::php_is_abstract($fqcn)) {
// Skip abstract classes
continue;
}
// Instantiate the model to get table name
$model_instance = new $fqcn();
$table_name = $model_instance->getTable();
// Check if table exists
if (!Schema::hasTable($table_name)) {
// This is acceptable, maybe migrations didnt run or migrations are broken. Just skip adding
// the database metadata to the manifest.
continue;
}
// Get column information
$columns = [];
// Use SHOW COLUMNS to get column information
$column_results = DB::select("SHOW COLUMNS FROM `{$table_name}`");
foreach ($column_results as $column) {
$columns[$column->Field] = [
'type' => static::__parse_column_type($column->Type),
'nullable' => ($column->Null === 'YES'),
'key' => $column->Key,
'default' => $column->Default,
'extra' => $column->Extra,
];
}
// Look up the file's metadata to get public_static_methods extracted during reflection
$file_path = $model_entry['file'] ?? '';
$public_static_methods = [];
// Find the public_static_methods data from the files array
if ($file_path && isset($manifest_data['data']['files'][$file_path])) {
$file_metadata = $manifest_data['data']['files'][$file_path];
$public_static_methods = $file_metadata['public_static_methods'] ?? [];
}
// Store model metadata with database info AND preserved public_static_methods
$full_data = [
'fqcn' => $fqcn,
'file' => $file_path,
'table' => $table_name,
'columns' => $columns,
'public_static_methods' => $public_static_methods, // Include the public static methods
'class' => $class_name, // Ajax_Endpoint_Controller expects this
];
$manifest_data['data']['models'][$class_name] = $full_data;
RsxCache::set_persistent($cachekey, $full_data);
}
}
/**
* Parse MySQL column type to a simpler format
*
* CRITICAL: TINYINT(1) is treated as boolean (common MySQL boolean convention)
* All other TINYINT sizes are treated as integers
*
* @param string $type MySQL column type (e.g., "varchar(255)", "tinyint(1)")
* @return string Simplified type
*/
protected static function __parse_column_type(string $type): string
{
// Special case: tinyint(1) is boolean
if (preg_match('/^tinyint\(1\)/i', $type)) {
return 'boolean';
}
// Extract base type without parameters
if (preg_match('/^([a-z]+)/', $type, $matches)) {
$base_type = $matches[1];
// Map MySQL types to simplified types
$type_map = [
'int' => 'integer',
'bigint' => 'integer',
'tinyint' => 'integer', // tinyint(1) handled above, others are integers
'smallint' => 'integer',
'mediumint' => 'integer',
'varchar' => 'string',
'char' => 'string',
'text' => 'text',
'mediumtext' => 'text',
'longtext' => 'text',
'datetime' => 'datetime',
'timestamp' => 'datetime',
'date' => 'date',
'time' => 'time',
'decimal' => 'decimal',
'float' => 'float',
'double' => 'double',
'boolean' => 'boolean',
'json' => 'json',
'enum' => 'enum',
];
return $type_map[$base_type] ?? $base_type;
}
return $type;
}
/**
* Get the name of this support module
*
* @return string
*/
public static function get_name(): string
{
return 'Model Database Metadata';
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\RSpade\Core\Task;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use App\RSpade\Core\Service\Rsx_Service_Abstract;
use App\RSpade\Core\Task\Task_Instance;
use App\RSpade\Core\Task\Task_Status;
/**
* Cleanup Service
*
* System maintenance tasks that run on a schedule.
* Handles cleanup of old data and temporary files.
*/
class Cleanup_Service extends Rsx_Service_Abstract
{
/**
* Clean up old completed/failed tasks
*
* Deletes task records older than the retention period (default: 30 days)
* Keeps pending/running tasks regardless of age.
*
* Runs daily at 3 AM
*/
#[Task_Attribute('Clean up old completed and failed task records')]
#[Schedule('0 3 * * *')]
public static function cleanup_old_tasks(Task_Instance $task, array $params = []): array
{
$retention_days = config('rsx.tasks.task_retention_days', 30);
$cutoff_date = now()->subDays($retention_days);
$task->info("Cleaning up tasks older than {$retention_days} days (before {$cutoff_date})");
// Delete old completed tasks
$deleted_completed = DB::table('_tasks')
->where('status', Task_Status::COMPLETED)
->where('completed_at', '<', $cutoff_date)
->delete();
$task->info("Deleted {$deleted_completed} completed task(s)");
// Delete old failed tasks
$deleted_failed = DB::table('_tasks')
->where('status', Task_Status::FAILED)
->where('completed_at', '<', $cutoff_date)
->delete();
$task->info("Deleted {$deleted_failed} failed task(s)");
$total_deleted = $deleted_completed + $deleted_failed;
return [
'deleted_completed' => $deleted_completed,
'deleted_failed' => $deleted_failed,
'total_deleted' => $total_deleted,
'retention_days' => $retention_days,
];
}
/**
* Clean up orphaned task temporary directories
*
* Removes temp directories for tasks that no longer exist or are completed.
* Checks the temp_expires_at timestamp for cleanup eligibility.
*
* Runs every hour
*/
#[Task_Attribute('Clean up orphaned task temporary directories')]
#[Schedule('0 * * * *')]
public static function cleanup_temp_directories(Task_Instance $task, array $params = []): array
{
$base_temp_dir = storage_path('rsx-tmp/tasks');
if (!is_dir($base_temp_dir)) {
$task->info("Temp directory does not exist: {$base_temp_dir}");
return ['directories_removed' => 0];
}
$task->info("Scanning temp directory: {$base_temp_dir}");
$directories_removed = 0;
$directories = File::directories($base_temp_dir);
foreach ($directories as $dir) {
$dir_name = basename($dir);
// Check if it's an immediate task temp dir (these should be cleaned up by the task itself)
if (str_starts_with($dir_name, 'immediate_')) {
// Check if directory is old (more than 1 hour)
$dir_time = filemtime($dir);
if (time() - $dir_time > 3600) {
$task->info("Removing old immediate temp directory: {$dir_name}");
File::deleteDirectory($dir);
$directories_removed++;
}
continue;
}
// Extract task ID from directory name (format: task_123)
if (preg_match('/^task_(\d+)$/', $dir_name, $matches)) {
$task_id = (int) $matches[1];
// Check if task still exists
$task_row = DB::table('_tasks')->where('id', $task_id)->first();
if (!$task_row) {
// Task doesn't exist, remove directory
$task->info("Removing orphaned temp directory for deleted task: {$dir_name}");
File::deleteDirectory($dir);
$directories_removed++;
continue;
}
// Check if task is completed/failed
if (in_array($task_row->status, [Task_Status::COMPLETED, Task_Status::FAILED])) {
$task->info("Removing temp directory for completed/failed task: {$dir_name}");
File::deleteDirectory($dir);
$directories_removed++;
continue;
}
}
}
$task->info("Removed {$directories_removed} temp director(ies)");
return [
'directories_removed' => $directories_removed,
'total_directories_scanned' => count($directories),
];
}
}

View File

@@ -0,0 +1,260 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Testing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
/**
* Rsx_Test_Abstract - Base class for RSX framework tests
*
* Provides simple test framework in the RSX spirit - no complex dependencies,
* just straightforward testing with clear pass/fail results
*/
abstract class Rsx_Test_Abstract
{
/**
* Test results storage
*/
protected static $results = [];
protected static $current_test = null;
/**
* Initialize the test class
* Called before running tests
*/
public static function setup()
{
// Override in child classes for test setup
}
/**
* Clean up after tests
* Called after all tests complete
*/
public static function teardown()
{
// Override in child classes for cleanup
}
/**
* Run all test methods in this class
*
* @return array Test results
*/
public static function run()
{
static::$results = [];
// Call setup
static::setup();
// Get all methods starting with 'test_'
$reflection = new \ReflectionClass(static::class);
$methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC);
foreach ($methods as $method) {
if (strpos($method->getName(), 'test_') === 0) {
static::$current_test = $method->getName();
try {
// Run the test
$method->invoke(null);
// If we got here without an exception, test passed
if (!isset(static::$results[static::$current_test])) {
static::$results[static::$current_test] = [
'status' => 'passed',
'message' => 'Test completed successfully'
];
}
} catch (\Exception $e) {
static::$results[static::$current_test] = [
'status' => 'failed',
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine()
];
}
}
}
// Call teardown
static::teardown();
return static::$results;
}
/**
* Assert that a condition is true
*
* @param bool $condition
* @param string $message
*/
protected static function __assert_true($condition, $message = 'Assertion failed')
{
if (!$condition) {
throw new \Exception($message);
}
}
/**
* Assert that a condition is false
*
* @param bool $condition
* @param string $message
*/
protected static function __assert_false($condition, $message = 'Assertion failed')
{
static::__assert_true(!$condition, $message);
}
/**
* Assert that two values are equal
*
* @param mixed $expected
* @param mixed $actual
* @param string $message
*/
protected static function __assert_equals($expected, $actual, $message = null)
{
if ($expected !== $actual) {
$message = $message ?: "Expected '" . var_export($expected, true) .
"' but got '" . var_export($actual, true) . "'";
throw new \Exception($message);
}
}
/**
* Assert that two values are not equal
*
* @param mixed $expected
* @param mixed $actual
* @param string $message
*/
protected static function __assert_not_equals($expected, $actual, $message = null)
{
if ($expected === $actual) {
$message = $message ?: "Values should not be equal: '" . var_export($expected, true) . "'";
throw new \Exception($message);
}
}
/**
* Assert that a string contains a substring
*
* @param string $needle
* @param string $haystack
* @param string $message
*/
protected static function __assert_contains($needle, $haystack, $message = null)
{
if (strpos($haystack, $needle) === false) {
$message = $message ?: "String does not contain '{$needle}'";
throw new \Exception($message);
}
}
/**
* Assert that an array has a key
*
* @param string $key
* @param array $array
* @param string $message
*/
protected static function __assert_array_has_key($key, $array, $message = null)
{
if (!array_key_exists($key, $array)) {
$message = $message ?: "Array does not have key '{$key}'";
throw new \Exception($message);
}
}
/**
* Assert that a value is null
*
* @param mixed $value
* @param string $message
*/
protected static function __assert_null($value, $message = 'Value should be null')
{
static::__assert_true($value === null, $message);
}
/**
* Assert that a value is not null
*
* @param mixed $value
* @param string $message
*/
protected static function __assert_not_null($value, $message = 'Value should not be null')
{
static::__assert_true($value !== null, $message);
}
/**
* Make a test HTTP request
*
* @param string $url
* @param string $method
* @param array $data
* @return \Illuminate\Http\Client\Response
*/
protected static function __request($url, $method = 'GET', $data = [])
{
$full_url = 'http://localhost' . $url;
switch (strtoupper($method)) {
case 'POST':
return Http::post($full_url, $data);
case 'GET':
return Http::get($full_url, $data);
default:
throw new \Exception("Unsupported HTTP method: {$method}");
}
}
/**
* Mark current test as passed with optional message
*
* @param string $message
*/
protected static function __pass($message = 'Test passed')
{
static::$results[static::$current_test] = [
'status' => 'passed',
'message' => $message
];
}
/**
* Mark current test as failed
*
* @param string $message
*/
protected static function __fail($message = 'Test failed')
{
throw new \Exception($message);
}
/**
* Skip current test
*
* @param string $reason
*/
protected static function __skip($reason = 'Test skipped')
{
static::$results[static::$current_test] = [
'status' => 'skipped',
'message' => $reason
];
// Throw special exception to stop test execution but not mark as failed
throw new \Exception('__SKIP__:' . $reason);
}
}