Skip to content

Commit 60e16d4

Browse files
committed
refactor: Add confirmation dialog for the cache clean command
1 parent 2691fb4 commit 60e16d4

4 files changed

Lines changed: 343 additions & 31 deletions

File tree

packages/cli/lib/cli/commands/cache.js

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import chalk from "chalk";
22
import path from "node:path";
33
import os from "node:os";
44
import process from "node:process";
5+
import readline from "node:readline";
56
import baseMiddleware from "../middlewares/base.js";
67
import Configuration from "@ui5/project/config/Configuration";
7-
import {cleanCache} from "@ui5/project/build/cache/CacheCleanup";
8+
import {cleanCache, getCacheInfo} from "@ui5/project/build/cache/CacheCleanup";
89

910
const cacheCommand = {
1011
command: "cache",
@@ -44,6 +45,26 @@ function formatSize(bytes) {
4445
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
4546
}
4647

48+
/**
49+
* Prompt user for confirmation.
50+
*
51+
* @param {string} question The question to ask
52+
* @returns {Promise<boolean>} True if user confirmed
53+
*/
54+
async function confirm(question) {
55+
const rl = readline.createInterface({
56+
input: process.stdin,
57+
output: process.stderr
58+
});
59+
60+
return new Promise((resolve) => {
61+
rl.question(question, (answer) => {
62+
rl.close();
63+
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
64+
});
65+
});
66+
}
67+
4768
async function handleCache() {
4869
// Resolve UI5 data directory
4970
let ui5DataDir = process.env.UI5_DATA_DIR;
@@ -57,20 +78,42 @@ async function handleCache() {
5778
ui5DataDir = path.join(os.homedir(), ".ui5");
5879
}
5980

60-
const result = await cleanCache({ui5DataDir});
81+
// Check what items exist before cleaning
82+
const items = await getCacheInfo({ui5DataDir});
6183

62-
if (result.totalCount === 0) {
84+
if (items.length === 0) {
6385
process.stderr.write("Nothing to clean\n");
6486
return;
6587
}
6688

89+
// Display items that will be removed
90+
process.stderr.write(chalk.bold("\nThe following items from cache will be removed:\n"));
91+
let totalSize = 0;
92+
for (const item of items) {
93+
totalSize += item.size;
94+
const sizeStr = item.size > 0 ? ` (${formatSize(item.size)})` : "";
95+
process.stderr.write(` ${chalk.yellow("•")} ${item.path}${sizeStr}\n`);
96+
}
97+
process.stderr.write(chalk.bold(`\nTotal: ${formatSize(totalSize)}\n\n`));
98+
99+
// Ask for confirmation
100+
const confirmed = await confirm("Do you want to continue? (y/N) ");
101+
if (!confirmed) {
102+
process.stderr.write("Cancelled\n");
103+
return;
104+
}
105+
106+
// Perform the actual cleanup
107+
const result = await cleanCache({ui5DataDir});
108+
109+
process.stderr.write("\n");
67110
for (const entry of result.entries) {
68111
const sizeStr = entry.size > 0 ? ` (${formatSize(entry.size)})` : "";
69-
process.stderr.write(`Removed ${chalk.bold(entry.path)}${sizeStr}\n`);
112+
process.stderr.write(`${chalk.green("✓")} Removed ${chalk.bold(entry.path)}${sizeStr}\n`);
70113
}
71114

72115
process.stderr.write(
73-
`\nCleaned ${result.totalCount} ${result.totalCount === 1 ? "entry" : "entries"}` +
116+
`\n${chalk.green("Success:")} Cleaned ${result.totalCount} ${result.totalCount === 1 ? "entry" : "entries"}` +
74117
(result.totalSize > 0 ? `, freed ${formatSize(result.totalSize)}` : "") + "\n"
75118
);
76119
}

packages/cli/test/lib/cli/commands/cache.js

Lines changed: 190 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,24 @@ test.beforeEach(async (t) => {
2424
sinon.stub(Configuration, "fromFile").resolves(new Configuration({}));
2525

2626
t.context.cleanCacheStub = sinon.stub();
27+
t.context.getCacheInfoStub = sinon.stub();
28+
29+
// Mock readline to simulate user confirmation
30+
const mockRLInterface = {
31+
question: sinon.stub(),
32+
close: sinon.stub()
33+
};
34+
t.context.readlineCreateInterfaceStub = sinon.stub().returns(mockRLInterface);
35+
t.context.mockRLInterface = mockRLInterface;
2736

2837
t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", {
2938
"@ui5/project/config/Configuration": t.context.Configuration,
3039
"@ui5/project/build/cache/CacheCleanup": {
3140
cleanCache: t.context.cleanCacheStub,
41+
getCacheInfo: t.context.getCacheInfoStub,
42+
},
43+
"node:readline": {
44+
createInterface: t.context.readlineCreateInterfaceStub,
3245
},
3346
});
3447
});
@@ -38,24 +51,53 @@ test.afterEach.always((t) => {
3851
esmock.purge(t.context.cache);
3952
});
4053

54+
test("Command builder", async (t) => {
55+
// Import cache module directly for builder test (before beforeEach stubs are created)
56+
const cacheModule = await import("../../../../lib/cli/commands/cache.js");
57+
const cliStub = {
58+
demandCommand: sinon.stub().returnsThis(),
59+
command: sinon.stub().returnsThis(),
60+
example: sinon.stub().returnsThis(),
61+
};
62+
const result = cacheModule.default.builder(cliStub);
63+
t.is(result, cliStub, "Builder returns cli instance");
64+
t.is(cliStub.demandCommand.callCount, 1, "demandCommand called once");
65+
t.is(cliStub.command.callCount, 1, "command called once");
66+
t.is(cliStub.example.callCount, 1, "example called once");
67+
});
68+
4169
test.serial("ui5 cache clean: nothing to clean", async (t) => {
42-
const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context;
70+
const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub} = t.context;
4371

44-
cleanCacheStub.resolves({entries: [], totalSize: 0, totalCount: 0});
72+
// Simulate no cache items
73+
getCacheInfoStub.resolves([]);
4574

4675
argv["_"] = ["cache", "clean"];
4776
await cache.handler(argv);
4877

4978
t.is(stderrWriteStub.firstCall.firstArg, "Nothing to clean\n");
79+
t.is(cleanCacheStub.callCount, 0, "cleanCache should not be called");
5080
});
5181

5282
test.serial("ui5 cache clean: removes entries and reports", async (t) => {
53-
const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context;
83+
const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub,
84+
mockRLInterface} = t.context;
85+
86+
// Simulate existing cache items
87+
getCacheInfoStub.resolves([
88+
{path: "framework/", size: 15 * 1024 * 1024, type: "directory"},
89+
{path: "buildCache/ (database records)", size: 8 * 1024 * 1024, type: "database"},
90+
]);
91+
92+
// Mock user confirmation
93+
mockRLInterface.question.callsFake((question, callback) => {
94+
callback("y");
95+
});
5496

5597
cleanCacheStub.resolves({
5698
entries: [
57-
{path: "@openui5/sap.ui.core/1.120.0", type: "framework", size: 15 * 1024 * 1024},
58-
{path: "@openui5/sap.m/1.120.0", type: "framework", size: 8 * 1024 * 1024},
99+
{path: "framework", type: "framework", size: 15 * 1024 * 1024},
100+
{path: "buildCache", type: "buildCache", size: 8 * 1024 * 1024},
59101
],
60102
totalSize: 23 * 1024 * 1024,
61103
totalCount: 2,
@@ -64,18 +106,156 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => {
64106
argv["_"] = ["cache", "clean"];
65107
await cache.handler(argv);
66108

67-
// Should have 4 writes: 2 entries + 1 newline + summary
68-
t.true(stderrWriteStub.callCount >= 3, "Multiple lines written to stderr");
69-
// Check that summary mentions entries count
109+
// Check that confirmation was asked
110+
t.is(mockRLInterface.question.callCount, 1, "Should ask for confirmation");
111+
t.true(mockRLInterface.question.firstCall.args[0].includes("continue"),
112+
"Confirmation question should ask to continue");
113+
114+
// Check that cleanCache was called
115+
t.is(cleanCacheStub.callCount, 1, "cleanCache should be called once");
116+
117+
// Check output
70118
const allOutput = stderrWriteStub.args.map((a) => a[0]).join("");
119+
t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed");
71120
t.true(allOutput.includes("2 entries"), "Summary mentions entry count");
72-
t.true(allOutput.includes("23.0 MB"), "Summary mentions freed size");
121+
t.true(allOutput.includes("Success"), "Shows success message");
73122
});
74123

75-
test("Command definition is correct", (t) => {
124+
test.serial("ui5 cache clean: user cancels", async (t) => {
125+
const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub,
126+
mockRLInterface} = t.context;
127+
128+
// Simulate existing cache items
129+
getCacheInfoStub.resolves([
130+
{path: "framework/", size: 5 * 1024 * 1024, type: "directory"}
131+
]);
132+
133+
// Mock user cancellation
134+
mockRLInterface.question.callsFake((question, callback) => {
135+
callback("n");
136+
});
137+
138+
argv["_"] = ["cache", "clean"];
139+
await cache.handler(argv);
140+
141+
// Check that confirmation was asked
142+
t.is(mockRLInterface.question.callCount, 1, "Should ask for confirmation");
143+
144+
// Check that cleanCache was NOT called
145+
t.is(cleanCacheStub.callCount, 0, "cleanCache should not be called when user cancels");
146+
147+
// Check output
148+
const allOutput = stderrWriteStub.args.map((a) => a[0]).join("");
149+
t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed");
150+
t.true(allOutput.includes("Cancelled"), "Shows cancelled message");
151+
t.false(allOutput.includes("Success"), "Should not show success message");
152+
});
153+
154+
test.serial("Command definition is correct", (t) => {
76155
// Import without esmock for structure check
77156
t.is(t.context.cache.command, "cache");
78157
t.is(t.context.cache.describe, "Manage UI5 CLI cache");
79158
t.is(typeof t.context.cache.builder, "function");
80159
t.is(typeof t.context.cache.handler, "function");
81160
});
161+
162+
test.serial("ui5 cache clean: accepts 'yes' as confirmation", async (t) => {
163+
const {cache, argv, cleanCacheStub, getCacheInfoStub, mockRLInterface} = t.context;
164+
165+
getCacheInfoStub.resolves([
166+
{path: "framework/", size: 1024, type: "directory"}
167+
]);
168+
169+
mockRLInterface.question.callsFake((question, callback) => {
170+
callback("yes");
171+
});
172+
173+
cleanCacheStub.resolves({
174+
entries: [{path: "framework", type: "framework", size: 1024}],
175+
totalSize: 1024,
176+
totalCount: 1,
177+
});
178+
179+
argv["_"] = ["cache", "clean"];
180+
await cache.handler(argv);
181+
182+
t.is(cleanCacheStub.callCount, 1, "cleanCache should be called with 'yes' confirmation");
183+
});
184+
185+
test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => {
186+
const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub, mockRLInterface} = t.context;
187+
188+
// Test with small bytes (B), KB, and GB sizes
189+
getCacheInfoStub.resolves([
190+
{path: "small", size: 512, type: "directory"}, // < 1024 = B
191+
{path: "medium", size: 50 * 1024, type: "directory"}, // KB
192+
{path: "large", size: 2 * 1024 * 1024 * 1024, type: "directory"}, // GB
193+
]);
194+
195+
mockRLInterface.question.callsFake((question, callback) => {
196+
callback("y");
197+
});
198+
199+
cleanCacheStub.resolves({
200+
entries: [
201+
{path: "small", type: "directory", size: 512},
202+
{path: "medium", type: "directory", size: 50 * 1024},
203+
{path: "large", type: "directory", size: 2 * 1024 * 1024 * 1024},
204+
],
205+
totalSize: 2 * 1024 * 1024 * 1024 + 50 * 1024 + 512,
206+
totalCount: 3,
207+
});
208+
209+
argv["_"] = ["cache", "clean"];
210+
await cache.handler(argv);
211+
212+
const allOutput = stderrWriteStub.args.map((a) => a[0]).join("");
213+
t.true(allOutput.includes("512 B"), "Shows bytes format");
214+
t.true(allOutput.includes("50.0 KB"), "Shows KB format");
215+
t.true(allOutput.includes("2.0 GB"), "Shows GB format");
216+
});
217+
218+
test.serial("ui5 cache clean: uses UI5_DATA_DIR from environment", async (t) => {
219+
const {cache, argv, getCacheInfoStub} = t.context;
220+
const originalEnv = process.env.UI5_DATA_DIR;
221+
222+
try {
223+
process.env.UI5_DATA_DIR = "/custom/ui5/path";
224+
225+
getCacheInfoStub.resolves([]);
226+
227+
argv["_"] = ["cache", "clean"];
228+
await cache.handler(argv);
229+
230+
t.is(getCacheInfoStub.callCount, 1, "getCacheInfo called");
231+
t.true(getCacheInfoStub.firstCall.args[0].ui5DataDir.includes("custom/ui5/path"),
232+
"Uses environment variable path");
233+
} finally {
234+
if (originalEnv) {
235+
process.env.UI5_DATA_DIR = originalEnv;
236+
} else {
237+
delete process.env.UI5_DATA_DIR;
238+
}
239+
}
240+
});
241+
242+
test.serial("ui5 cache clean: uses config.getUi5DataDir when no env var", async (t) => {
243+
const {cache, argv, getCacheInfoStub, Configuration} = t.context;
244+
const originalEnv = process.env.UI5_DATA_DIR;
245+
246+
try {
247+
delete process.env.UI5_DATA_DIR;
248+
249+
Configuration.fromFile.resolves(new Configuration({ui5DataDir: "/config/path"}));
250+
getCacheInfoStub.resolves([]);
251+
252+
argv["_"] = ["cache", "clean"];
253+
await cache.handler(argv);
254+
255+
t.is(getCacheInfoStub.callCount, 1, "getCacheInfo called");
256+
} finally {
257+
if (originalEnv) {
258+
process.env.UI5_DATA_DIR = originalEnv;
259+
}
260+
}
261+
});

packages/project/lib/build/cache/BuildCacheStorage.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,21 @@ export default class BuildCacheStorage {
576576
return bytesBefore - bytesAfter;
577577
}
578578

579+
/**
580+
* Checks if the database has any records in any table.
581+
*
582+
* @returns {boolean} True if there are any records
583+
*/
584+
hasRecords() {
585+
const tables = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"];
586+
for (const table of tables) {
587+
const count = this.#db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get()?.count ?? 0;
588+
if (count > 0) {
589+
return true;
590+
}
591+
}
592+
return false;
593+
}
579594
/**
580595
* Closes the database connection
581596
*/

0 commit comments

Comments
 (0)