Skip to content

Commit c1956b4

Browse files
adamintCopilot
andcommitted
Extract AppHost parser to shared module with 25 unit tests
- Extracted parseAppHostCode, makeId, makeResource, types to utils/appHostParser.ts - 25 vitest unit tests covering: simple resources, chained AddDatabase, WithReference/WaitFor across lines, attributes, secret params, full eShop-style AppHost, edge cases - Fixed statement splitter to handle lambdas with braces - PlaygroundPage now imports from shared module Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 764203b commit c1956b4

3 files changed

Lines changed: 476 additions & 216 deletions

File tree

AspireAcademy.Web/src/pages/PlaygroundPage.tsx

Lines changed: 3 additions & 216 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
2+
import { parseAppHostCode, makeId, makeResource, type PlaygroundResource, type ResourceType, type CodeLanguage } from '../utils/appHostParser';
23
import {
34
Box,
45
Flex,
@@ -51,48 +52,11 @@ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
5152

5253
// ─── Types ────────────────────────────────────────────────────────────────────
5354

54-
type CodeLanguage = 'csharp' | 'typescript';
55-
56-
type ResourceType =
57-
| 'postgres'
58-
| 'redis'
59-
| 'sqlserver'
60-
| 'mongodb'
61-
| 'rabbitmq'
62-
| 'kafka'
63-
| 'project'
64-
| 'container'
65-
| 'npmapp'
66-
| 'pythonapp'
67-
| 'azurestorage'
68-
| 'keyvault'
69-
| 'parameter';
70-
7155
interface EnvVar {
7256
key: string;
7357
value: string;
7458
}
7559

76-
interface PlaygroundResource {
77-
id: string;
78-
type: ResourceType;
79-
name: string;
80-
databases: string[];
81-
image: string;
82-
references: string[];
83-
waitFor: string[];
84-
hasDataVolume: boolean;
85-
hasExternalEndpoints: boolean;
86-
ports: string;
87-
envVars: EnvVar[];
88-
isPersistent: boolean;
89-
isSecret: boolean; // for parameter type
90-
args: string; // for containers
91-
scriptPath: string; // for python apps
92-
projectPath: string; // for npm / python relative path
93-
projectLanguage: CodeLanguage;
94-
}
95-
9660
interface ResourceTemplate {
9761
type: ResourceType;
9862
label: string;
@@ -157,29 +121,7 @@ interface Example {
157121
resources: PlaygroundResource[];
158122
}
159123

160-
function makeId(): string {
161-
return `r-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
162-
}
163-
164-
function makeResource(overrides: Partial<PlaygroundResource> & { id: string; type: ResourceType; name: string }): PlaygroundResource {
165-
return {
166-
databases: [],
167-
image: '',
168-
references: [],
169-
waitFor: [],
170-
hasDataVolume: false,
171-
hasExternalEndpoints: false,
172-
ports: '',
173-
envVars: [],
174-
isPersistent: false,
175-
isSecret: false,
176-
args: '',
177-
scriptPath: '',
178-
projectPath: '',
179-
projectLanguage: 'csharp',
180-
...overrides,
181-
};
182-
}
124+
// makeId, makeResource, parseAppHostCode imported from ../utils/appHostParser
183125

184126
function buildExamples(): Example[] {
185127
const ecomIds = { pg: makeId(), cache: makeId(), mq: makeId(), api: makeId(), web: makeId() };
@@ -672,162 +614,7 @@ function makeCSharpScaffold(name: string, refs: PlaygroundResource[]): ProjectSc
672614
];
673615
}
674616

675-
// ─── Import parser ───────────────────────────────────────────────────────────
676-
677-
const METHOD_TO_TYPE: Record<string, ResourceType> = {
678-
AddPostgres: 'postgres', AddRedis: 'redis', AddSqlServer: 'sqlserver',
679-
AddMongoDB: 'mongodb', AddRabbitMQ: 'rabbitmq', AddKafka: 'kafka',
680-
AddProject: 'project', AddCSharpApp: 'project',
681-
AddContainer: 'container',
682-
AddNpmApp: 'npmapp', AddViteApp: 'npmapp', AddJavaScriptApp: 'npmapp', AddNodeApp: 'npmapp',
683-
AddPythonApp: 'pythonapp', AddUvicornApp: 'pythonapp', AddUvApp: 'pythonapp',
684-
AddAzureStorage: 'azurestorage', AddAzureKeyVault: 'keyvault',
685-
AddParameter: 'parameter', AddConnectionString: 'parameter',
686-
AddYarp: 'container', AddElasticsearch: 'container',
687-
AddMySql: 'container', AddOracle: 'container', AddNats: 'container',
688-
};
689-
690-
function preprocessCode(code: string): string {
691-
return code
692-
.replace(/\/\/.*$/gm, '')
693-
.replace(/\/\*[\s\S]*?\*\//g, '')
694-
.replace(/#(?:if|else|endif|pragma|region|endregion).*$/gm, '')
695-
.replace(/\r\n/g, '\n');
696-
}
697-
698-
function splitStatements(code: string): string[] {
699-
const flat = code.replace(/\n/g, ' ').replace(/\s+/g, ' ');
700-
return flat.split(';').map(s => s.trim()).filter(Boolean);
701-
}
702-
703-
function parseAppHostCode(code: string): PlaygroundResource[] {
704-
const resources: PlaygroundResource[] = [];
705-
const varToId = new Map<string, string>();
706-
// Map varName to the statement it was defined in (for attribute scanning)
707-
const varToStmt = new Map<string, string>();
708-
709-
const cleaned = preprocessCode(code);
710-
const statements = splitStatements(cleaned);
711-
712-
for (const stmt of statements) {
713-
// Step 1: Extract builder.Add* resource declarations
714-
const addMatch = stmt.match(
715-
/(?:var|const|[A-Z]\w*)\s+(\w+)\s*=\s*builder\.(\w+)\s*(?:<[^>]+>)?\s*\(\s*"([^"]+)"(?:\s*,\s*(?:@?"([^"]*)"))?/
716-
);
717-
if (addMatch) {
718-
const [, varName, method, name, secondArg] = addMatch;
719-
const type = METHOD_TO_TYPE[method];
720-
if (type) {
721-
const id = makeId();
722-
varToId.set(varName, id);
723-
varToStmt.set(varName, stmt);
724-
725-
const r = makeResource({ id, type, name });
726-
if (type === 'container' && secondArg) r.image = secondArg;
727-
if ((type === 'npmapp' || type === 'pythonapp' || type === 'project') && secondArg) r.projectPath = secondArg;
728-
if (type === 'parameter' && /secret\s*:\s*true/i.test(stmt)) r.isSecret = true;
729-
resources.push(r);
730-
}
731-
}
732-
733-
// Step 2: Extract .AddDatabase() anywhere in the statement
734-
const dbMatches = stmt.matchAll(/\.AddDatabase\s*\(\s*"([^"]+)"\s*\)/g);
735-
for (const dbMatch of dbMatches) {
736-
const dbName = dbMatch[1];
737-
// The parent is the variable assigned in the same statement that contains
738-
// the builder.Add* call, OR the variable whose chain this belongs to.
739-
// First, try to find the builder.Add* parent in this statement.
740-
const parentAddMatch = stmt.match(
741-
/(?:var|const|[A-Z]\w*)\s+(\w+)\s*=\s*builder\.(\w+)/
742-
);
743-
if (parentAddMatch) {
744-
// The database is chained onto the resource defined in this statement
745-
const parentVar = parentAddMatch[1];
746-
const parentId = varToId.get(parentVar);
747-
if (parentId) {
748-
const parent = resources.find(r => r.id === parentId);
749-
if (parent && !parent.databases.includes(dbName)) parent.databases.push(dbName);
750-
}
751-
} else {
752-
// The database call is on a standalone variable: varName.AddDatabase(...)
753-
const standaloneMatch = stmt.match(/(\w+)\s*\.\s*AddDatabase/);
754-
if (standaloneMatch) {
755-
const parentId = varToId.get(standaloneMatch[1]);
756-
if (parentId) {
757-
const parent = resources.find(r => r.id === parentId);
758-
if (parent && !parent.databases.includes(dbName)) parent.databases.push(dbName);
759-
}
760-
}
761-
}
762-
}
763-
764-
// Step 3: Also handle `var dbVar = parentVar.AddDatabase("name")` as a variable assignment
765-
const dbAssignMatch = stmt.match(
766-
/(?:var|const|[A-Z]\w*)\s+(\w+)\s*=\s*(\w+)(?:\.\w+\([^)]*\))*\.AddDatabase\s*\(\s*"([^"]+)"\s*\)/
767-
);
768-
if (dbAssignMatch) {
769-
const [, dbVarName, , dbName] = dbAssignMatch;
770-
// Find the database resource by name and map the variable to it
771-
// The database itself isn't a separate PlaygroundResource — it's stored on the parent.
772-
// But the variable might be referenced via .WithReference(dbVar), so we need to track it.
773-
// Find which parent has this database
774-
const parentForDb = resources.find(r => r.databases.includes(dbName));
775-
if (parentForDb) {
776-
varToId.set(dbVarName, parentForDb.id);
777-
varToStmt.set(dbVarName, stmt);
778-
}
779-
}
780-
781-
// Step 4: Extract .WithReference() and .WaitFor() — find the consumer variable
782-
const refMatches = [...stmt.matchAll(/\.(WithReference|WaitFor)\s*\(\s*(\w+)\s*\)/g)];
783-
if (refMatches.length > 0) {
784-
// Find the consumer: the variable being assigned in this statement
785-
const consumerMatch = stmt.match(/(?:var|const|[A-Z]\w*)\s+(\w+)\s*=/);
786-
const consumerVar = consumerMatch?.[1];
787-
const consumerId = consumerVar ? varToId.get(consumerVar) : undefined;
788-
const consumer = consumerId ? resources.find(r => r.id === consumerId) : undefined;
789-
790-
if (!consumer) {
791-
// Try standalone: varName.WithReference(...)
792-
const standaloneConsumer = stmt.match(/^(\w+)\s*\./);
793-
if (standaloneConsumer) {
794-
const scId = varToId.get(standaloneConsumer[1]);
795-
const sc = scId ? resources.find(r => r.id === scId) : undefined;
796-
if (sc) {
797-
for (const rm of refMatches) {
798-
const [, method, depVar] = rm;
799-
const depId = varToId.get(depVar);
800-
if (!depId) continue;
801-
if (method === 'WithReference' && !sc.references.includes(depId)) sc.references.push(depId);
802-
if (method === 'WaitFor' && !sc.waitFor.includes(depId)) sc.waitFor.push(depId);
803-
}
804-
}
805-
}
806-
} else {
807-
for (const rm of refMatches) {
808-
const [, method, depVar] = rm;
809-
const depId = varToId.get(depVar);
810-
if (!depId) continue;
811-
if (method === 'WithReference' && !consumer.references.includes(depId)) consumer.references.push(depId);
812-
if (method === 'WaitFor' && !consumer.waitFor.includes(depId)) consumer.waitFor.push(depId);
813-
}
814-
}
815-
}
816-
}
817-
818-
// Step 5: Scan statements for attribute methods (.WithDataVolume, .WithLifetime, etc.)
819-
for (const r of resources) {
820-
const varName = [...varToId.entries()].find(([, id]) => id === r.id)?.[0];
821-
if (!varName) continue;
822-
const stmt = varToStmt.get(varName) ?? '';
823-
if (/\.WithDataVolume\b/.test(stmt)) r.hasDataVolume = true;
824-
if (/\.WithLifetime\s*\(\s*ContainerLifetime\.Persistent\s*\)/.test(stmt)) r.isPersistent = true;
825-
if (/\.WithExternalHttpEndpoints\b/.test(stmt)) r.hasExternalEndpoints = true;
826-
if (/\.WithHttpEndpoint\b/.test(stmt)) r.hasExternalEndpoints = true;
827-
}
828-
829-
return resources;
830-
}
617+
// Parser (parseAppHostCode, makeId, makeResource) imported from ../utils/appHostParser
831618

832619
// ─── localStorage persistence ────────────────────────────────────────────────
833620

0 commit comments

Comments
 (0)