feat(app): add maestro tests, download progress, and task hierarchy
Add Maestro E2E test flows for app launch and sidebar interactions. Implement download progress tracking with speed/ETA display in file explorer. Extend task CLI with parent-child hierarchy, body content from stdin, and ancestor context in task show. Fix explorer sidebar gesture to allow horizontal scrolling while preserving close swipe. Replace hardcoded monospace font with Fonts.mono constant. Fix RefreshControl to only show spinner on manual pull-to-refresh.
This commit is contained in:
@@ -23,6 +23,10 @@ function serializeTask(task: Task): string {
|
||||
`created: ${task.created}`,
|
||||
];
|
||||
|
||||
if (task.parentId) {
|
||||
frontmatterLines.push(`parentId: ${task.parentId}`);
|
||||
}
|
||||
|
||||
if (task.assignee) {
|
||||
frontmatterLines.push(`assignee: ${task.assignee}`);
|
||||
}
|
||||
@@ -31,19 +35,19 @@ function serializeTask(task: Task): string {
|
||||
|
||||
const frontmatter = frontmatterLines.join("\n");
|
||||
|
||||
let body = "";
|
||||
if (task.description) {
|
||||
body += task.description + "\n";
|
||||
let content = "";
|
||||
if (task.body) {
|
||||
content += task.body + "\n";
|
||||
}
|
||||
|
||||
if (task.notes.length > 0) {
|
||||
body += "\n## Notes\n";
|
||||
content += "\n## Notes\n";
|
||||
for (const note of task.notes) {
|
||||
body += `\n**${note.timestamp}**\n\n${note.content}\n`;
|
||||
content += `\n**${note.timestamp}**\n\n${note.content}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return frontmatter + "\n\n" + body;
|
||||
return frontmatter + "\n\n" + content;
|
||||
}
|
||||
|
||||
function parseTask(content: string): Task {
|
||||
@@ -53,7 +57,7 @@ function parseTask(content: string): Task {
|
||||
}
|
||||
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const body = content.slice(frontmatterMatch[0].length);
|
||||
const fileBody = content.slice(frontmatterMatch[0].length);
|
||||
|
||||
const getValue = (key: string): string => {
|
||||
const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m"));
|
||||
@@ -72,7 +76,7 @@ function parseTask(content: string): Task {
|
||||
|
||||
// Parse notes from body
|
||||
const notes: Task["notes"] = [];
|
||||
const notesSection = body.match(/## Notes\n([\s\S]*?)$/);
|
||||
const notesSection = fileBody.match(/## Notes\n([\s\S]*?)$/);
|
||||
if (notesSection) {
|
||||
const noteMatches = notesSection[1].matchAll(
|
||||
/\*\*(\d{4}-\d{2}-\d{2}T[\d:.Z]+)\*\*\n\n([\s\S]*?)(?=\n\*\*\d{4}|$)/g
|
||||
@@ -85,21 +89,23 @@ function parseTask(content: string): Task {
|
||||
}
|
||||
}
|
||||
|
||||
// Description is everything before ## Notes
|
||||
let description = body;
|
||||
// Body is everything before ## Notes (backwards compat: also check for old "description" field name)
|
||||
let taskBody = fileBody;
|
||||
if (notesSection) {
|
||||
description = body.slice(0, body.indexOf("## Notes")).trim();
|
||||
taskBody = fileBody.slice(0, fileBody.indexOf("## Notes")).trim();
|
||||
}
|
||||
description = description.trim();
|
||||
taskBody = taskBody.trim();
|
||||
|
||||
const assignee = getValue("assignee") as AgentType | "";
|
||||
const parentId = getValue("parentId");
|
||||
|
||||
return {
|
||||
id: getValue("id"),
|
||||
title: getValue("title"),
|
||||
status: getValue("status") as TaskStatus,
|
||||
deps,
|
||||
description,
|
||||
parentId: parentId || undefined,
|
||||
body: taskBody,
|
||||
notes,
|
||||
created: getValue("created") || new Date().toISOString(),
|
||||
assignee: assignee || undefined,
|
||||
@@ -148,7 +154,8 @@ export class FileTaskStore implements TaskStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
// Sort by created date (oldest first) for consistent ordering
|
||||
return tasks.sort((a, b) => a.created.localeCompare(b.created));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
@@ -192,6 +199,32 @@ export class FileTaskStore implements TaskStore {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getAncestors(id: string): Promise<Task[]> {
|
||||
const task = await this.get(id);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
|
||||
const ancestors: Task[] = [];
|
||||
let currentId = task.parentId;
|
||||
|
||||
while (currentId) {
|
||||
const parent = await this.get(currentId);
|
||||
if (!parent) break;
|
||||
ancestors.push(parent);
|
||||
currentId = parent.parentId;
|
||||
}
|
||||
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
async getChildren(id: string): Promise<Task[]> {
|
||||
const allTasks = await this.list();
|
||||
return allTasks
|
||||
.filter((t) => t.parentId === id)
|
||||
.sort((a, b) => a.created.localeCompare(b.created));
|
||||
}
|
||||
|
||||
async getReady(scopeId?: string): Promise<Task[]> {
|
||||
const allTasks = await this.list();
|
||||
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
||||
@@ -251,19 +284,28 @@ export class FileTaskStore implements TaskStore {
|
||||
candidates = await this.list();
|
||||
}
|
||||
|
||||
// Sort by created date (most recent first) for closed tasks
|
||||
// Sort by created date (oldest first) for consistent ordering
|
||||
return candidates
|
||||
.filter((t) => t.status === "done")
|
||||
.sort((a, b) => b.created.localeCompare(a.created));
|
||||
.sort((a, b) => a.created.localeCompare(b.created));
|
||||
}
|
||||
|
||||
async create(title: string, opts?: CreateTaskOptions): Promise<Task> {
|
||||
// Validate parent exists if provided
|
||||
if (opts?.parentId) {
|
||||
const parent = await this.get(opts.parentId);
|
||||
if (!parent) {
|
||||
throw new Error(`Parent task not found: ${opts.parentId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const task: Task = {
|
||||
id: generateId(),
|
||||
title,
|
||||
status: opts?.status ?? "open",
|
||||
deps: opts?.deps ?? [],
|
||||
description: opts?.description ?? "",
|
||||
parentId: opts?.parentId,
|
||||
body: opts?.body ?? "",
|
||||
notes: [],
|
||||
created: new Date().toISOString(),
|
||||
assignee: opts?.assignee,
|
||||
@@ -314,6 +356,45 @@ export class FileTaskStore implements TaskStore {
|
||||
await this.writeTask(task);
|
||||
}
|
||||
|
||||
async setParent(id: string, parentId: string | null): Promise<void> {
|
||||
const task = await this.get(id);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
const parent = await this.get(parentId);
|
||||
if (!parent) {
|
||||
throw new Error(`Parent task not found: ${parentId}`);
|
||||
}
|
||||
// Prevent circular reference
|
||||
if (parentId === id) {
|
||||
throw new Error("Task cannot be its own parent");
|
||||
}
|
||||
// Check that the new parent isn't a descendant of this task
|
||||
const ancestors = await this.getAncestorsFrom(parentId);
|
||||
if (ancestors.some((a) => a.id === id)) {
|
||||
throw new Error("Cannot set parent: would create circular reference");
|
||||
}
|
||||
}
|
||||
|
||||
await this.update(id, { parentId: parentId ?? undefined });
|
||||
}
|
||||
|
||||
private async getAncestorsFrom(id: string): Promise<Task[]> {
|
||||
const ancestors: Task[] = [];
|
||||
let currentId: string | undefined = id;
|
||||
|
||||
while (currentId) {
|
||||
const task = await this.get(currentId);
|
||||
if (!task) break;
|
||||
ancestors.push(task);
|
||||
currentId = task.parentId;
|
||||
}
|
||||
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
async addNote(id: string, content: string): Promise<void> {
|
||||
const task = await this.get(id);
|
||||
if (!task) {
|
||||
|
||||
Reference in New Issue
Block a user