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
3 changes: 2 additions & 1 deletion cli/lucli/services/Admin.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ component {
) {
var result = {success: true, generated: [], errors: []};
var singular = lCase(arguments.modelData.model);
var plural = variables.helpers.pluralize(singular);
// Prefer tableName from modelData for view/controller paths when provided
var plural = len(arguments.modelData.tableName ?: "") ? lCase(arguments.modelData.tableName) : variables.helpers.pluralize(singular);
var singularCap = variables.helpers.capitalize(singular);
var pluralCap = variables.helpers.capitalize(plural);

Expand Down
2 changes: 1 addition & 1 deletion cli/lucli/services/Doctor.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ component {
if (findNoCase("No migrations", combined)) {
arrayAppend(recs, "Run 'wheels generate migration' to create your first migration");
}
if (findNoCase("No test files", combined)) {
if (findNoCase("No test files", combined) || findNoCase("Missing recommended directory: tests", combined)) {
arrayAppend(recs, "Run 'wheels generate test' to add test coverage");
}
if (findNoCase("Missing required directory", combined)) {
Expand Down
2 changes: 1 addition & 1 deletion cli/lucli/services/Helpers.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ component {
loc.rv = loc.text;

if (count != 1) {
if (reFindNoCase("[A-Z]", loc.text)) {
if (reFind("[A-Z]", loc.text)) {
loc.upperCasePos = reFind("[A-Z]", reverse(loc.text));
loc.prepend = mid(loc.text, 1, len(loc.text) - loc.upperCasePos);
loc.text = reverse(mid(reverse(loc.text), 1, loc.upperCasePos));
Expand Down
11 changes: 11 additions & 0 deletions cli/lucli/tests/Application.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
component {

this.name = "WheelsCLITests_" & hash(getCurrentTemplatePath());

// Map to project root so tests can instantiate cli.lucli.services.* and wheels.wheelstest.*
local.projectRoot = expandPath("../../../");
this.mappings["/cli"] = local.projectRoot & "cli/";
this.mappings["/wheels"] = local.projectRoot & "vendor/wheels/";
this.mappings["/vendor"] = local.projectRoot & "vendor/";

}
170 changes: 170 additions & 0 deletions cli/lucli/tests/TestHelper.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Shared test utilities for CLI module specs.
*
* Provides temp project scaffolding (copies project skeleton to temp dir),
* HTTP helper for integration tests, and server port detection.
*/
component {

/**
* Copy the project skeleton into a temp directory for isolated testing.
* Returns the absolute path to the temp project root.
*/
public string function scaffoldTempProject(required string sourceRoot) {
var tempBase = getTempDirectory() & "wheels-cli-test-" & createUUID();
directoryCreate(tempBase, true);

// Copy app structure
var dirs = ["app", "config", "tests/specs", "public"];
for (var dir in dirs) {
var srcPath = arguments.sourceRoot & "/" & dir;
var destPath = tempBase & "/" & dir;
if (directoryExists(srcPath)) {
directoryCopy(srcPath, destPath, true);
} else {
directoryCreate(destPath, true);
}
}

// Ensure all required and recommended directories exist in the temp project.
// directoryCopy skips empty source directories, so we create them explicitly.
var requiredDirs = [
"app",
"app/controllers",
"app/models",
"app/views",
"app/helpers",
"app/migrator",
"app/migrator/migrations",
"config",
"public",
"public/files",
"tests",
"tests/specs",
"tests/specs/models",
"tests/specs/controllers",
"tests/specs/views"
];
for (var reqDir in requiredDirs) {
var fullPath = tempBase & "/" & reqDir;
if (!directoryExists(fullPath)) {
directoryCreate(fullPath, true);
}
}

// Ensure config/routes.cfm exists with minimal valid content
var routesPath = tempBase & "/config/routes.cfm";
if (!fileExists(routesPath)) {
var nl = chr(10);
var t = chr(9);
var routesContent = "// routes" & nl & "mapper()" & nl & t & "// CLI-Appends-Here" & nl & t & ".wildcard()" & nl & ".end();";
fileWrite(routesPath, routesContent);
}

// Ensure config/settings.cfm exists with datasource config (satisfies Doctor health check)
var settingsPath = tempBase & "/config/settings.cfm";
if (!fileExists(settingsPath)) {
var nl = chr(10);
var settingsContent = "// settings" & nl & "set(dataSourceName=" & chr(34) & "wheels" & chr(34) & ");";
fileWrite(settingsPath, settingsContent);
}

// Create a placeholder migration so Doctor doesn't warn about missing migrations
var migrationPlaceholderPath = tempBase & "/app/migrator/migrations/00000000000000_placeholder.cfc";
if (!fileExists(migrationPlaceholderPath)) {
fileWrite(migrationPlaceholderPath, "component extends=" & chr(34) & "wheels.migrator.Migration" & chr(34) & " {}");
}

// Create a placeholder test spec so Doctor doesn't warn about missing tests
var testPlaceholderPath = tempBase & "/tests/specs/PlaceholderSpec.cfc";
if (!fileExists(testPlaceholderPath)) {
fileWrite(testPlaceholderPath, "component extends=" & chr(34) & "wheels.WheelsTest" & chr(34) & " {}");
}

// Copy key config files from root
var files = [".env", "lucee.json"];
for (var f in files) {
var srcFile = arguments.sourceRoot & "/" & f;
if (fileExists(srcFile)) {
fileCopy(srcFile, tempBase & "/" & f);
}
}

return tempBase;
}

/**
* Delete the temp project directory.
*/
public void function cleanupTempProject(required string tempRoot) {
if (len(arguments.tempRoot) > 10 && directoryExists(arguments.tempRoot)) {
directoryDelete(arguments.tempRoot, true);
}
}

/**
* Detect a running server port.
* Checks PORT env var first, then probes 8080 and 60007.
* Returns port number or 0 if no server found.
*/
public numeric function detectServerPort() {
// Check environment variable (set by CI)
var envPort = createObject("java", "java.lang.System").getenv("PORT");
if (!isNull(envPort) && len(envPort) && isPortResponding(val(envPort))) {
return val(envPort);
}

// Probe common ports
if (isPortResponding(8080)) return 8080;
if (isPortResponding(60007)) return 60007;

return 0;
}

/**
* HTTP GET request, returns response body string.
* Returns empty string on connection failure.
*/
public string function httpGet(required string url) {
try {
var javaUrl = createObject("java", "java.net.URL").init(arguments.url);
var conn = javaUrl.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(5000);
conn.setReadTimeout(30000);

var responseCode = conn.getResponseCode();
var inputStream = responseCode >= 400
? conn.getErrorStream()
: conn.getInputStream();
var scanner = createObject("java", "java.util.Scanner")
.init(inputStream, "UTF-8");
var response = "";
while (scanner.hasNextLine()) {
response &= scanner.nextLine() & chr(10);
}
scanner.close();
return trim(response);
} catch (any e) {
return "";
}
}

/**
* Check if a port is responding to HTTP.
*/
private boolean function isPortResponding(required numeric port) {
try {
var javaUrl = createObject("java", "java.net.URL")
.init("http://localhost:#arguments.port#/");
var conn = javaUrl.openConnection();
conn.setConnectTimeout(2000);
conn.setReadTimeout(2000);
conn.getResponseCode();
return true;
} catch (any e) {
return false;
}
}

}
36 changes: 36 additions & 0 deletions cli/lucli/tests/runner.cfm
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<cfsetting requestTimeOut="300">
<cfscript>
try {
testBox = new wheels.wheelstest.system.TestBox(
directory = "cli.lucli.tests.specs",
options = { coverage = { enabled = false } }
);

local.sortedArray = testBox.getBundles();
arraySort(local.sortedArray, "textNoCase");
testBox.setBundles(local.sortedArray);

if (!structKeyExists(url, "format") || url.format == "html") {
result = testBox.run(
reporter = "wheels.wheelstest.system.reports.SimpleReporter"
);
} else if (url.format == "json") {
result = testBox.run(
reporter = "wheels.wheelstest.system.reports.JSONReporter"
);
cfcontent(type = "application/json");
local.parsed = deserializeJSON(result);
if (local.parsed.totalFail > 0 || local.parsed.totalError > 0) {
cfheader(statuscode = 417);
} else {
cfheader(statuscode = 200);
}
}

writeOutput(result);
} catch (any e) {
cfheader(statuscode = 500);
cfcontent(type = "application/json");
writeOutput('{"success":false,"error":"' & replace(e.message, '"', '\"', 'all') & '"}');
}
</cfscript>
68 changes: 68 additions & 0 deletions cli/lucli/tests/specs/integration/DbCommandsSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
component extends="wheels.wheelstest.system.BaseSpec" {

function beforeAll() {
variables.testHelper = new cli.lucli.tests.TestHelper();
variables.serverPort = testHelper.detectServerPort();
variables.skipIntegration = (variables.serverPort == 0);
if (variables.skipIntegration) {
variables.skipReason = "No running server detected — skipping integration tests";
}
variables.baseUrl = "http://localhost:#variables.serverPort#";
}

function run() {

describe("DB Commands Integration", () => {

it("dbStatus returns valid JSON with migrations", () => {
if (skipIntegration) { debug(skipReason); return; }

var response = testHelper.httpGet(
"#baseUrl#/wheels/cli?command=dbStatus&format=json"
);
expect(len(response)).toBeGT(0);

var data = deserializeJSON(response);
expect(data.success).toBeTrue();
expect(structKeyExists(data, "migrations")).toBeTrue();
expect(isArray(data.migrations)).toBeTrue();
expect(structKeyExists(data, "summary")).toBeTrue();
expect(data.summary.total).toBeGTE(0);
expect(data.summary.applied).toBeGTE(0);
expect(data.summary.pending).toBeGTE(0);
});

it("dbStatus migration entries have required fields", () => {
if (skipIntegration) { debug(skipReason); return; }

var response = testHelper.httpGet(
"#baseUrl#/wheels/cli?command=dbStatus&format=json"
);
var data = deserializeJSON(response);

if (arrayLen(data.migrations) > 0) {
var m = data.migrations[1];
expect(structKeyExists(m, "version")).toBeTrue();
expect(structKeyExists(m, "description")).toBeTrue();
expect(structKeyExists(m, "status")).toBeTrue();
}
});

it("dbVersion returns current version", () => {
if (skipIntegration) { debug(skipReason); return; }

var response = testHelper.httpGet(
"#baseUrl#/wheels/cli?command=dbVersion&format=json"
);
expect(len(response)).toBeGT(0);

var data = deserializeJSON(response);
expect(data.success).toBeTrue();
expect(structKeyExists(data, "version")).toBeTrue();
});

});

}

}
Loading
Loading