From e97cdf7fcf200dcf72b069f6a743780c599b5120 Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Mon, 5 Jan 2026 10:42:45 -0800 Subject: [PATCH 1/9] Scripts to seed and deseed db --- e2e-tests/data/external-dataset.json | 5 +- e2e-tests/utilities/api.ts | 719 +++++++++++++++++-- e2e-tests/utilities/deseed.test.ts | 502 ++++++++++++++ e2e-tests/utilities/seed.test.ts | 999 +++++++++++++++++++++++++++ package.json | 2 + playwright.config.ts | 17 +- src/components/timeline/Row.svelte | 3 +- src/schemas/index.ts | 8 +- src/stores/external-source.ts | 1 + src/types/simulation.ts | 26 +- src/utilities/resources.ts | 64 +- 11 files changed, 2267 insertions(+), 79 deletions(-) create mode 100644 e2e-tests/utilities/deseed.test.ts create mode 100644 e2e-tests/utilities/seed.test.ts diff --git a/e2e-tests/data/external-dataset.json b/e2e-tests/data/external-dataset.json index b0b1bf49e8..2a4003cbb2 100644 --- a/e2e-tests/data/external-dataset.json +++ b/e2e-tests/data/external-dataset.json @@ -37,6 +37,9 @@ "rate": -0.5 } }, + { + "duration": 40000000 + }, { "duration": 30000000, "dynamics": { @@ -48,4 +51,4 @@ "type": "real" } } -} \ No newline at end of file +} diff --git a/e2e-tests/utilities/api.ts b/e2e-tests/utilities/api.ts index 3658093bff..8fc9f2d94f 100644 --- a/e2e-tests/utilities/api.ts +++ b/e2e-tests/utilities/api.ts @@ -13,13 +13,27 @@ import nodePath from 'path'; import { adjectives, animals, colors, uniqueNamesGenerator } from 'unique-names-generator'; import url from 'url'; import { STORAGE_STATE, USER_STORAGE_STATES } from '../../playwright.config.js'; -import { SchedulingDefinitionType } from '../../src/enums/scheduling.js'; +import type { ActionDefinition } from '../../src/types/actions.js'; import { ActivityDirectiveInsertInput } from '../../src/types/activity.js'; +import { BaseUser } from '../../src/types/app.js'; import type { ReqAuthResponse } from '../../src/types/auth'; -import { ConstraintDefinitionInsertInput } from '../../src/types/constraint.js'; -import { ModelInsertInput } from '../../src/types/model.js'; -import { PlanInsertInput } from '../../src/types/plan.js'; -import { SchedulingGoalDefinitionInsertInput, SchedulingGoalInsertInput } from '../../src/types/scheduling.js'; +import type { ConstraintInsertInput } from '../../src/types/constraint.js'; +import { ExpansionRuleInsertInput, ExpansionRuleSlim, ExpansionSet } from '../../src/types/expansion.js'; +import { DerivationGroupInsertInput } from '../../src/types/external-source.js'; +import { ModelInsertInput, ModelSetInput } from '../../src/types/model.js'; +import { PlanInsertInput, PlanSchema, PlanSlim } from '../../src/types/plan.js'; +import { SchedulingConditionInsertInput, SchedulingGoalInsertInput } from '../../src/types/scheduling.js'; +import type { + ChannelDictionaryMetadata, + CommandDictionaryMetadata, + ParameterDictionaryMetadata, + Parcel, + ParcelInsertInput, + SequenceAdaptationMetadata, +} from '../../src/types/sequencing.js'; +import { ExternalDatasetInput, ResourceType } from '../../src/types/simulation.js'; +import { ViewInsertInput } from '../../src/types/view.js'; +import type { Workspace } from '../../src/types/workspace.js'; import { convertToQuery } from '../../src/utilities/generic.js'; import gql from '../../src/utilities/gql.js'; import { getIntervalFromDoyRange } from '../../src/utilities/time.js'; @@ -31,14 +45,28 @@ import { SchedulingConditions } from '../fixtures/SchedulingConditions.js'; import { SchedulingGoals } from '../fixtures/SchedulingGoals.js'; import { View } from '../fixtures/View.js'; +// Load .env file if it exists (Node.js doesn't load it automatically) +const envPath = nodePath.resolve(process.cwd(), '.env'); +if (fs.existsSync(envPath)) { + for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.slice(0, eqIndex); + const value = trimmed.slice(eqIndex + 1).replace(/^['"]|['"]$/g, ''); + if (!process.env[key]) { + process.env[key] = value; + } + } + } + } +} + // Default URLs from environment variables, with fallbacks for local development const DEFAULT_HASURA_URL = process.env.PUBLIC_HASURA_CLIENT_URL ?? 'http://localhost:8080/v1/graphql'; const DEFAULT_GATEWAY_URL = process.env.PUBLIC_GATEWAY_CLIENT_URL ?? 'http://localhost:9000'; - -export interface ApiUser { - id: string; - token: string; -} +const DEFAULT_WORKSPACE_URL = process.env.PUBLIC_WORKSPACE_CLIENT_URL ?? 'http://localhost:9200'; /** * Shared test data written during global setup and read by tests. @@ -54,34 +82,223 @@ export interface SharedTestData { export class AerieApi { private gatewayUrl: string; private hasuraUrl: string; - private user: ApiUser | null = null; - - constructor(hasuraUrl: string = DEFAULT_HASURA_URL, gatewayUrl: string = DEFAULT_GATEWAY_URL) { + private user: BaseUser | null = null; + private workspaceUrl: string; + + constructor( + hasuraUrl: string = DEFAULT_HASURA_URL, + gatewayUrl: string = DEFAULT_GATEWAY_URL, + workspaceUrl: string = DEFAULT_WORKSPACE_URL, + ) { this.hasuraUrl = hasuraUrl; this.gatewayUrl = gatewayUrl; + this.workspaceUrl = workspaceUrl; + } + + async addConstraintModelSpecifications( + modelId: number, + constraintSpecs: Array<{ constraintId: number; constraintRevision?: number | null }>, + ): Promise { + const constraintSpecsToAdd = constraintSpecs.map((spec, index) => ({ + arguments: {}, + constraint_id: spec.constraintId, + constraint_revision: spec.constraintRevision ?? null, + model_id: modelId, + order: index, + })); + await this.gqlQuery(gql.UPDATE_CONSTRAINT_MODEL_SPECIFICATIONS, { + constraintInvocationIdsToDelete: [], + constraintSpecsToAdd, + }); + } + + async addSchedulingConditionModelSpecifications( + modelId: number, + conditionSpecs: Array<{ conditionId: number; conditionRevision?: number | null }>, + ): Promise { + const conditionSpecsToUpdate = conditionSpecs.map(spec => ({ + condition_id: spec.conditionId, + condition_revision: spec.conditionRevision ?? null, + model_id: modelId, + })); + await this.gqlQuery(gql.UPDATE_SCHEDULING_CONDITION_MODEL_SPECIFICATIONS, { + conditionIdsToDelete: [], + conditionSpecsToUpdate, + modelId, + }); + } + + async addSchedulingGoalModelSpecifications( + modelId: number, + goalSpecs: Array<{ goalId: number; goalRevision?: number | null; priority?: number }>, + ): Promise { + const goalSpecsToAdd = goalSpecs.map((spec, index) => ({ + goal_id: spec.goalId, + goal_revision: spec.goalRevision ?? null, + model_id: modelId, + priority: spec.priority ?? index, + })); + await this.gqlQuery(gql.UPDATE_SCHEDULING_GOAL_MODEL_SPECIFICATIONS, { + goalIdsToDelete: [], + goalSpecsToAdd, + }); + } + + async createActionDefinition( + workspaceId: number, + name: string, + description: string, + actionFilePath: string, + ): Promise<{ id: number }> { + // Upload the action file first + const actionFileId = await this.uploadFile(actionFilePath); + + // Create the action definition + const data = await this.gqlQuery<{ insert_action_definition_one: { id: number } }>(gql.CREATE_ACTION_DEFINITION, { + actionDefinitionInsertInput: { + action_file_id: actionFileId, + description, + name, + workspace_id: workspaceId, + }, + }); + return { id: data.insert_action_definition_one.id }; } async createActivityDirective(activityDirective: ActivityDirectiveInsertInput): Promise<{ id: number }> { - const data = await this.gqlQuery<{ createActivityDirective: { id: number } }>(gql.CREATE_ACTIVITY_DIRECTIVE, { + const data = await this.gqlQuery<{ insert_activity_directive_one: { id: number } }>(gql.CREATE_ACTIVITY_DIRECTIVE, { activityDirectiveInsertInput: activityDirective, }); - return { id: data.createActivityDirective.id }; + return { id: data.insert_activity_directive_one.id }; + } + + async createActivityDirectives( + activityDirectives: ActivityDirectiveInsertInput[], + ): Promise> { + const data = await this.gqlQuery<{ + insert_activity_directive: { returning: Array<{ id: number; type: string }> }; + }>(gql.CREATE_ACTIVITY_DIRECTIVES, { + activityDirectivesInsertInput: activityDirectives, + }); + return data.insert_activity_directive.returning; + } + + async createConstraint(constraint: ConstraintInsertInput): Promise<{ id: number }> { + // Create metadata with nested versions using CREATE_CONSTRAINT + const data = await this.gqlQuery<{ constraint: { id: number } }>(gql.CREATE_CONSTRAINT, { constraint }); + return { id: data.constraint.id }; + } + + async createDerivationGroup(derivationGroup: DerivationGroupInsertInput): Promise<{ name: string }> { + const data = await this.gqlQuery<{ createDerivationGroup: { name: string } }>(gql.CREATE_DERIVATION_GROUP, { + derivationGroup, + }); + return data.createDerivationGroup; + } + + async createDictionary( + dictionaryXml: string, + persistDictionaryToFilesystem: boolean = false, + ): Promise<{ + channel?: ChannelDictionaryMetadata; + command?: CommandDictionaryMetadata; + parameter?: ParameterDictionaryMetadata; + }> { + const data = await this.gqlQuery<{ + createDictionary: { + channel?: ChannelDictionaryMetadata; + command?: CommandDictionaryMetadata; + parameter?: ParameterDictionaryMetadata; + }; + }>(gql.CREATE_DICTIONARY, { dictionary: dictionaryXml, persistDictionaryToFilesystem }); + return data.createDictionary; } - async createConstraint(constraint: ConstraintDefinitionInsertInput): Promise<{ id: number }> { - // Create metadata first using CREATE_CONSTRAINT - const metadatadata = await this.gqlQuery<{ constraint: { id: number } }>(gql.CREATE_CONSTRAINT, { constraint }); - const constraintId = metadatadata.constraint.id; + async createExpansionRule(rule: ExpansionRuleInsertInput): Promise<{ id: number }> { + const data = await this.gqlQuery<{ createExpansionRule: { id: number } }>(gql.CREATE_EXPANSION_RULE, { rule }); + return data.createExpansionRule; + } + + async createExpansionSet( + parcelId: number, + modelId: number, + expansionRuleIds: number[], + name?: string, + description?: string, + ): Promise<{ id: number }> { + const data = await this.gqlQuery<{ createExpansionSet: { id: number } }>(gql.CREATE_EXPANSION_SET, { + description, + expansionRuleIds, + modelId, + name, + parcelId, + }); + return data.createExpansionSet; + } - // Then create definition - await this.gqlQuery(gql.CREATE_CONSTRAINT_DEFINITION, { - constraintDefinition: { - constraint_id: constraintId, - definition: constraint.definition, + async createExtension( + label: string, + url: string, + description: string = '', + roles: string[] = ['aerie_admin'], + ): Promise<{ id: number }> { + const data = await this.gqlQuery<{ + insert_extensions: { returning: Array<{ id: number }> }; + }>( + `mutation InsertExtension($label: String!, $description: String!, $url: String!, $roles: [extension_roles_insert_input!]!) { + insert_extensions(objects: { + label: $label, + description: $description, + url: $url, + extension_roles: { data: $roles } + }) { + returning { id } + } + }`, + { + description, + label, + roles: roles.map(role => ({ role })), + url, }, + ); + return { id: data.insert_extensions.returning[0].id }; + } + + async createExternalDataset(planId: number, dataset: ExternalDatasetInput): Promise { + // Create a JSON file from the dataset and upload via gateway + const datasetJson = JSON.stringify(dataset); + const blob = new Blob([datasetJson], { type: 'application/json' }); + + const formData = new FormData(); + formData.append('plan_id', `${planId}`); + formData.append('external_dataset', blob, 'external-dataset.json'); + + const response = await fetch(`${this.gatewayUrl}/uploadDataset`, { + body: formData, + headers: { Authorization: `Bearer ${this.user?.token}` }, + method: 'POST', }); - return { id: constraintId }; + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to upload external dataset: ${response.status} ${errorText}`); + } + + const datasetId = await response.json(); + return datasetId as number; + } + + async createExternalSourceEventTypes( + sourceTypes: Record, + eventTypes: Record, + ): Promise { + // Each type entry should be the JSON Schema directly (with type, properties, etc.) + const body = JSON.stringify({ + event_types: JSON.stringify(eventTypes), + source_types: JSON.stringify(sourceTypes), + }); + await this.gatewayRequest('/uploadExternalSourceEventTypes', 'POST', body); } async createModel(model: ModelInsertInput): Promise<{ id: number }> { @@ -89,29 +306,43 @@ export class AerieApi { return data.createModel; } + async createParcel(parcel: ParcelInsertInput): Promise<{ id: number }> { + const data = await this.gqlQuery<{ createParcel: { id: number } }>(gql.CREATE_PARCEL, { parcel }); + return data.createParcel; + } + async createPlan(plan: PlanInsertInput): Promise<{ id: number }> { const data = await this.gqlQuery<{ createPlan: { id: number } }>(gql.CREATE_PLAN, { plan }); return data.createPlan; } - async createSchedulingGoal(goal: SchedulingGoalInsertInput): Promise<{ id: number }> { - const metadatadata = await this.gqlQuery<{ createSchedulingGoal: { id: number } }>(gql.CREATE_SCHEDULING_GOAL, { - description: goal.description ?? '', - name: goal.name, - public: goal.public, + async createPlanDerivationGroup(planId: number, derivationGroupName: string): Promise { + await this.gqlQuery(gql.CREATE_PLAN_DERIVATION_GROUP, { + source: { + derivation_group_name: derivationGroupName, + plan_id: planId, + }, }); - const goalId = metadatadata.createSchedulingGoal.id; - - const goalDefinitionInsertInput: SchedulingGoalDefinitionInsertInput = { - ...goal, - definition: null, - goal_id: goalId, - type: SchedulingDefinitionType.EDSL, - uploaded_jar_id: null, - }; - await this.gqlQuery(gql.CREATE_SCHEDULING_GOAL_DEFINITION, { goalDefinition: goalDefinitionInsertInput }); + } - return { id: goalId }; + async createSchedulingCondition(condition: SchedulingConditionInsertInput): Promise<{ id: number }> { + const data = await this.gqlQuery<{ createSchedulingCondition: { id: number } }>(gql.CREATE_SCHEDULING_CONDITION, { + condition, + }); + return data.createSchedulingCondition; + } + + async createSchedulingGoal(goal: SchedulingGoalInsertInput): Promise<{ id: number }> { + // Use nested versions like effects.ts createSchedulingGoal + const data = await this.gqlQuery<{ createSchedulingGoal: { id: number } }>(gql.CREATE_SCHEDULING_GOAL, { goal }); + return data.createSchedulingGoal; + } + + async createSequenceAdaptation(adaptation: { adaptation: string; name: string }): Promise<{ name: string }> { + const data = await this.gqlQuery<{ createSequenceAdaptation: { name: string } }>(gql.CREATE_SEQUENCE_ADAPTATION, { + adaptation, + }); + return data.createSequenceAdaptation; } async createTag(name: string, color: string = '#000000'): Promise<{ id: number }> { @@ -122,6 +353,102 @@ export class AerieApi { return data.insert_tags_one; } + async createView(view: ViewInsertInput): Promise<{ id: number }> { + const data = await this.gqlQuery<{ newView: { id: number } }>(gql.CREATE_VIEW, { view }); + return data.newView; + } + + async createWorkspace(location: string, parcelId: number, name?: string): Promise { + if (!this.user) { + throw new Error('Not logged in. Call login() first.'); + } + + const body = JSON.stringify({ + parcelId, + workspaceLocation: location, + ...(name ? { workspaceName: name } : {}), + }); + + const response = await fetch(`${this.workspaceUrl}/ws/create`, { + body, + headers: { + Authorization: `Bearer ${this.user.token}`, + 'Content-Type': 'application/json', + 'x-hasura-role': 'aerie_admin', + 'x-hasura-user-id': this.user.id as string, + }, + method: 'POST', + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Workspace creation failed: ${response.statusText} - ${errorText}`); + } + + return response.json(); + } + + /** + * Create a file or folder in a workspace. + * @param workspaceId - The workspace ID + * @param path - The path to create (e.g., 'folder/file.txt') + * @param content - File content (string or Uint8Array), or undefined for folders + */ + async createWorkspaceItem(workspaceId: number, path: string, content?: string | Uint8Array): Promise { + if (!this.user) { + throw new Error('Not logged in. Call login() first.'); + } + + const isFolder = content === undefined; + const type = isFolder ? 'directory' : 'file'; + + let body: FormData | undefined; + if (!isFolder) { + const pathParts = path.split('/'); + const fileName = pathParts[pathParts.length - 1]; + + let blob: Blob; + if (typeof content === 'string') { + blob = new Blob([content]); + } else { + // Convert Uint8Array to ArrayBuffer for Blob compatibility + const arrayBuffer = content.buffer.slice( + content.byteOffset, + content.byteOffset + content.byteLength, + ) as ArrayBuffer; + blob = new Blob([arrayBuffer]); + } + + body = new FormData(); + body.append('file', blob, fileName); + } + + const response = await fetch(`${this.workspaceUrl}/ws/${workspaceId}/${path}?type=${type}`, { + body, + headers: { + Authorization: `Bearer ${this.user.token}`, + 'x-hasura-role': 'aerie_admin', + 'x-hasura-user-id': this.user.id as string, + }, + method: 'PUT', + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Workspace ${type} creation failed: ${response.statusText} - ${errorText}`); + } + } + + async deleteActionDefinition(id: number): Promise { + // No built-in DELETE_ACTION_DEFINITION in gql.ts, using raw mutation + await this.gqlQuery( + `mutation DeleteActionDefinition($id: Int!) { + delete_action_definition_by_pk(id: $id) { id } + }`, + { id }, + ); + } + async deleteActivityDirectives(planId: number, activityIds: number[]): Promise { await this.gqlQuery(gql.DELETE_ACTIVITY_DIRECTIVES, { activity_ids: activityIds, @@ -129,6 +456,14 @@ export class AerieApi { }); } + async deleteChannelDictionary(id: number): Promise { + await this.gqlQuery(gql.DELETE_CHANNEL_DICTIONARY, { id }); + } + + async deleteCommandDictionary(id: number): Promise { + await this.gqlQuery(gql.DELETE_COMMAND_DICTIONARY, { id }); + } + async deleteConstraint(id: number): Promise { await this.gqlQuery(gql.DELETE_CONSTRAINT_METADATA, { id }); } @@ -137,10 +472,29 @@ export class AerieApi { await this.gqlQuery(gql.DELETE_DERIVATION_GROUPS, { derivationGroupNames }); } + async deleteExpansionRule(id: number): Promise { + await this.gqlQuery(gql.DELETE_EXPANSION_RULE, { id }); + } + async deleteExpansionSequence(seqId: string, simulationDatasetId: number): Promise { await this.gqlQuery(gql.DELETE_EXPANSION_SEQUENCE, { seqId, simulationDatasetId }); } + async deleteExpansionSet(id: number): Promise { + await this.gqlQuery(gql.DELETE_EXPANSION_SET, { id }); + } + + async deleteExtension(id: number): Promise { + await this.gqlQuery( + `mutation DeleteExtension($id: Int!) { + delete_extensions(where: { id: { _eq: $id } }) { + returning { id } + } + }`, + { id }, + ); + } + async deleteExternalEventTypes(names: string[]): Promise { await this.gqlQuery(gql.DELETE_EXTERNAL_EVENT_TYPE, { names }); } @@ -157,14 +511,30 @@ export class AerieApi { await this.gqlQuery(gql.DELETE_MODEL, { id }); } + async deleteParameterDictionary(id: number): Promise { + await this.gqlQuery(gql.DELETE_PARAMETER_DICTIONARY, { id }); + } + + async deleteParcel(id: number): Promise { + await this.gqlQuery(gql.DELETE_PARCEL, { id }); + } + async deletePlan(id: number): Promise { await this.gqlQuery(gql.DELETE_PLAN, { id }); } + async deleteSchedulingCondition(id: number): Promise { + await this.gqlQuery(gql.DELETE_SCHEDULING_CONDITION_METADATA, { id }); + } + async deleteSchedulingGoal(id: number): Promise { await this.gqlQuery(gql.DELETE_SCHEDULING_GOAL_METADATA, { id }); } + async deleteSequenceAdaptation(id: number): Promise { + await this.gqlQuery(gql.DELETE_SEQUENCE_ADAPTATION, { id }); + } + async deleteSequenceTemplate(sequenceTemplateId: number): Promise { await this.gqlQuery(gql.DELETE_SEQUENCE_TEMPLATE, { sequenceTemplateId }); } @@ -173,11 +543,198 @@ export class AerieApi { await this.gqlQuery(gql.DELETE_TAG, { id }); } - async getPlan(id: number): Promise { - const data = await this.gqlQuery<{ plan: unknown }>(gql.GET_PLAN, { id }); + async deleteView(id: number): Promise { + await this.gqlQuery(gql.DELETE_VIEW, { id }); + } + + async deleteWorkspace(id: number): Promise { + if (!this.user) { + throw new Error('Not logged in. Call login() first.'); + } + + const response = await fetch(`${this.workspaceUrl}/ws/${id}`, { + headers: { + Authorization: `Bearer ${this.user.token}`, + 'x-hasura-role': 'aerie_admin', + 'x-hasura-user-id': this.user.id as string, + }, + method: 'DELETE', + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Workspace deletion failed: ${response.statusText} - ${errorText}`); + } + } + + /** + * Execute a request against the Gateway API. + */ + private async gatewayRequest( + endpoint: string, + method: 'GET' | 'POST' | 'DELETE' = 'GET', + body?: FormData | string, + ): Promise { + if (!this.user) { + throw new Error('Not logged in. Call login() first.'); + } + + const headers: Record = { + Authorization: `Bearer ${this.user.token}`, + 'x-hasura-role': 'aerie_admin', + 'x-hasura-user-id': this.user.id as string, + }; + + // Don't set Content-Type for FormData - browser will set it with boundary + if (body && typeof body === 'string') { + headers['Content-Type'] = 'application/json'; + } + + const response = await fetch(`${this.gatewayUrl}${endpoint}`, { + body, + headers, + method, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Gateway request failed: ${response.statusText} - ${errorText}`); + } + + return response.json(); + } + + async getActionDefinitions(): Promise { + const data = await this.gqlQuery<{ action_definition: ActionDefinition[] }>( + convertToQuery(gql.SUB_ACTION_DEFINITIONS), + ); + return data.action_definition; + } + + async getChannelDictionaries(): Promise { + const data = await this.gqlQuery<{ + channel_dictionary: ChannelDictionaryMetadata[]; + }>(convertToQuery(gql.SUB_CHANNEL_DICTIONARIES)); + return data.channel_dictionary; + } + + async getCommandDictionaries(): Promise { + const data = await this.gqlQuery<{ + // TODO the actual usage of SUB_COMMAND_DICTIONARIES maps to CommandDictionaryMetadata[] but the subscription does NOT return full CommandDictionaryMetadata objects! + // This is the same for channel dictionaries + command_dictionary: CommandDictionaryMetadata[]; + }>(convertToQuery(gql.SUB_COMMAND_DICTIONARIES)); + return data.command_dictionary; + } + + async getConstraints(): Promise> { + const data = await this.gqlQuery<{ constraints: Array<{ id: number; name: string }> }>( + convertToQuery(gql.SUB_CONSTRAINTS), + ); + return data.constraints; + } + + async getDerivationGroups(): Promise> { + const data = await this.gqlQuery<{ + derivationGroups: Array<{ name: string; source_type_name: string }>; + }>(convertToQuery(gql.SUB_DERIVATION_GROUPS)); + return data.derivationGroups; + } + + async getExpansionRules(): Promise { + const data = await this.gqlQuery<{ expansionRules: ExpansionRuleSlim[] }>(convertToQuery(gql.SUB_EXPANSION_RULES)); + return data.expansionRules; + } + + async getExpansionSets(): Promise { + const data = await this.gqlQuery<{ expansionSets: ExpansionSet[] }>(convertToQuery(gql.SUB_EXPANSION_SETS)); + return data.expansionSets; + } + + async getExtensions(): Promise> { + const data = await this.gqlQuery<{ + extensions: Array<{ description: string; id: number; label: string; url: string }>; + }>(convertToQuery(gql.SUB_EXTENSIONS)); + return data.extensions; + } + + async getExternalEventTypes(): Promise> { + const data = await this.gqlQuery<{ models: Array<{ attribute_schema: object; name: string }> }>( + convertToQuery(gql.SUB_EXTERNAL_EVENT_TYPES), + ); + return data.models; + } + + async getExternalSourceTypes(): Promise> { + const data = await this.gqlQuery<{ models: Array<{ name: string }> }>( + convertToQuery(gql.SUB_EXTERNAL_SOURCE_TYPES), + ); + return data.models; + } + + async getExternalSources(): Promise> { + const data = await this.gqlQuery<{ models: Array<{ derivation_group_name: string; key: string }> }>( + convertToQuery(gql.SUB_EXTERNAL_SOURCES), + ); + return data.models; + } + + async getModels(): Promise> { + const data = await this.gqlQuery<{ models: Array<{ id: number; name: string }> }>(gql.GET_MODELS); + return data.models; + } + + async getParameterDictionaries(): Promise { + const data = await this.gqlQuery<{ + parameter_dictionary: ParameterDictionaryMetadata[]; + }>(convertToQuery(gql.SUB_PARAMETER_DICTIONARIES)); + return data.parameter_dictionary; + } + + async getParcels(): Promise { + const data = await this.gqlQuery<{ parcel: Parcel[] }>(convertToQuery(gql.SUB_PARCELS)); + return data.parcel; + } + + async getPlan(id: number): Promise { + const data = await this.gqlQuery<{ plan: PlanSchema }>(gql.GET_PLAN, { id }); return data.plan; } + async getPlans(): Promise { + const data = await this.gqlQuery<{ plans: PlanSlim[] }>(convertToQuery(gql.SUB_PLANS)); + return data.plans; + } + + async getResourceTypes(modelId: number): Promise { + const data = await this.gqlQuery<{ resource_types: ResourceType[] }>(gql.GET_RESOURCE_TYPES, { + model_id: modelId, + }); + const { resource_types: resourceTypes } = data; + return resourceTypes; + } + + async getSchedulingConditions(): Promise> { + const data = await this.gqlQuery<{ conditions: Array<{ id: number; name: string }> }>( + convertToQuery(gql.SUB_SCHEDULING_CONDITIONS), + ); + return data.conditions; + } + + async getSchedulingGoals(): Promise> { + const data = await this.gqlQuery<{ goals: Array<{ id: number; name: string }> }>( + convertToQuery(gql.SUB_SCHEDULING_GOALS), + ); + return data.goals; + } + + async getSequenceAdaptations(): Promise { + const data = await this.gqlQuery<{ + sequence_adaptation: SequenceAdaptationMetadata[]; + }>(convertToQuery(gql.SUB_SEQUENCE_ADAPTATIONS)); + return data.sequence_adaptation; + } + async getSimulationDataset(id: number): Promise<{ reason: string | null; status: string }> { const data = await this.gqlQuery<{ simulation_dataset_by_pk: { reason: string | null; status: string }; @@ -185,7 +742,12 @@ export class AerieApi { return data.simulation_dataset_by_pk; } - getUser(): ApiUser | null { + async getTags(): Promise> { + const data = await this.gqlQuery<{ tags: Array<{ id: number; name: string }> }>(convertToQuery(gql.SUB_TAGS)); + return data.tags; + } + + getUser(): BaseUser | null { return this.user; } @@ -194,6 +756,16 @@ export class AerieApi { return data.users; } + async getViews(): Promise> { + const data = await this.gqlQuery<{ views: Array<{ id: number; name: string }> }>(convertToQuery(gql.SUB_VIEWS)); + return data.views; + } + + async getWorkspaces(): Promise { + const data = await this.gqlQuery<{ workspace: Workspace[] }>(convertToQuery(gql.SUB_WORKSPACES)); + return data.workspace; + } + /** * Execute a GraphQL query/mutation against Hasura. */ @@ -212,7 +784,7 @@ export class AerieApi { Authorization: `Bearer ${this.user.token}`, 'Content-Type': 'application/json', 'x-hasura-role': role, - 'x-hasura-user-id': this.user.id, + 'x-hasura-user-id': this.user.id as string, }, method: 'POST', }); @@ -233,7 +805,7 @@ export class AerieApi { /** * Login via Gateway and store the token for subsequent requests. */ - async login(username: string, password: string): Promise { + async login(username: string, password: string): Promise { const response = await fetch(`${this.gatewayUrl}/auth/login`, { body: JSON.stringify({ password, username }), headers: { 'Content-Type': 'application/json' }, @@ -257,7 +829,7 @@ export class AerieApi { /** * Set the user/token directly (e.g., from storage state). */ - setUser(user: ApiUser): void { + setUser(user: BaseUser): void { this.user = user; } @@ -269,6 +841,57 @@ export class AerieApi { return data.simulate; } + async updateModel(id: number, model: Partial): Promise { + await this.gqlQuery(gql.UPDATE_MODEL, { id, model }); + } + + /** + * Upload an external source JSON file to create events. + */ + async uploadExternalSource( + derivationGroupName: string, + sourceJson: { + events: Array<{ + attributes: object; + duration: string; + event_type_name: string; + key: string; + start_time: string; + }>; + source: { + attributes: object; + key: string; + period: { end_time: string; start_time: string }; + source_type_name: string; + valid_at: string; + }; + }, + ): Promise { + if (!this.user) { + throw new Error('Not logged in. Call login() first.'); + } + + const formData = new FormData(); + formData.append('derivation_group_name', derivationGroupName); + const blob = new Blob([JSON.stringify(sourceJson)], { type: 'application/json' }); + formData.append('external_source_file', blob, 'external_source.json'); + + const response = await fetch(`${this.gatewayUrl}/uploadExternalSource`, { + body: formData, + headers: { + Authorization: `Bearer ${this.user.token}`, + 'x-hasura-role': 'aerie_admin', + 'x-hasura-user-id': this.user.id as string, + }, + method: 'POST', + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`External source upload failed: ${response.statusText} - ${errorText}`); + } + } + /** * Upload a JAR file to the Gateway and return the uploaded file ID. */ @@ -294,7 +917,7 @@ export class AerieApi { headers: { Authorization: `Bearer ${this.user.token}`, 'x-hasura-role': 'aerie_admin', - 'x-hasura-user-id': this.user.id, + 'x-hasura-user-id': this.user.id as string, }, method: 'POST', }); diff --git a/e2e-tests/utilities/deseed.test.ts b/e2e-tests/utilities/deseed.test.ts new file mode 100644 index 0000000000..7b30962c20 --- /dev/null +++ b/e2e-tests/utilities/deseed.test.ts @@ -0,0 +1,502 @@ +/** + * Aerie De-Seed Script + * + * Removes all data created by the seed script. + * Run with: npm run deseed + * + * Identifies seeded items by their naming pattern: "Name (animal-suffix)" + * Deletes in reverse order to respect foreign key constraints. + */ + +import { test } from '@playwright/test'; +import { AerieApi } from './api.js'; + +// Run with single worker and no retries +test.describe.configure({ mode: 'serial', retries: 0, timeout: 120000 }); + +// Pattern to identify seeded items: name contains " (" indicating unique suffix +const isSeedItem = (name: string): boolean => name.includes(' (') && name.includes(')'); + +// External type name prefixes from seed script (actual names have _suffix appended) +const SEED_EXTERNAL_SOURCE_TYPE_PREFIX = 'BananaSupplySource_'; +const SEED_EXTERNAL_EVENT_TYPE_PREFIX = 'BananaDelivery_'; +// Dictionary mission name prefix from seed script +const SEED_DICTIONARY_MISSION_PREFIX = 'Seed_'; + +test('remove all seeded Aerie data', async () => { + console.log('Starting Aerie de-seed...\n'); + + const api = new AerieApi(); + + // Login + console.log('Logging in as test user...'); + await api.login('test', 'test'); + console.log('Logged in successfully.\n'); + + // Get all items + console.log('Querying for seeded items...'); + const [ + models, + plans, + tags, + constraints, + schedulingGoals, + schedulingConditions, + views, + derivationGroups, + externalSources, + externalSourceTypes, + externalEventTypes, + parcels, + sequenceAdaptations, + workspaces, + commandDictionaries, + channelDictionaries, + parameterDictionaries, + expansionRules, + expansionSets, + actionDefinitions, + extensions, + ] = await Promise.all([ + api.getModels(), + api.getPlans(), + api.getTags(), + api.getConstraints(), + api.getSchedulingGoals(), + api.getSchedulingConditions(), + api.getViews(), + api.getDerivationGroups(), + api.getExternalSources(), + api.getExternalSourceTypes(), + api.getExternalEventTypes(), + api.getParcels(), + api.getSequenceAdaptations(), + api.getWorkspaces(), + api.getCommandDictionaries(), + api.getChannelDictionaries(), + api.getParameterDictionaries(), + api.getExpansionRules(), + api.getExpansionSets(), + api.getActionDefinitions(), + api.getExtensions(), + ]); + + // Filter for seeded items + const seededModels = models.filter(m => isSeedItem(m.name)); + const seededPlans = plans.filter(p => isSeedItem(p.name)); + const seededTags = tags.filter(t => isSeedItem(t.name)); + const seededConstraints = constraints.filter(c => isSeedItem(c.name)); + const seededGoals = schedulingGoals.filter(g => isSeedItem(g.name)); + const seededConditions = schedulingConditions.filter(c => isSeedItem(c.name)); + const seededViews = views.filter(v => isSeedItem(v.name)); + const seededDerivationGroups = derivationGroups.filter(dg => isSeedItem(dg.name)); + // External sources belong to seeded derivation groups + const seededDerivationGroupNames = new Set(seededDerivationGroups.map(dg => dg.name)); + const seededExternalSources = externalSources.filter(s => seededDerivationGroupNames.has(s.derivation_group_name)); + // External types use underscore+suffix pattern instead of parentheses + const seededSourceTypes = externalSourceTypes.filter(t => t.name.startsWith(SEED_EXTERNAL_SOURCE_TYPE_PREFIX)); + const seededEventTypes = externalEventTypes.filter(t => t.name.startsWith(SEED_EXTERNAL_EVENT_TYPE_PREFIX)); + const seededParcels = parcels.filter(p => isSeedItem(p.name)); + const seededAdaptations = sequenceAdaptations.filter(a => isSeedItem(a.name)); + const seededWorkspaces = workspaces.filter(w => isSeedItem(w.name)); + // Dictionaries use mission name with Seed_ prefix + const seededCommandDicts = commandDictionaries.filter(d => d.mission.startsWith(SEED_DICTIONARY_MISSION_PREFIX)); + const seededChannelDicts = channelDictionaries.filter(d => d.mission.startsWith(SEED_DICTIONARY_MISSION_PREFIX)); + const seededParamDicts = parameterDictionaries.filter(d => d.mission.startsWith(SEED_DICTIONARY_MISSION_PREFIX)); + // Expansion rules and sets use parentheses pattern + const seededExpansionRules = expansionRules.filter(r => isSeedItem(r.name)); + const seededExpansionSets = expansionSets.filter(s => isSeedItem(s.name)); + // Action definitions use parentheses pattern + const seededActionDefinitions = actionDefinitions.filter(a => isSeedItem(a.name)); + // Extensions use parentheses pattern + const seededExtensions = extensions.filter(e => isSeedItem(e.label)); + + console.log(`Found ${seededModels.length} seeded models`); + console.log(`Found ${seededPlans.length} seeded plans`); + console.log(`Found ${seededTags.length} seeded tags`); + console.log(`Found ${seededConstraints.length} seeded constraints`); + console.log(`Found ${seededGoals.length} seeded scheduling goals`); + console.log(`Found ${seededConditions.length} seeded scheduling conditions`); + console.log(`Found ${seededViews.length} seeded views`); + console.log(`Found ${seededDerivationGroups.length} seeded derivation groups`); + console.log(`Found ${seededExternalSources.length} seeded external sources`); + console.log(`Found ${seededSourceTypes.length} seeded external source types`); + console.log(`Found ${seededEventTypes.length} seeded external event types`); + console.log(`Found ${seededParcels.length} seeded parcels`); + console.log(`Found ${seededAdaptations.length} seeded sequence adaptations`); + console.log(`Found ${seededWorkspaces.length} seeded workspaces`); + console.log(`Found ${seededCommandDicts.length} seeded command dictionaries`); + console.log(`Found ${seededChannelDicts.length} seeded channel dictionaries`); + console.log(`Found ${seededParamDicts.length} seeded parameter dictionaries`); + console.log(`Found ${seededExpansionRules.length} seeded expansion rules`); + console.log(`Found ${seededExpansionSets.length} seeded expansion sets`); + console.log(`Found ${seededActionDefinitions.length} seeded action definitions`); + console.log(`Found ${seededExtensions.length} seeded extensions\n`); + + const totalSeeded = + seededModels.length + + seededPlans.length + + seededTags.length + + seededConstraints.length + + seededGoals.length + + seededConditions.length + + seededViews.length + + seededDerivationGroups.length + + seededExternalSources.length + + seededSourceTypes.length + + seededEventTypes.length + + seededParcels.length + + seededAdaptations.length + + seededWorkspaces.length + + seededCommandDicts.length + + seededChannelDicts.length + + seededParamDicts.length + + seededExpansionRules.length + + seededExpansionSets.length + + seededActionDefinitions.length + + seededExtensions.length; + + if (totalSeeded === 0) { + console.log('No seeded items found. Nothing to clean up.'); + return; + } + + // Delete in reverse order of creation to respect foreign keys + + // 15. Delete plans (they reference models) + if (seededPlans.length > 0) { + console.log('Deleting plans...'); + for (const plan of seededPlans) { + try { + await api.deletePlan(plan.id); + console.log(` - Deleted plan: ${plan.name} (ID: ${plan.id})`); + } catch (e) { + console.log(` - Failed to delete plan: ${plan.name} (ID: ${plan.id}) - ${e}`); + } + } + console.log(''); + } + + // 1. Delete models + if (seededModels.length > 0) { + console.log('Deleting models...'); + for (const model of seededModels) { + try { + await api.deleteModel(model.id); + console.log(` - Deleted model: ${model.name} (ID: ${model.id})`); + } catch (e) { + console.log(` - Failed to delete model: ${model.name} (ID: ${model.id}) - ${e}`); + } + } + console.log(''); + } + + // 2. Delete external sources first (they contain events and reference derivation groups) + if (seededExternalSources.length > 0) { + console.log('Deleting external sources...'); + // Group sources by derivation group for deletion + const sourcesByGroup = new Map(); + for (const source of seededExternalSources) { + const existing = sourcesByGroup.get(source.derivation_group_name) ?? []; + existing.push(source.key); + sourcesByGroup.set(source.derivation_group_name, existing); + } + for (const [groupName, sourceKeys] of sourcesByGroup) { + try { + await api.deleteExternalSources(groupName, sourceKeys); + for (const key of sourceKeys) { + console.log(` - Deleted external source: ${key} (group: ${groupName})`); + } + } catch (e) { + console.log(` - Failed to delete external sources in ${groupName}: ${e}`); + } + } + console.log(''); + } + + // 3. Delete derivation groups + if (seededDerivationGroups.length > 0) { + console.log('Deleting derivation groups...'); + try { + const names = seededDerivationGroups.map(dg => dg.name); + await api.deleteDerivationGroups(names); + for (const dg of seededDerivationGroups) { + console.log(` - Deleted derivation group: ${dg.name}`); + } + } catch (e) { + console.log(` - Failed to delete derivation groups: ${e}`); + } + console.log(''); + } + + // 4. Delete external event types (after events are deleted with sources) + if (seededEventTypes.length > 0) { + console.log('Deleting external event types...'); + try { + const names = seededEventTypes.map(t => t.name); + await api.deleteExternalEventTypes(names); + for (const eventType of seededEventTypes) { + console.log(` - Deleted external event type: ${eventType.name}`); + } + } catch (e) { + console.log(` - Failed to delete external event types: ${e}`); + } + console.log(''); + } + + // 5. Delete external source types (after derivation groups are deleted) + if (seededSourceTypes.length > 0) { + console.log('Deleting external source types...'); + try { + const names = seededSourceTypes.map(t => t.name); + await api.deleteExternalSourceTypes(names); + for (const sourceType of seededSourceTypes) { + console.log(` - Deleted external source type: ${sourceType.name}`); + } + } catch (e) { + console.log(` - Failed to delete external source types: ${e}`); + } + console.log(''); + } + + // 6. Delete action definitions (they reference workspaces) + if (seededActionDefinitions.length > 0) { + console.log('Deleting action definitions...'); + for (const action of seededActionDefinitions) { + try { + await api.deleteActionDefinition(action.id); + console.log(` - Deleted action definition: ${action.name} (ID: ${action.id})`); + } catch (e) { + console.log(` - Failed to delete action definition: ${action.name} (ID: ${action.id}) - ${e}`); + } + } + console.log(''); + } + + // 7. Delete extensions (no foreign key dependencies) + if (seededExtensions.length > 0) { + console.log('Deleting extensions...'); + for (const ext of seededExtensions) { + try { + await api.deleteExtension(ext.id); + console.log(` - Deleted extension: ${ext.label} (ID: ${ext.id})`); + } catch (e) { + console.log(` - Failed to delete extension: ${ext.label} (ID: ${ext.id}) - ${e}`); + } + } + console.log(''); + } + + // 8. Delete workspaces (they reference parcels) + if (seededWorkspaces.length > 0) { + console.log('Deleting workspaces...'); + for (const workspace of seededWorkspaces) { + try { + await api.deleteWorkspace(workspace.id); + console.log(` - Deleted workspace: ${workspace.name} (ID: ${workspace.id})`); + } catch (e) { + console.log(` - Failed to delete workspace: ${workspace.name} (ID: ${workspace.id}) - ${e}`); + } + } + console.log(''); + } + + // 9. Delete expansion sets (they reference expansion rules) + if (seededExpansionSets.length > 0) { + console.log('Deleting expansion sets...'); + for (const set of seededExpansionSets) { + try { + await api.deleteExpansionSet(set.id); + console.log(` - Deleted expansion set: ${set.name} (ID: ${set.id})`); + } catch (e) { + console.log(` - Failed to delete expansion set: ${set.name} (ID: ${set.id}) - ${e}`); + } + } + console.log(''); + } + + // 10. Delete expansion rules (they reference parcels) + if (seededExpansionRules.length > 0) { + console.log('Deleting expansion rules...'); + for (const rule of seededExpansionRules) { + try { + await api.deleteExpansionRule(rule.id); + console.log(` - Deleted expansion rule: ${rule.name} (ID: ${rule.id})`); + } catch (e) { + console.log(` - Failed to delete expansion rule: ${rule.name} (ID: ${rule.id}) - ${e}`); + } + } + console.log(''); + } + + // 11. Delete parcels (they reference dictionaries and expansion rules reference them) + if (seededParcels.length > 0) { + console.log('Deleting parcels...'); + for (const parcel of seededParcels) { + try { + await api.deleteParcel(parcel.id); + console.log(` - Deleted parcel: ${parcel.name} (ID: ${parcel.id})`); + } catch (e) { + console.log(` - Failed to delete parcel: ${parcel.name} (ID: ${parcel.id}) - ${e}`); + } + } + console.log(''); + } + + // 12. Delete dictionaries (after parcels since parcels reference them) + if (seededCommandDicts.length > 0) { + console.log('Deleting command dictionaries...'); + for (const dict of seededCommandDicts) { + try { + await api.deleteCommandDictionary(dict.id); + console.log(` - Deleted command dictionary: ${dict.mission} v${dict.version} (ID: ${dict.id})`); + } catch (e) { + console.log( + ` - Failed to delete command dictionary: ${dict.mission} v${dict.version} (ID: ${dict.id}) - ${e}`, + ); + } + } + console.log(''); + } + + if (seededChannelDicts.length > 0) { + console.log('Deleting channel dictionaries...'); + for (const dict of seededChannelDicts) { + try { + await api.deleteChannelDictionary(dict.id); + console.log(` - Deleted channel dictionary: ${dict.mission} v${dict.version} (ID: ${dict.id})`); + } catch (e) { + console.log( + ` - Failed to delete channel dictionary: ${dict.mission} v${dict.version} (ID: ${dict.id}) - ${e}`, + ); + } + } + console.log(''); + } + + if (seededParamDicts.length > 0) { + console.log('Deleting parameter dictionaries...'); + for (const dict of seededParamDicts) { + try { + await api.deleteParameterDictionary(dict.id); + console.log(` - Deleted parameter dictionary: ${dict.mission} v${dict.version} (ID: ${dict.id})`); + } catch (e) { + console.log( + ` - Failed to delete parameter dictionary: ${dict.mission} v${dict.version} (ID: ${dict.id}) - ${e}`, + ); + } + } + console.log(''); + } + + // 13. Delete sequence adaptations + if (seededAdaptations.length > 0) { + console.log('Deleting sequence adaptations...'); + for (const adaptation of seededAdaptations) { + try { + await api.deleteSequenceAdaptation(adaptation.id); + console.log(` - Deleted sequence adaptation: ${adaptation.name} (ID: ${adaptation.id})`); + } catch (e) { + console.log(` - Failed to delete sequence adaptation: ${adaptation.name} (ID: ${adaptation.id}) - ${e}`); + } + } + console.log(''); + } + + // 14. Delete views + if (seededViews.length > 0) { + console.log('Deleting views...'); + for (const view of seededViews) { + try { + await api.deleteView(view.id); + console.log(` - Deleted view: ${view.name} (ID: ${view.id})`); + } catch (e) { + console.log(` - Failed to delete view: ${view.name} (ID: ${view.id}) - ${e}`); + } + } + console.log(''); + } + + // 15. Delete scheduling conditions + if (seededConditions.length > 0) { + console.log('Deleting scheduling conditions...'); + for (const condition of seededConditions) { + try { + await api.deleteSchedulingCondition(condition.id); + console.log(` - Deleted scheduling condition: ${condition.name} (ID: ${condition.id})`); + } catch (e) { + console.log(` - Failed to delete scheduling condition: ${condition.name} (ID: ${condition.id}) - ${e}`); + } + } + console.log(''); + } + + // 16. Delete scheduling goals + if (seededGoals.length > 0) { + console.log('Deleting scheduling goals...'); + for (const goal of seededGoals) { + try { + await api.deleteSchedulingGoal(goal.id); + console.log(` - Deleted scheduling goal: ${goal.name} (ID: ${goal.id})`); + } catch (e) { + console.log(` - Failed to delete scheduling goal: ${goal.name} (ID: ${goal.id}) - ${e}`); + } + } + console.log(''); + } + + // 17. Delete constraints + if (seededConstraints.length > 0) { + console.log('Deleting constraints...'); + for (const constraint of seededConstraints) { + try { + await api.deleteConstraint(constraint.id); + console.log(` - Deleted constraint: ${constraint.name} (ID: ${constraint.id})`); + } catch (e) { + console.log(` - Failed to delete constraint: ${constraint.name} (ID: ${constraint.id}) - ${e}`); + } + } + console.log(''); + } + + // 18. Delete tags + if (seededTags.length > 0) { + console.log('Deleting tags...'); + for (const tag of seededTags) { + try { + await api.deleteTag(tag.id); + console.log(` - Deleted tag: ${tag.name} (ID: ${tag.id})`); + } catch (e) { + console.log(` - Failed to delete tag: ${tag.name} (ID: ${tag.id}) - ${e}`); + } + } + console.log(''); + } + + // Print summary + console.log('========================================'); + console.log('De-Seed Complete!'); + console.log('========================================\n'); + console.log('Deleted:'); + console.log(` External Sources: ${seededExternalSources.length}`); + console.log(` Derivation Groups: ${seededDerivationGroups.length}`); + console.log(` External Event Types: ${seededEventTypes.length}`); + console.log(` External Source Types: ${seededSourceTypes.length}`); + console.log(` Action Definitions: ${seededActionDefinitions.length}`); + console.log(` Extensions: ${seededExtensions.length}`); + console.log(` Workspaces: ${seededWorkspaces.length}`); + console.log(` Expansion Sets: ${seededExpansionSets.length}`); + console.log(` Expansion Rules: ${seededExpansionRules.length}`); + console.log(` Parcels: ${seededParcels.length}`); + console.log(` Command Dictionaries: ${seededCommandDicts.length}`); + console.log(` Channel Dictionaries: ${seededChannelDicts.length}`); + console.log(` Parameter Dictionaries: ${seededParamDicts.length}`); + console.log(` Sequence Adaptations: ${seededAdaptations.length}`); + console.log(` Views: ${seededViews.length}`); + console.log(` Scheduling Conditions: ${seededConditions.length}`); + console.log(` Scheduling Goals: ${seededGoals.length}`); + console.log(` Constraints: ${seededConstraints.length}`); + console.log(` Plans: ${seededPlans.length}`); + console.log(` Models: ${seededModels.length}`); + console.log(` Tags: ${seededTags.length}`); +}); diff --git a/e2e-tests/utilities/seed.test.ts b/e2e-tests/utilities/seed.test.ts new file mode 100644 index 0000000000..c0196495ad --- /dev/null +++ b/e2e-tests/utilities/seed.test.ts @@ -0,0 +1,999 @@ +/** + * Aerie Seed Script + * + * Populates Aerie with sample test data for development and testing. + * Run with: npm run seed + * + * Creates: + * - 1 model (banananation) + * - 4 plans with varying durations and activity counts + * - 5 tags with different colors + * - 3 constraints + * - 2 scheduling goals + * - 1 scheduling condition + * - 2 views with different layouts + * - 1 external source with events + * - 1 external dataset per plan (resource profiles) + * - 1 command dictionary, 1 channel dictionary, 1 parameter dictionary + * - 1 sequence adaptation + * - 1 parcel bundling the dictionaries + * - 3 expansion rules (one per activity type) + * - 1 expansion set bundling the rules + * - 2 workspaces using the parcel + * - 10 workspace files in first workspace (sequences, text, binary, image, json, folders) + * - ~1305 workspace items in second workspace (5 projects × 10 modules with deep nesting) + * - 1 action definition in the first workspace + * - 1 extension (demo plan analyzer, requires local extension server) + */ + +import { test } from '@playwright/test'; +import fs from 'fs'; +import { animals, uniqueNamesGenerator } from 'unique-names-generator'; +import { ConstraintDefinitionType } from '../../src/enums/constraint.js'; +import { SchedulingDefinitionType } from '../../src/enums/scheduling.js'; +import type { ActivityDirectiveInsertInput } from '../../src/types/activity.js'; +import type { SchedulingConditionInsertInput } from '../../src/types/scheduling.js'; +import { ResourceType } from '../../src/types/simulation.js'; +import { getIntervalFromDoyRange, getUnixEpochTime } from '../../src/utilities/time.js'; +import { generateDefaultView } from '../../src/utilities/view.js'; +import { AerieApi } from './api.js'; + +// Run with single worker and no retries +test.describe.configure({ mode: 'serial', retries: 0, timeout: 300000 }); + +// Generate unique suffix for this seed run +const uniqueSuffix = uniqueNamesGenerator({ dictionaries: [animals], separator: '-' }); + +// Banana-themed tags with colors +const TAGS = [ + { color: '#fbbf24', name: 'Ripe' }, + { color: '#84cc16', name: 'Unripe' }, + { color: '#7c3aed', name: 'Organic' }, + { color: '#f97316', name: 'Premium' }, + { color: '#64748b', name: 'Bruised' }, +]; + +// Cluster centers for realistic activity distribution (as fraction of plan duration) +// Creates operational windows with gaps between them +const CLUSTER_CENTERS = [0.05, 0.15, 0.25, 0.4, 0.55, 0.7, 0.8, 0.92]; + +// Generate clustered offset - activities grouped around operational windows +function getClusteredOffset(index: number, totalCount: number, planDurationMinutes: number): number { + // Assign activity to a cluster based on index + const clusterIndex = index % CLUSTER_CENTERS.length; + const clusterCenter = CLUSTER_CENTERS[clusterIndex]; + + // Spread within cluster (tighter for more activities, wider for fewer) + const spread = Math.min(0.08, 0.5 / Math.sqrt(totalCount)); + + // Box-Muller transform for gaussian-like distribution + const u1 = Math.random(); + const u2 = Math.random(); + const gaussian = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + + // Position within cluster (clamped to 0-1 range) + const position = Math.max(0, Math.min(1, clusterCenter + gaussian * spread)); + + return Math.floor(position * planDurationMinutes * 0.95); +} + +// Activity generators with realistic arguments +type ActivityGenerator = ( + planId: number, + index: number, + totalCount: number, + planDurationMinutes: number, +) => ActivityDirectiveInsertInput; + +const activityGenerators: Record = { + BiteBanana: (planId, index, totalCount, planDurationMinutes) => { + const biteSizes = [1, 2, 10, 100, 1000]; + const offsetMinutes = getClusteredOffset(index, totalCount, planDurationMinutes); + return { + anchor_id: null, + anchored_to_start: true, + arguments: { biteSize: biteSizes[index % biteSizes.length] }, + metadata: {}, + name: `Bite Banana #${index + 1}`, + plan_id: planId, + start_offset: formatOffset(offsetMinutes), + type: 'BiteBanana', + }; + }, + + GrowBanana: (planId, index, totalCount, planDurationMinutes) => { + const quantities = [1, 2, 10, 100, 1000]; + // Durations in microseconds: 1hr, 2hr, 4hr, 8hr + const durationsUs = [3_600_000_000, 7_200_000_000, 14_400_000_000, 28_800_000_000]; + const offsetMinutes = getClusteredOffset(index, totalCount, planDurationMinutes); + return { + anchor_id: null, + anchored_to_start: true, + arguments: { + growingDuration: durationsUs[index % durationsUs.length], + quantity: quantities[index % quantities.length], + }, + metadata: {}, + name: `Grow Batch #${index + 1}`, + plan_id: planId, + start_offset: formatOffset(offsetMinutes), + type: 'GrowBanana', + }; + }, + + PeelBanana: (planId, index, totalCount, planDurationMinutes) => { + const directions = ['fromStem', 'fromTip']; + const offsetMinutes = getClusteredOffset(index, totalCount, planDurationMinutes); + return { + anchor_id: null, + anchored_to_start: true, + arguments: { peelDirection: directions[index % directions.length] }, + metadata: {}, + name: `Peel Banana #${index + 1}`, + plan_id: planId, + start_offset: formatOffset(offsetMinutes), + type: 'PeelBanana', + }; + }, + + PickBanana: (planId, index, totalCount, planDurationMinutes) => { + const quantities = [5, 10, 15, 20, 25, 50]; + const offsetMinutes = getClusteredOffset(index, totalCount, planDurationMinutes); + return { + anchor_id: null, + anchored_to_start: true, + arguments: { quantity: quantities[index % quantities.length] }, + metadata: {}, + name: `Harvest #${index + 1}`, + plan_id: planId, + start_offset: formatOffset(offsetMinutes), + type: 'PickBanana', + }; + }, +}; + +function formatOffset(totalMinutes: number): string { + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}:${minutes.toString().padStart(2, '0')}:00`; +} + +// Plan configurations - diverse scenarios +interface PlanConfig { + activityMix: Record; // activity type -> count + description: string; + endTime: string; + name: string; + startTime: string; +} + +const PLANS: PlanConfig[] = [ + { + // Short daily operations plan + activityMix: { BiteBanana: 5, GrowBanana: 2, PeelBanana: 5, PickBanana: 3 }, + description: 'Daily operations with routine activities', + endTime: '2024-002T00:00:00', + name: 'Daily Ops', + startTime: '2024-001T00:00:00', + }, + { + // Week-long harvest cycle + activityMix: { BiteBanana: 15, GrowBanana: 10, PeelBanana: 15, PickBanana: 20 }, + description: 'Weekly harvest and processing cycle', + endTime: '2024-008T00:00:00', + name: 'Weekly Harvest', + startTime: '2024-001T00:00:00', + }, + { + // Month-long production run (1K activities) + activityMix: { BiteBanana: 250, GrowBanana: 200, PeelBanana: 250, PickBanana: 300 }, + description: 'Monthly production cycle with high throughput', + endTime: '2024-032T00:00:00', + name: 'Monthly Production', + startTime: '2024-001T00:00:00', + }, + // { + // // Quarter-long mission (10K activities) + // activityMix: { BiteBanana: 2500, GrowBanana: 2000, PeelBanana: 2500, PickBanana: 3000 }, + // description: 'Full quarterly mission with comprehensive operations', + // endTime: '2024-091T00:00:00', + // name: 'Q1 Mission', + // startTime: '2024-001T00:00:00', + // }, +]; + +// Constraint definitions (EDSL format) +const CONSTRAINTS = [ + { + definition: `export default function fruitAvailable(): Constraint { return Real.Resource('/fruit').greaterThanOrEqual(1); }`, + description: 'Ensure fruit resource stays above minimum threshold', + name: 'Fruit Availability', + }, + { + definition: `export default function peelConstraint(): Constraint { return Real.Resource('/peel').greaterThanOrEqual(0); }`, + description: 'Peel count should never go negative', + name: 'Peel Non-Negative', + }, + { + definition: `export default function producerCheck(): Constraint { return Real.Resource('/producer').lessThanOrEqual(100); }`, + description: 'Producer resource should not exceed capacity', + name: 'Producer Capacity', + }, +]; + +// Scheduling goal definitions +const SCHEDULING_GOALS = [ + { + definition: `export default (): Goal => Goal.ActivityRecurrenceGoal({ activityTemplate: ActivityTemplates.GrowBanana({ quantity: 10, growingDuration: 3600000000 }), interval: Temporal.Duration.from({ hours: 24 }) })`, + description: 'Grow bananas daily to maintain supply', + name: 'Daily Banana Growth', + }, + { + definition: `export default (): Goal => Goal.ActivityRecurrenceGoal({ activityTemplate: ActivityTemplates.PickBanana({ quantity: 5 }), interval: Temporal.Duration.from({ hours: 12 }) })`, + description: 'Harvest bananas twice daily', + name: 'Regular Harvest', + }, +]; + +// Scheduling condition definitions +const SCHEDULING_CONDITIONS: Array & { definition: string }> = + [ + { + definition: `export default (): GlobalSchedulingCondition => GlobalSchedulingCondition.scheduleActivitiesOnlyWhen(Real.Resource("/fruit").greaterThan(5.0))`, + description: 'Only schedule activities when fruit inventory is sufficient', + name: 'Fruit Inventory Check', + public: true, + }, + ]; + +// View configurations - each with different grid layouts +const VIEW_CONFIGS = [ + { + // Default mission view + name: 'Mission Default', + }, + { + // Timeline-focused view with hidden sidebars + grid: { + columnSizes: '1fr', + leftHidden: true, + middleRowSizes: '1fr', + middleSplit: false, + rightHidden: true, + }, + name: 'Timeline Focus', + }, + { + // Table-focused view with activity table prominent + grid: { + columnSizes: '3fr 3px 1fr', + leftHidden: true, + middleRowSizes: '1fr 3px 2fr', + middleSplit: true, + rightComponentBottom: 'ConstraintsPanel', + rightComponentTop: 'ActivityFormPanel', + rightHidden: false, + }, + name: 'Table View', + }, +]; + +// External source/event configuration +const EXTERNAL_SOURCE_TYPE = 'BananaSupplySource'; +const EXTERNAL_EVENT_TYPE = 'BananaDelivery'; + +const EXTERNAL_EVENTS = [ + { + attributes: { quantity: 100, supplier: 'Tropical Farms' }, + duration: '01:00:00', + key: 'delivery-001', + start_time: '2024-001T08:00:00', + }, + { + attributes: { quantity: 250, supplier: 'Island Growers' }, + duration: '02:00:00', + key: 'delivery-002', + start_time: '2024-003T10:00:00', + }, + { + attributes: { quantity: 150, supplier: 'Tropical Farms' }, + duration: '01:30:00', + key: 'delivery-003', + start_time: '2024-005T14:00:00', + }, +]; + +// External dataset (resource profiles) configuration +// Duration is in microseconds: 1 minute = 60,000,000 µs +const MINUTE_US = 60_000_000; + +// Generate external dataset profiles with semi-random data and gaps +function generateExternalDataset(startTime: string, durationHours: number) { + const segmentMinutes = 10; + const segmentCount = Math.floor((durationHours * 60) / segmentMinutes); + + const ripenessStates = ['green', 'yellow-green', 'yellow', 'spotted', 'brown', 'overripe']; + const segments: Array<{ duration: number; dynamics?: unknown }> = []; + + for (let i = 0; i < segmentCount; i++) { + const duration = Math.round((segmentMinutes + Math.random() * 10) * MINUTE_US); + if (Math.random() < 0.15) { + segments.push({ duration }); + } else { + segments.push({ duration, dynamics: ripenessStates[i % ripenessStates.length] }); + } + } + + // Battery: random initial/rate values + const batterySegments: Array<{ duration: number; dynamics?: unknown }> = []; + for (let i = 0; i < segmentCount; i++) { + const duration = Math.round((segmentMinutes + Math.random() * 10) * MINUTE_US); + if (Math.random() < 0.15) { + batterySegments.push({ duration }); + } else { + batterySegments.push({ + duration, + dynamics: { initial: 20 + Math.random() * 80, rate: -3 + Math.random() * 6 }, + }); + } + } + + // Temperature: random values 12-16 + const tempSegments: Array<{ duration: number; dynamics?: unknown }> = []; + for (let i = 0; i < segmentCount; i++) { + const duration = Math.round((segmentMinutes + Math.random() * 10) * MINUTE_US); + if (Math.random() < 0.15) { + tempSegments.push({ duration }); + } else { + tempSegments.push({ duration, dynamics: Math.round((12 + Math.random() * 4) * 10) / 10 }); + } + } + + return { + datasetStart: startTime, + profileSet: { + '/bananaRipeness': { + schema: { type: 'string' }, + segments, + type: 'discrete' as const, + }, + '/batteryEnergy': { + schema: { + items: { initial: { type: 'real' }, rate: { type: 'real' } }, + type: 'struct', + }, + segments: batterySegments, + type: 'real' as const, + }, + '/storageTemp': { + schema: { type: 'real' }, + segments: tempSegments, + type: 'discrete' as const, + }, + }, + }; +} + +test('seed Aerie with sample data', async () => { + console.log(`Starting Aerie seed (${uniqueSuffix})...\n`); + + const api = new AerieApi(); + + // Login + console.log('Logging in as test user...'); + await api.login('test', 'test'); + console.log('Logged in successfully.\n'); + + // Upload JAR + console.log('Uploading banananation JAR...'); + const jarId = await api.uploadFile('e2e-tests/data/banananation-develop.jar'); + console.log(`JAR uploaded with ID: ${jarId}\n`); + + // Create model + const modelName = `Banananation (${uniqueSuffix})`; + console.log('Creating model...'); + const model = await api.createModel({ + description: 'Seeded model for development and testing', + jar_id: jarId, + mission: 'Banananation', + name: modelName, + version: '1.0.0', + }); + console.log(`Model created with ID: ${model.id}`); + + // Create tags + console.log('Creating tags...'); + const createdTags: Array<{ id: number; name: string }> = []; + for (const tag of TAGS) { + const tagName = `${tag.name} (${uniqueSuffix})`; + const created = await api.createTag(tagName, tag.color); + createdTags.push({ id: created.id, name: tagName }); + console.log(` - Created tag: ${tagName} (ID: ${created.id})`); + } + console.log(''); + + // Create plans and activities + console.log('Creating plans and activities...'); + const createdPlans: Array<{ + activityCount: number; + durationHours: number; + id: number; + name: string; + startTime: string; + }> = []; + + for (const planConfig of PLANS) { + // Create plan with unique name + const planName = `${planConfig.name} (${uniqueSuffix})`; + const plan = await api.createPlan({ + duration: getIntervalFromDoyRange(planConfig.startTime, planConfig.endTime), + model_id: model.id, + name: planName, + start_time: planConfig.startTime, + }); + + const totalActivities = Object.values(planConfig.activityMix).reduce((a, b) => a + b, 0); + console.log(` - Created plan: ${planName} (ID: ${plan.id})`); + console.log(` ${planConfig.description}`); + console.log(` Creating ${totalActivities} activities...`); + + // Calculate plan duration in minutes for activity spacing + const startMs = getUnixEpochTime(planConfig.startTime); + const endMs = getUnixEpochTime(planConfig.endTime); + const planDurationMinutes = Math.floor((endMs - startMs) / (1000 * 60)); + + // Build activities for each type + const activities: ActivityDirectiveInsertInput[] = []; + for (const [activityType, count] of Object.entries(planConfig.activityMix)) { + const generator = activityGenerators[activityType]; + for (let i = 0; i < count; i++) { + activities.push(generator(plan.id, i, count, planDurationMinutes)); + } + } + + // Shuffle activities to interleave types (more realistic) + for (let i = activities.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [activities[i], activities[j]] = [activities[j], activities[i]]; + } + + // Sort by start_offset for cleaner timeline + activities.sort((a, b) => { + const parseOffset = (offset: string) => { + const [h, m] = offset.split(':').map(Number); + return h * 60 + m; + }; + return parseOffset(a.start_offset) - parseOffset(b.start_offset); + }); + + // Bulk insert in batches of 1000 + const BATCH_SIZE = 1000; + for (let i = 0; i < activities.length; i += BATCH_SIZE) { + const batch = activities.slice(i, i + BATCH_SIZE); + await api.createActivityDirectives(batch); + console.log(` Progress: ${Math.min(i + BATCH_SIZE, totalActivities)}/${totalActivities}`); + } + console.log(` Created ${totalActivities} activities`); + + const durationHours = planDurationMinutes / 60; + createdPlans.push({ + activityCount: totalActivities, + durationHours, + id: plan.id, + name: planName, + startTime: planConfig.startTime, + }); + } + + // Create constraints + console.log('\nCreating constraints...'); + const createdConstraints: Array<{ id: number; name: string }> = []; + for (const constraint of CONSTRAINTS) { + const constraintName = `${constraint.name} (${uniqueSuffix})`; + const created = await api.createConstraint({ + description: constraint.description, + name: constraintName, + public: true, + tags: { data: [] }, + versions: { + data: [ + { + definition: constraint.definition, + tags: { data: [] }, + type: ConstraintDefinitionType.EDSL, + uploaded_jar_id: null, + }, + ], + }, + }); + createdConstraints.push({ id: created.id, name: constraintName }); + console.log(` - Created constraint: ${constraintName} (ID: ${created.id})`); + } + + // Create scheduling goals + console.log('\nCreating scheduling goals...'); + const createdGoals: Array<{ id: number; name: string }> = []; + for (const goal of SCHEDULING_GOALS) { + const goalName = `${goal.name} (${uniqueSuffix})`; + const created = await api.createSchedulingGoal({ + description: goal.description, + name: goalName, + public: true, + tags: { data: [] }, + versions: { + data: [ + { + definition: goal.definition, + tags: { data: [] }, + type: SchedulingDefinitionType.EDSL, + uploaded_jar_id: null, + }, + ], + }, + }); + createdGoals.push({ id: created.id, name: goalName }); + console.log(` - Created scheduling goal: ${goalName} (ID: ${created.id})`); + } + + // Create scheduling conditions + console.log('\nCreating scheduling conditions...'); + const createdConditions: Array<{ id: number; name: string }> = []; + for (const condition of SCHEDULING_CONDITIONS) { + const conditionName = `${condition.name} (${uniqueSuffix})`; + const created = await api.createSchedulingCondition({ + description: condition.description, + name: conditionName, + public: true, + tags: { data: [] }, + versions: { + data: [ + { + definition: condition.definition, + tags: { data: [] }, + }, + ], + }, + }); + createdConditions.push({ id: created.id, name: conditionName }); + console.log(` - Created scheduling condition: ${conditionName} (ID: ${created.id})`); + } + + // Associate constraints, goals, and conditions with the model + console.log('\nAssociating library items with model...'); + await api.addConstraintModelSpecifications( + model.id, + createdConstraints.map(c => ({ constraintId: c.id })), + ); + console.log(` - Associated ${createdConstraints.length} constraints with model`); + + await api.addSchedulingGoalModelSpecifications( + model.id, + createdGoals.map((g, index) => ({ goalId: g.id, priority: index })), + ); + console.log(` - Associated ${createdGoals.length} scheduling goals with model`); + + await api.addSchedulingConditionModelSpecifications( + model.id, + createdConditions.map(c => ({ conditionId: c.id })), + ); + console.log(` - Associated ${createdConditions.length} scheduling conditions with model`); + + // Create external source and event types first (needed for views) + console.log('\nCreating external source types...'); + const derivationGroupName = `Banana Supply (${uniqueSuffix})`; + const sourceTypeName = `${EXTERNAL_SOURCE_TYPE}_${uniqueSuffix.replace(/-/g, '_')}`; + const eventTypeName = `${EXTERNAL_EVENT_TYPE}_${uniqueSuffix.replace(/-/g, '_')}`; + const eventTypeSchema = { + properties: { quantity: { type: 'number' }, supplier: { type: 'string' } }, + required: [], + type: 'object', + }; + + await api.createExternalSourceEventTypes( + { [sourceTypeName]: { properties: {}, required: [], type: 'object' } }, + { [eventTypeName]: eventTypeSchema }, + ); + console.log(` - Created external source type: ${sourceTypeName}`); + console.log(` - Created external event type: ${eventTypeName}`); + + // Create derivation group and upload sources + console.log('\nCreating external sources...'); + + // Create derivation group + await api.createDerivationGroup({ + name: derivationGroupName, + source_type_name: sourceTypeName, + }); + console.log(` - Created derivation group: ${derivationGroupName}`); + + // Upload external source with events + const sourceKey = `supply-source-${uniqueSuffix}`; + await api.uploadExternalSource(derivationGroupName, { + events: EXTERNAL_EVENTS.map(e => ({ + attributes: e.attributes, + duration: e.duration, + event_type_name: eventTypeName, + key: e.key, + start_time: e.start_time, + })), + source: { + attributes: {}, + key: sourceKey, + period: { end_time: '2024-010T00:00:00', start_time: '2024-001T00:00:00' }, + source_type_name: sourceTypeName, + valid_at: '2024-001T00:00:00', + }, + }); + console.log(` - Uploaded external source: ${sourceKey} with ${EXTERNAL_EVENTS.length} events`); + + // Associate the derivation group with each plan + for (const plan of createdPlans) { + await api.createPlanDerivationGroup(plan.id, derivationGroupName); + console.log(` - Associated derivation group with plan: ${plan.name}`); + } + + // Create external dataset (resource profiles) for each plan + console.log('\nCreating external datasets...'); + const createdDatasets: Array<{ id: number; planName: string }> = []; + + // Generate first dataset to derive profile schemas for view generation + const firstDataset = generateExternalDataset(createdPlans[0].startTime, createdPlans[0].durationHours); + const externalDatasetProfiles: ResourceType[] = Object.entries(firstDataset.profileSet).map(([name, profile]) => ({ + name, + schema: profile.schema as ResourceType[][number]['schema'], + })); + const profileNames = externalDatasetProfiles.map(p => p.name); + + for (const plan of createdPlans) { + const dataset = generateExternalDataset(plan.startTime, plan.durationHours); + const datasetId = await api.createExternalDataset(plan.id, dataset); + createdDatasets.push({ id: datasetId, planName: plan.name }); + console.log(` - Created external dataset (ID: ${datasetId}) on plan ${plan.name} (${plan.durationHours}h)`); + } + console.log(` Profiles: ${profileNames.join(', ')}`); + + // Fetch resource types for view generation + const resourceTypes = await api.getResourceTypes(model.id); + console.log(`Fetched ${resourceTypes.length} resource types from model\n`); + + // Create views with different configurations (using resource types and external event types) + console.log('\nCreating views...'); + const externalEventTypes = [{ attribute_schema: eventTypeSchema, name: eventTypeName }]; + const allResourceTypes = [...resourceTypes, ...externalDatasetProfiles]; + + const createdViews: Array<{ id: number; name: string }> = []; + for (const viewConfig of VIEW_CONFIGS) { + const viewName = `${viewConfig.name} (${uniqueSuffix})`; + const defaultView = generateDefaultView(allResourceTypes, externalEventTypes); + Object.assign(defaultView.definition.plan.grid, viewConfig.grid); + const created = await api.createView({ + definition: defaultView.definition, + name: viewName, + }); + createdViews.push({ id: created.id, name: viewName }); + console.log(` - Created view: ${viewName} (ID: ${created.id})`); + } + + // Set the "Mission Default" view as the model's default view + const missionDefaultView = createdViews.find(v => v.name.includes('Mission Default')); + if (missionDefaultView) { + await api.updateModel(model.id, { default_view_id: missionDefaultView.id }); + console.log(` - Set model default view to: ${missionDefaultView.name} (ID: ${missionDefaultView.id})`); + } + + // Create dictionaries and parcel + console.log('\nCreating dictionaries and parcel...'); + + // Mission name for dictionaries - use suffix to make them identifiable as seeded + const missionName = `Seed_${uniqueSuffix}`; + + // Read and upload command dictionary with customized mission name + const commandDictXml = fs + .readFileSync('e2e-tests/data/command-dictionary.xml', 'utf-8') + .replace('mission_name="GENERIC"', `mission_name="${missionName}"`); + const commandDictResult = await api.createDictionary(commandDictXml); + const commandDictId = commandDictResult.command?.id; + console.log(` - Created command dictionary (ID: ${commandDictId}, mission: ${missionName})`); + + // Read and upload channel dictionary with customized mission name + const channelDictXml = fs + .readFileSync('e2e-tests/data/channel-dictionary.xml', 'utf-8') + .replace('mission_name="GENERIC"', `mission_name="${missionName}"`); + const channelDictResult = await api.createDictionary(channelDictXml); + const channelDictId = channelDictResult.channel?.id; + console.log(` - Created channel dictionary (ID: ${channelDictId}, mission: ${missionName})`); + + // Read and upload parameter dictionary with customized mission name + const paramDictXml = fs + .readFileSync('e2e-tests/data/parameter-dictionary.xml', 'utf-8') + .replace('mission_name="GENERIC"', `mission_name="${missionName}"`); + const paramDictResult = await api.createDictionary(paramDictXml); + const paramDictId = paramDictResult.parameter?.id; + console.log(` - Created parameter dictionary (ID: ${paramDictId}, mission: ${missionName})`); + + // Read and upload sequence adaptation + const adaptationCode = fs.readFileSync('e2e-tests/data/sequence-adaptation.js', 'utf-8'); + const adaptationName = `Seed Adaptation (${uniqueSuffix})`; + const adaptationResult = await api.createSequenceAdaptation({ + adaptation: adaptationCode, + name: adaptationName, + }); + console.log(` - Created sequence adaptation: ${adaptationResult.name}`); + + // Create parcel bundling the dictionaries + const parcelName = `Seed Parcel (${uniqueSuffix})`; + const parcel = await api.createParcel({ + channel_dictionary_id: channelDictId ?? null, + command_dictionary_id: commandDictId!, + name: parcelName, + sequence_adaptation_id: null, // Adaptations are linked separately + }); + console.log(` - Created parcel: ${parcelName} (ID: ${parcel.id})`); + + // Create expansion rules and set + console.log('\nCreating expansion rules and set...'); + const expansionRules = [ + { + activity_type: 'BiteBanana', + description: 'Expands BiteBanana activity to bite commands', + expansion_logic: `export default function({ activityInstance: ActivityType }): ExpansionReturn { + return [ + C.FSW_CMD_0({ + enum_arg_0: "ON", + boolean_arg_0: true, + float_arg_0: 0.5 + }) + ]; +}`, + name: `BiteBanana Expansion (${uniqueSuffix})`, + }, + { + activity_type: 'PeelBanana', + description: 'Expands PeelBanana activity to peel commands', + expansion_logic: `export default function({ activityInstance: ActivityType }): ExpansionReturn { + return [ + C.FSW_CMD_1({ + float_arg_0: 1.0, + integer_arg_0: 10, + time_arg_0: "2024-001T00:00:00", + unsigned_arg_0: 100, + var_string_arg_0: "0000" + }) + ]; +}`, + name: `PeelBanana Expansion (${uniqueSuffix})`, + }, + { + activity_type: 'PickBanana', + description: 'Expands PickBanana activity to FSW commands', + expansion_logic: `export default function({ activityInstance: ActivityType }): ExpansionReturn { + return [ + C.FSW_CMD_0({ + enum_arg_0: "OFF", + boolean_arg_0: false, + float_arg_0: 1.0 + }) + ]; +}`, + name: `PickBanana Expansion (${uniqueSuffix})`, + }, + ]; + + const createdExpansionRules: Array<{ id: number; name: string }> = []; + for (const rule of expansionRules) { + const created = await api.createExpansionRule({ + activity_type: rule.activity_type, + authoring_mission_model_id: model.id, + description: rule.description, + expansion_logic: rule.expansion_logic, + name: rule.name, + parcel_id: parcel.id, + }); + createdExpansionRules.push({ id: created.id, name: rule.name }); + console.log(` - Created expansion rule: ${rule.name} (ID: ${created.id})`); + } + + // Create expansion set bundling all rules + const expansionSetName = `Seed Expansion Set (${uniqueSuffix})`; + const expansionSet = await api.createExpansionSet( + parcel.id, + model.id, + createdExpansionRules.map(r => r.id), + expansionSetName, + 'Expansion set containing all seeded expansion rules', + ); + console.log(` - Created expansion set: ${expansionSetName} (ID: ${expansionSet.id})`); + + // Create workspace using the parcel + console.log('\nCreating workspace...'); + const workspaceName = `Seed Workspace (${uniqueSuffix})`; + const workspaceLocation = `seed_workspace_${uniqueSuffix}`; + const workspaceId = await api.createWorkspace(workspaceLocation, parcel.id, workspaceName); + console.log(` - Created workspace: ${workspaceName} (ID: ${workspaceId}, location: ${workspaceLocation})`); + + // Create action definition in the workspace + console.log('\nCreating action...'); + const actionName = `Seed Action (${uniqueSuffix})`; + const actionDescription = 'Demo action that fetches data from GitHub API'; + const action = await api.createActionDefinition( + workspaceId, + actionName, + actionDescription, + 'e2e-tests/data/aerie-action-demo.js', + ); + console.log(` - Created action: ${actionName} (ID: ${action.id})`); + + // Create extension + // Extensions receive POST with { planId, selectedActivityDirectiveId, simulationDatasetId, gateway, hasura } + // and must return { success: boolean, message: string, url: string } + console.log('\nCreating extension...'); + const extensionName = `Plan Analyzer (${uniqueSuffix})`; + const extensionDescription = + 'Demo extension - analyzes plan data and opens results (requires local extension server)'; + const extensionUrl = 'http://localhost:8000/analyze'; + const extension = await api.createExtension(extensionName, extensionUrl, extensionDescription, ['aerie_admin']); + console.log(` - Created extension: ${extensionName} (ID: ${extension.id})`); + + // Create workspace files + console.log('\nCreating workspace files...'); + + // Reusable content + const sequenceContent = `@ID "seed_sequence"\n\nC FSW_CMD_0 "ON" true 0.5\nC FSW_CMD_1 1.0 10 "2024-001T00:00:00" 100 "0000"\n`; + const textContent = `Seed Workspace Notes\n====================\n\nCreated by seed script. Suffix: ${uniqueSuffix}\n`; + const jsonContent = `{ seeded_json: ${uniqueSuffix} }`; + const binaryContent = new Uint8Array(256).map(() => Math.floor(Math.random() * 256)); + // Minimal valid JPEG (1x1 pixel) + // prettier-ignore + const jpegContent = new Uint8Array([ + 0xff,0xd8,0xff,0xe0,0x00,0x10,0x4a,0x46,0x49,0x46,0x00,0x01,0x01,0x00,0x00,0x01,0x00,0x01,0x00,0x00, + 0xff,0xdb,0x00,0x43,0x00,0x08,0x06,0x06,0x07,0x06,0x05,0x08,0x07,0x07,0x07,0x09,0x09,0x08,0x0a,0x0c, + 0x14,0x0d,0x0c,0x0b,0x0b,0x0c,0x19,0x12,0x13,0x0f,0x14,0x1d,0x1a,0x1f,0x1e,0x1d,0x1a,0x1c,0x1c,0x20, + 0x24,0x2e,0x27,0x20,0x22,0x2c,0x23,0x1c,0x1c,0x28,0x37,0x29,0x2c,0x30,0x31,0x34,0x34,0x34,0x1f,0x27, + 0x39,0x3d,0x38,0x32,0x3c,0x2e,0x33,0x34,0x32,0xff,0xc0,0x00,0x0b,0x08,0x00,0x01,0x00,0x01,0x01,0x01, + 0x11,0x00,0xff,0xc4,0x00,0x1f,0x00,0x00,0x01,0x05,0x01,0x01,0x01,0x01,0x01,0x01,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0a,0x0b,0xff,0xc4,0x00,0xb5,0x10, + 0x00,0x02,0x01,0x03,0x03,0x02,0x04,0x03,0x05,0x05,0x04,0x04,0x00,0x00,0x01,0x7d,0x01,0x02,0x03,0x00, + 0x04,0x11,0x05,0x12,0x21,0x31,0x41,0x06,0x13,0x51,0x61,0x07,0x22,0x71,0x14,0x32,0x81,0x91,0xa1,0x08, + 0x23,0x42,0xb1,0xc1,0x15,0x52,0xd1,0xf0,0x24,0x33,0x62,0x72,0x82,0x09,0x0a,0x16,0x17,0x18,0x19,0x1a, + 0x25,0x26,0x27,0x28,0x29,0x2a,0x34,0x35,0x36,0x37,0x38,0x39,0x3a,0x43,0x44,0x45,0x46,0x47,0x48,0x49, + 0x4a,0x53,0x54,0x55,0x56,0x57,0x58,0x59,0x5a,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x6a,0x73,0x74,0x75, + 0x76,0x77,0x78,0x79,0x7a,0x83,0x84,0x85,0x86,0x87,0x88,0x89,0x8a,0x92,0x93,0x94,0x95,0x96,0x97,0x98, + 0x99,0x9a,0xa2,0xa3,0xa4,0xa5,0xa6,0xa7,0xa8,0xa9,0xaa,0xb2,0xb3,0xb4,0xb5,0xb6,0xb7,0xb8,0xb9,0xba, + 0xc2,0xc3,0xc4,0xc5,0xc6,0xc7,0xc8,0xc9,0xca,0xd2,0xd3,0xd4,0xd5,0xd6,0xd7,0xd8,0xd9,0xda,0xe1,0xe2, + 0xe3,0xe4,0xe5,0xe6,0xe7,0xe8,0xe9,0xea,0xf1,0xf2,0xf3,0xf4,0xf5,0xf6,0xf7,0xf8,0xf9,0xfa,0xff,0xda, + 0x00,0x08,0x01,0x01,0x00,0x00,0x3f,0x00,0xfb,0xd5,0xdb,0x20,0xa8,0xf1,0x45,0x14,0x00,0xff,0xd9, + ]); + + // Define workspace structure: { path, content? } - undefined content = folder + const workspaceItems: Array<{ content?: string | Uint8Array; path: string }> = [ + { content: sequenceContent, path: 'seed_sequence.seq' }, + { content: textContent, path: 'seed_notes.txt' }, + { content: binaryContent, path: 'seed_data.bin' }, + { content: jpegContent, path: 'seed_image.jpg' }, + { content: jsonContent, path: 'seed_image.json' }, + { path: 'seed_folder' }, // folder + { content: sequenceContent, path: 'seed_folder/folder_sequence.seq' }, + { content: binaryContent, path: 'seed_folder/folder_data.bin' }, + { path: 'seed_folder/nested' }, // nested folder + { content: sequenceContent, path: 'seed_folder/nested/nested_sequence.seq' }, + ]; + + for (const item of workspaceItems) { + await api.createWorkspaceItem(workspaceId, item.path, item.content); + const isFolder = item.content === undefined; + console.log(` - Created ${item.path}${isFolder ? '/' : ''}`); + } + + // Create second workspace with thousands of files for performance testing + console.log('\nCreating large workspace...'); + const largeWorkspaceName = `Large Workspace (${uniqueSuffix})`; + const largeWorkspaceLocation = `large_workspace_${uniqueSuffix}`; + const largeWorkspaceId = await api.createWorkspace(largeWorkspaceLocation, parcel.id, largeWorkspaceName); + console.log(` - Created workspace: ${largeWorkspaceName} (ID: ${largeWorkspaceId})`); + + // Generate thousands of files with deep nesting + const projects = ['alpha', 'beta', 'gamma', 'delta', 'epsilon']; + const fileMap: Record | string> = { + '.bin': binaryContent, + '.json': jsonContent, + '.seq': sequenceContent, + '.txt': textContent, + }; + const fileTypes = Object.keys(fileMap); + let largeWorkspaceItemCount = 0; + + for (const project of projects) { + // Create project folder + await api.createWorkspaceItem(largeWorkspaceId, `project_${project}`); + largeWorkspaceItemCount++; + + for (let m = 1; m <= 10; m++) { + const modulePath = `project_${project}/module_${m.toString().padStart(2, '0')}`; + // Create module folder + await api.createWorkspaceItem(largeWorkspaceId, modulePath); + largeWorkspaceItemCount++; + + // Create regular files in module + for (let f = 1; f <= 15; f++) { + const ext = fileTypes[(f - 1) % fileTypes.length]; + const filePath = `${modulePath}/file_${f.toString().padStart(3, '0')}${ext}`; + const content = fileMap[ext]; + await api.createWorkspaceItem(largeWorkspaceId, filePath, content); + largeWorkspaceItemCount++; + } + + // Add deep nesting for every other module (up to 7 levels deep) + if (m % 2 === 1) { + const depths = ['level_1', 'level_2', 'level_3', 'level_4', 'level_5']; + let currentPath = modulePath; + for (const depth of depths) { + currentPath = `${currentPath}/${depth}`; + await api.createWorkspaceItem(largeWorkspaceId, currentPath); + largeWorkspaceItemCount++; + // Add a few files at each level + for (let f = 1; f <= 3; f++) { + const ext = fileTypes[(f - 1) % fileTypes.length]; + const content = fileMap[ext]; + await api.createWorkspaceItem(largeWorkspaceId, `${currentPath}/nested_${f}${ext}`, content); + largeWorkspaceItemCount++; + } + } + } + } + console.log(` - Created project_${project}/ with nested folders`); + } + console.log(` - Total: ${largeWorkspaceItemCount} items`); + + // Print summary + console.log('\n========================================'); + console.log('Seed Complete!'); + console.log('========================================\n'); + console.log(`Unique suffix: ${uniqueSuffix}`); + console.log('Created resources:'); + console.log(` Model: ${model.id} (${modelName})`); + console.log(` Tags: ${createdTags.length}`); + for (const tag of createdTags) { + console.log(` - ${tag.name} (ID: ${tag.id})`); + } + console.log(` Plans: ${createdPlans.length}`); + for (const plan of createdPlans) { + console.log(` - ${plan.name} (ID: ${plan.id}, ${plan.activityCount} activities)`); + } + console.log(` Constraints: ${createdConstraints.length}`); + for (const constraint of createdConstraints) { + console.log(` - ${constraint.name} (ID: ${constraint.id})`); + } + console.log(` Scheduling Goals: ${createdGoals.length}`); + for (const goal of createdGoals) { + console.log(` - ${goal.name} (ID: ${goal.id})`); + } + console.log(` Scheduling Conditions: ${createdConditions.length}`); + for (const condition of createdConditions) { + console.log(` - ${condition.name} (ID: ${condition.id})`); + } + console.log(` Views: ${createdViews.length}`); + for (const view of createdViews) { + console.log(` - ${view.name} (ID: ${view.id})`); + } + console.log(` External Sources: 1 derivation group with ${EXTERNAL_EVENTS.length} events`); + console.log( + ` External Datasets: ${createdDatasets.length} (one per plan, each with ${profileNames.length} profiles)`, + ); + console.log(` Dictionaries (mission: ${missionName}):`); + console.log(` - Command Dictionary (ID: ${commandDictId})`); + console.log(` - Channel Dictionary (ID: ${channelDictId})`); + console.log(` - Parameter Dictionary (ID: ${paramDictId})`); + console.log(` Sequence Adaptation: ${adaptationName}`); + console.log(` Parcel: ${parcelName} (ID: ${parcel.id})`); + console.log(` Expansion Rules: ${createdExpansionRules.length}`); + for (const rule of createdExpansionRules) { + console.log(` - ${rule.name} (ID: ${rule.id})`); + } + console.log(` Expansion Set: ${expansionSetName} (ID: ${expansionSet.id})`); + console.log(` Workspaces: 2`); + console.log(` - ${workspaceName} (ID: ${workspaceId}, ${workspaceItems.length} items)`); + console.log(` - ${largeWorkspaceName} (ID: ${largeWorkspaceId}, ${largeWorkspaceItemCount} items)`); + console.log(` Action: ${actionName} (ID: ${action.id})`); + console.log(` Extension: ${extensionName} (ID: ${extension.id})`); + console.log('\nYou can now view these in the Aerie UI at http://localhost:3000'); +}); diff --git a/package.json b/package.json index 9939667369..3efc0bab81 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "preview": "vite preview --port 3000", "sync": "svelte-kit sync", "test": "vitest", + "deseed": "playwright test --project=deseed", + "seed": "playwright test --project=seed", "test:e2e": "playwright test", "test:e2e:clear-cache": "rm -rf .playwright", "test:e2e:codegen": "playwright codegen http://localhost:3000", diff --git a/playwright.config.ts b/playwright.config.ts index 04b2bb60ae..8b6a962319 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -41,7 +41,11 @@ const config: PlaywrightTestConfig = { name: 'e2e tests', teardown: 'teardown', testDir: './e2e-tests', - testIgnore: /.*\/sequence-templates\.test\.ts/, + testIgnore: [ + /.*\/sequence-templates\.test\.ts/, + /.*\/utilities\/seed\.test\.ts/, + /.*\/utilities\/deseed\.test\.ts/, + ], use: { baseURL: MAIN_TEST_SUITE_BASE_URL, storageState: STORAGE_STATE, @@ -62,6 +66,17 @@ const config: PlaywrightTestConfig = { name: 'teardown', testMatch: /global\.teardown\.ts/, }, + // Seed/deseed utilities - run explicitly with --project=seed or --project=deseed + { + name: 'seed', + testDir: './e2e-tests/utilities', + testMatch: /(? link.plan_id === plan?.id && !($derivationGroupVisibilityMap[link.derivation_group_name] ?? true), ) .map(link => link.derivation_group_name); - + // TODO seeing a bug here where if you load a plan from url with derivation groups associated and an external event row + // and then load a plan that doesn't have these and then go back to the first plan, the events don't show up / are filtered out for some reason... // Apply filter for hiding derivation groups let externalEventsFilteredByDG = externalEvents.filter(ee => { let derivationGroup = diff --git a/src/schemas/index.ts b/src/schemas/index.ts index f074b55d04..200c59b5fd 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -1,7 +1,7 @@ -import * as v0 from './ui-view-schema-v0.json'; -import * as v1 from './ui-view-schema-v1.json'; -import * as v2 from './ui-view-schema-v2.json'; -import * as v3 from './ui-view-schema-v3.json'; +import v0 from './ui-view-schema-v0.json' assert { type: 'json' }; +import v1 from './ui-view-schema-v1.json' assert { type: 'json' }; +import v2 from './ui-view-schema-v2.json' assert { type: 'json' }; +import v3 from './ui-view-schema-v3.json' assert { type: 'json' }; export default { v0, diff --git a/src/stores/external-source.ts b/src/stores/external-source.ts index 112054943c..780eb24647 100644 --- a/src/stores/external-source.ts +++ b/src/stores/external-source.ts @@ -96,6 +96,7 @@ export function resetExternalSourceStores(): void { createDerivationGroupError.set(null); derivationGroupPlanLinkError.set(null); planDerivationGroupLinks.updateValue(() => []); + derivationGroupVisibilityMap.set({}); } function transformDerivationGroups( diff --git a/src/types/simulation.ts b/src/types/simulation.ts index d559032bff..3b33e205cd 100644 --- a/src/types/simulation.ts +++ b/src/types/simulation.ts @@ -27,9 +27,33 @@ export type Profile = { }; }; +// External dataset types for api upload +export interface ExternalDatasetInput { + datasetStart: string; + profileSet: Record; +} + +export interface ExternalProfileInput { + schema: object; + segments: Array<{ + duration: number; + dynamics?: unknown; + }>; + type: 'discrete' | 'real'; +} + +// Dynamics for real profiles (linear interpolation) +export type RealDynamics = { + initial: number; + rate: number; +}; + +// Dynamics can be: simple value (discrete), RealDynamics (real), or null/missing (gap) +export type ProfileDynamics = RealDynamics | string | number | boolean | null; + export type ProfileSegment = { dataset_id: number; - dynamics: any; + dynamics?: ProfileDynamics; is_gap: boolean; profile_id: number; start_offset: string; diff --git a/src/utilities/resources.ts b/src/utilities/resources.ts index 103c3e23c8..d0f9f68a27 100644 --- a/src/utilities/resources.ts +++ b/src/utilities/resources.ts @@ -1,6 +1,20 @@ -import type { Profile, Resource, ResourceValue } from '../types/simulation'; +import type { Profile, RealDynamics, Resource, ResourceValue } from '../types/simulation'; import { getIntervalInMs } from './time'; +/** + * Type guard to check if dynamics represents a real profile (linear interpolation). + */ +function isRealDynamics(dynamics: unknown): dynamics is RealDynamics { + return ( + typeof dynamics === 'object' && + dynamics !== null && + 'initial' in dynamics && + 'rate' in dynamics && + typeof (dynamics as RealDynamics).initial === 'number' && + typeof (dynamics as RealDynamics).rate === 'number' + ); +} + /** * Samples a list of profiles at their change points. Converts the sampled profiles to Resources. */ @@ -30,29 +44,33 @@ export function sampleProfiles( const { dynamics, is_gap } = segment; - if (type === 'discrete') { - values.push({ - is_gap, - x: start + segmentOffset, - y: dynamics, - }); - values.push({ - is_gap, - x: start + nextSegmentOffset, - y: dynamics, - }); - } else if (type === 'real') { - values.push({ - is_gap, - x: start + segmentOffset, - y: dynamics.initial, - }); - values.push({ - is_gap, - x: start + nextSegmentOffset, - y: dynamics.initial + dynamics.rate * ((nextSegmentOffset - segmentOffset) / 1000), - }); + // Compute y values based on segment type + let startY: ResourceValue['y']; + let endY: ResourceValue['y']; + let segmentIsGap: boolean; + + if (is_gap || dynamics == null) { + segmentIsGap = true; + startY = null; + endY = null; + } else if (type === 'real' && isRealDynamics(dynamics)) { + segmentIsGap = false; + startY = dynamics.initial; + endY = dynamics.initial + dynamics.rate * ((nextSegmentOffset - segmentOffset) / 1000); + } else if (type === 'discrete') { + // Discrete - dynamics is the value itself + segmentIsGap = false; + startY = dynamics as ResourceValue['y']; + endY = dynamics as ResourceValue['y']; + } else { + // Type is not supported + continue; } + + values.push( + { is_gap: segmentIsGap, x: start + segmentOffset, y: startY }, + { is_gap: segmentIsGap, x: start + nextSegmentOffset, y: endY }, + ); } resources.push({ name, schema, values }); From 50b123ec010886e4308209d342e51be80b02ab7d Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Mon, 5 Jan 2026 12:18:38 -0800 Subject: [PATCH 2/9] Fix --- playwright.config.ts | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 8b6a962319..2e651aaa59 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -67,6 +67,7 @@ const config: PlaywrightTestConfig = { testMatch: /global\.teardown\.ts/, }, // Seed/deseed utilities - run explicitly with --project=seed or --project=deseed + // These don't need a web server since they only use the API directly { name: 'seed', testDir: './e2e-tests/utilities', @@ -96,17 +97,21 @@ const config: PlaywrightTestConfig = { trace: process.env.CI ? 'retain-on-failure' : 'off', video: process.env.CI ? 'retain-on-failure' : 'off', }, - webServer: [ - { - command: 'npm run preview', - port: 3000, - reuseExistingServer: !process.env.CI, - }, - { - command: 'PUBLIC_COMMAND_EXPANSION_MODE=templating npm run preview', - port: 3001, - }, - ], + webServer: + // Seed/deseed don't need a web server - check if we're running those projects + process.argv.includes('--project=seed') || process.argv.includes('--project=deseed') + ? undefined + : [ + { + command: 'npm run preview', + port: 3000, + reuseExistingServer: !process.env.CI, + }, + { + command: 'PUBLIC_COMMAND_EXPANSION_MODE=templating npm run preview', + port: 3001, + }, + ], }; export default config; From ddb5bd6cfabf9b3580b3ef365668d60628c4cb38 Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Tue, 27 Jan 2026 07:20:21 -0800 Subject: [PATCH 3/9] Use tsx instead of playwright to run seed/deseed. --- package.json | 4 +- playwright.config.ts | 44 +++++-------------- .../deseed.test.ts => scripts/deseed.ts | 15 ++++--- .../utilities/seed.test.ts => scripts/seed.ts | 34 ++++++++------ 4 files changed, 43 insertions(+), 54 deletions(-) rename e2e-tests/utilities/deseed.test.ts => scripts/deseed.ts (98%) rename e2e-tests/utilities/seed.test.ts => scripts/seed.ts (97%) diff --git a/package.json b/package.json index 3efc0bab81..647453cc57 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "preview": "vite preview --port 3000", "sync": "svelte-kit sync", "test": "vitest", - "deseed": "playwright test --project=deseed", - "seed": "playwright test --project=seed", + "deseed": "npx tsx scripts/deseed.ts", + "seed": "npx tsx scripts/seed.ts", "test:e2e": "playwright test", "test:e2e:clear-cache": "rm -rf .playwright", "test:e2e:codegen": "playwright codegen http://localhost:3000", diff --git a/playwright.config.ts b/playwright.config.ts index 2e651aaa59..00c8aa3d0c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -41,11 +41,7 @@ const config: PlaywrightTestConfig = { name: 'e2e tests', teardown: 'teardown', testDir: './e2e-tests', - testIgnore: [ - /.*\/sequence-templates\.test\.ts/, - /.*\/utilities\/seed\.test\.ts/, - /.*\/utilities\/deseed\.test\.ts/, - ], + testIgnore: [/.*\/sequence-templates\.test\.ts/], use: { baseURL: MAIN_TEST_SUITE_BASE_URL, storageState: STORAGE_STATE, @@ -66,18 +62,6 @@ const config: PlaywrightTestConfig = { name: 'teardown', testMatch: /global\.teardown\.ts/, }, - // Seed/deseed utilities - run explicitly with --project=seed or --project=deseed - // These don't need a web server since they only use the API directly - { - name: 'seed', - testDir: './e2e-tests/utilities', - testMatch: /(? name.includes(' (') && name.includes(')'); @@ -23,7 +20,7 @@ const SEED_EXTERNAL_EVENT_TYPE_PREFIX = 'BananaDelivery_'; // Dictionary mission name prefix from seed script const SEED_DICTIONARY_MISSION_PREFIX = 'Seed_'; -test('remove all seeded Aerie data', async () => { +async function deseed() { console.log('Starting Aerie de-seed...\n'); const api = new AerieApi(); @@ -499,4 +496,10 @@ test('remove all seeded Aerie data', async () => { console.log(` Plans: ${seededPlans.length}`); console.log(` Models: ${seededModels.length}`); console.log(` Tags: ${seededTags.length}`); +} + +// Run the deseed script +deseed().catch(error => { + console.error('De-seed failed:', error); + process.exit(1); }); diff --git a/e2e-tests/utilities/seed.test.ts b/scripts/seed.ts similarity index 97% rename from e2e-tests/utilities/seed.test.ts rename to scripts/seed.ts index c0196495ad..5838ea1d36 100644 --- a/e2e-tests/utilities/seed.test.ts +++ b/scripts/seed.ts @@ -1,3 +1,4 @@ +#!/usr/bin/env npx tsx /** * Aerie Seed Script * @@ -26,20 +27,16 @@ * - 1 extension (demo plan analyzer, requires local extension server) */ -import { test } from '@playwright/test'; import fs from 'fs'; import { animals, uniqueNamesGenerator } from 'unique-names-generator'; -import { ConstraintDefinitionType } from '../../src/enums/constraint.js'; -import { SchedulingDefinitionType } from '../../src/enums/scheduling.js'; -import type { ActivityDirectiveInsertInput } from '../../src/types/activity.js'; -import type { SchedulingConditionInsertInput } from '../../src/types/scheduling.js'; -import { ResourceType } from '../../src/types/simulation.js'; -import { getIntervalFromDoyRange, getUnixEpochTime } from '../../src/utilities/time.js'; -import { generateDefaultView } from '../../src/utilities/view.js'; -import { AerieApi } from './api.js'; - -// Run with single worker and no retries -test.describe.configure({ mode: 'serial', retries: 0, timeout: 300000 }); +import { ConstraintDefinitionType } from '../src/enums/constraint.js'; +import { SchedulingDefinitionType } from '../src/enums/scheduling.js'; +import type { ActivityDirectiveInsertInput } from '../src/types/activity.js'; +import type { SchedulingConditionInsertInput } from '../src/types/scheduling.js'; +import { ResourceType } from '../src/types/simulation.js'; +import { getIntervalFromDoyRange, getUnixEpochTime } from '../src/utilities/time.js'; +import { generateDefaultView } from '../src/utilities/view.js'; +import { AerieApi } from '../e2e-tests/utilities/api.js'; // Generate unique suffix for this seed run const uniqueSuffix = uniqueNamesGenerator({ dictionaries: [animals], separator: '-' }); @@ -309,7 +306,10 @@ const MINUTE_US = 60_000_000; // Generate external dataset profiles with semi-random data and gaps function generateExternalDataset(startTime: string, durationHours: number) { - const segmentMinutes = 10; + // Scale segment duration based on plan length to keep total segments reasonable + // Target ~150 segments per profile regardless of plan duration + const targetSegments = 150; + const segmentMinutes = Math.max(10, Math.ceil((durationHours * 60) / targetSegments)); const segmentCount = Math.floor((durationHours * 60) / segmentMinutes); const ripenessStates = ['green', 'yellow-green', 'yellow', 'spotted', 'brown', 'overripe']; @@ -374,7 +374,7 @@ function generateExternalDataset(startTime: string, durationHours: number) { }; } -test('seed Aerie with sample data', async () => { +async function seed() { console.log(`Starting Aerie seed (${uniqueSuffix})...\n`); const api = new AerieApi(); @@ -996,4 +996,10 @@ test('seed Aerie with sample data', async () => { console.log(` Action: ${actionName} (ID: ${action.id})`); console.log(` Extension: ${extensionName} (ID: ${extension.id})`); console.log('\nYou can now view these in the Aerie UI at http://localhost:3000'); +} + +// Run the seed script +seed().catch(error => { + console.error('Seed failed:', error); + process.exit(1); }); From a7e21faf644101346cf13106b1761f80af148467 Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Thu, 29 Jan 2026 07:24:41 -0800 Subject: [PATCH 4/9] Remove comment --- src/components/timeline/Row.svelte | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/timeline/Row.svelte b/src/components/timeline/Row.svelte index e72edb75e3..df455eae09 100644 --- a/src/components/timeline/Row.svelte +++ b/src/components/timeline/Row.svelte @@ -524,8 +524,6 @@ link => link.plan_id === plan?.id && !($derivationGroupVisibilityMap[link.derivation_group_name] ?? true), ) .map(link => link.derivation_group_name); - // TODO seeing a bug here where if you load a plan from url with derivation groups associated and an external event row - // and then load a plan that doesn't have these and then go back to the first plan, the events don't show up / are filtered out for some reason... // Apply filter for hiding derivation groups let externalEventsFilteredByDG = externalEvents.filter(ee => { let derivationGroup = From 0c1c972e21e3e0d7194d9bcb069b685ebd10c93b Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Thu, 29 Jan 2026 07:26:46 -0800 Subject: [PATCH 5/9] Clean --- playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index 00c8aa3d0c..04b2bb60ae 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -41,7 +41,7 @@ const config: PlaywrightTestConfig = { name: 'e2e tests', teardown: 'teardown', testDir: './e2e-tests', - testIgnore: [/.*\/sequence-templates\.test\.ts/], + testIgnore: /.*\/sequence-templates\.test\.ts/, use: { baseURL: MAIN_TEST_SUITE_BASE_URL, storageState: STORAGE_STATE, From c13784f1f5262641ce24cff5becedfc879e65e2d Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Fri, 27 Feb 2026 08:16:30 -0800 Subject: [PATCH 6/9] Revert change --- src/schemas/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 200c59b5fd..52db5b6fdd 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -1,7 +1,7 @@ -import v0 from './ui-view-schema-v0.json' assert { type: 'json' }; -import v1 from './ui-view-schema-v1.json' assert { type: 'json' }; -import v2 from './ui-view-schema-v2.json' assert { type: 'json' }; -import v3 from './ui-view-schema-v3.json' assert { type: 'json' }; +import * as v0 from './ui-view-schema-v0.json' assert { type: 'json' }; +import * as v1 from './ui-view-schema-v1.json' assert { type: 'json' }; +import * as v2 from './ui-view-schema-v2.json' assert { type: 'json' }; +import * as v3 from './ui-view-schema-v3.json' assert { type: 'json' }; export default { v0, From ff1e7d7c5d19b0900e2cd1a1c89713e76dd1db32 Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Fri, 27 Feb 2026 09:22:47 -0800 Subject: [PATCH 7/9] Fixes and refactoring --- e2e-tests/utilities/api.ts | 20 ++------------ package.json | 1 + scripts/deseed.ts | 42 ++++++++++++++-------------- scripts/seed.ts | 44 ++++++++++++++++++------------ src/components/timeline/Row.svelte | 1 + 5 files changed, 53 insertions(+), 55 deletions(-) diff --git a/e2e-tests/utilities/api.ts b/e2e-tests/utilities/api.ts index 8fc9f2d94f..5ffbe5ba1d 100644 --- a/e2e-tests/utilities/api.ts +++ b/e2e-tests/utilities/api.ts @@ -8,6 +8,7 @@ */ import type { Browser, BrowserContext, Page } from '@playwright/test'; +import dotenv from 'dotenv'; import fs from 'fs'; import nodePath from 'path'; import { adjectives, animals, colors, uniqueNamesGenerator } from 'unique-names-generator'; @@ -45,23 +46,8 @@ import { SchedulingConditions } from '../fixtures/SchedulingConditions.js'; import { SchedulingGoals } from '../fixtures/SchedulingGoals.js'; import { View } from '../fixtures/View.js'; -// Load .env file if it exists (Node.js doesn't load it automatically) -const envPath = nodePath.resolve(process.cwd(), '.env'); -if (fs.existsSync(envPath)) { - for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) { - const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith('#')) { - const eqIndex = trimmed.indexOf('='); - if (eqIndex > 0) { - const key = trimmed.slice(0, eqIndex); - const value = trimmed.slice(eqIndex + 1).replace(/^['"]|['"]$/g, ''); - if (!process.env[key]) { - process.env[key] = value; - } - } - } - } -} +// Load .env file if it exists (won't override existing env vars) +dotenv.config(); // Default URLs from environment variables, with fallbacks for local development const DEFAULT_HASURA_URL = process.env.PUBLIC_HASURA_CLIENT_URL ?? 'http://localhost:8080/v1/graphql'; diff --git a/package.json b/package.json index 647453cc57..95eec23442 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "@vitest/ui": "^1.4.0", "autoprefixer": "^10.4.20", "cloc": "2.0.0-cloc", + "dotenv": "^17.3.1", "esbuild": "^0.24.0", "eslint": "^8.43.0", "eslint-config-prettier": "^9.1.0", diff --git a/scripts/deseed.ts b/scripts/deseed.ts index 7b4631b1a0..c1da27300f 100644 --- a/scripts/deseed.ts +++ b/scripts/deseed.ts @@ -11,8 +11,8 @@ import { AerieApi } from '../e2e-tests/utilities/api.js'; -// Pattern to identify seeded items: name contains " (" indicating unique suffix -const isSeedItem = (name: string): boolean => name.includes(' (') && name.includes(')'); +// Pattern to identify seeded items: name ends with " (animal-suffix)" from unique-names-generator +const isSeedItem = (name: string): boolean => /\(\w+\)$/.test(name); // External type name prefixes from seed script (actual names have _suffix appended) const SEED_EXTERNAL_SOURCE_TYPE_PREFIX = 'BananaSupplySource_'; @@ -160,7 +160,7 @@ async function deseed() { // Delete in reverse order of creation to respect foreign keys - // 15. Delete plans (they reference models) + // 1. Delete plans (they reference models) if (seededPlans.length > 0) { console.log('Deleting plans...'); for (const plan of seededPlans) { @@ -174,7 +174,7 @@ async function deseed() { console.log(''); } - // 1. Delete models + // 2. Delete models if (seededModels.length > 0) { console.log('Deleting models...'); for (const model of seededModels) { @@ -188,7 +188,7 @@ async function deseed() { console.log(''); } - // 2. Delete external sources first (they contain events and reference derivation groups) + // 3. Delete external sources (they contain events and reference derivation groups) if (seededExternalSources.length > 0) { console.log('Deleting external sources...'); // Group sources by derivation group for deletion @@ -211,7 +211,7 @@ async function deseed() { console.log(''); } - // 3. Delete derivation groups + // 4. Delete derivation groups if (seededDerivationGroups.length > 0) { console.log('Deleting derivation groups...'); try { @@ -226,7 +226,7 @@ async function deseed() { console.log(''); } - // 4. Delete external event types (after events are deleted with sources) + // 5. Delete external event types (after events are deleted with sources) if (seededEventTypes.length > 0) { console.log('Deleting external event types...'); try { @@ -241,7 +241,7 @@ async function deseed() { console.log(''); } - // 5. Delete external source types (after derivation groups are deleted) + // 6. Delete external source types (after derivation groups are deleted) if (seededSourceTypes.length > 0) { console.log('Deleting external source types...'); try { @@ -256,7 +256,7 @@ async function deseed() { console.log(''); } - // 6. Delete action definitions (they reference workspaces) + // 7. Delete action definitions (they reference workspaces) if (seededActionDefinitions.length > 0) { console.log('Deleting action definitions...'); for (const action of seededActionDefinitions) { @@ -270,7 +270,7 @@ async function deseed() { console.log(''); } - // 7. Delete extensions (no foreign key dependencies) + // 8. Delete extensions (no foreign key dependencies) if (seededExtensions.length > 0) { console.log('Deleting extensions...'); for (const ext of seededExtensions) { @@ -284,7 +284,7 @@ async function deseed() { console.log(''); } - // 8. Delete workspaces (they reference parcels) + // 9. Delete workspaces (they reference parcels) if (seededWorkspaces.length > 0) { console.log('Deleting workspaces...'); for (const workspace of seededWorkspaces) { @@ -298,7 +298,7 @@ async function deseed() { console.log(''); } - // 9. Delete expansion sets (they reference expansion rules) + // 10. Delete expansion sets (they reference expansion rules) if (seededExpansionSets.length > 0) { console.log('Deleting expansion sets...'); for (const set of seededExpansionSets) { @@ -312,7 +312,7 @@ async function deseed() { console.log(''); } - // 10. Delete expansion rules (they reference parcels) + // 11. Delete expansion rules (they reference parcels) if (seededExpansionRules.length > 0) { console.log('Deleting expansion rules...'); for (const rule of seededExpansionRules) { @@ -326,7 +326,7 @@ async function deseed() { console.log(''); } - // 11. Delete parcels (they reference dictionaries and expansion rules reference them) + // 12. Delete parcels (they reference dictionaries and expansion rules reference them) if (seededParcels.length > 0) { console.log('Deleting parcels...'); for (const parcel of seededParcels) { @@ -340,7 +340,7 @@ async function deseed() { console.log(''); } - // 12. Delete dictionaries (after parcels since parcels reference them) + // 13. Delete dictionaries (after parcels since parcels reference them) if (seededCommandDicts.length > 0) { console.log('Deleting command dictionaries...'); for (const dict of seededCommandDicts) { @@ -386,7 +386,7 @@ async function deseed() { console.log(''); } - // 13. Delete sequence adaptations + // 14. Delete sequence adaptations if (seededAdaptations.length > 0) { console.log('Deleting sequence adaptations...'); for (const adaptation of seededAdaptations) { @@ -400,7 +400,7 @@ async function deseed() { console.log(''); } - // 14. Delete views + // 15. Delete views if (seededViews.length > 0) { console.log('Deleting views...'); for (const view of seededViews) { @@ -414,7 +414,7 @@ async function deseed() { console.log(''); } - // 15. Delete scheduling conditions + // 16. Delete scheduling conditions if (seededConditions.length > 0) { console.log('Deleting scheduling conditions...'); for (const condition of seededConditions) { @@ -428,7 +428,7 @@ async function deseed() { console.log(''); } - // 16. Delete scheduling goals + // 17. Delete scheduling goals if (seededGoals.length > 0) { console.log('Deleting scheduling goals...'); for (const goal of seededGoals) { @@ -442,7 +442,7 @@ async function deseed() { console.log(''); } - // 17. Delete constraints + // 18. Delete constraints if (seededConstraints.length > 0) { console.log('Deleting constraints...'); for (const constraint of seededConstraints) { @@ -456,7 +456,7 @@ async function deseed() { console.log(''); } - // 18. Delete tags + // 19. Delete tags if (seededTags.length > 0) { console.log('Deleting tags...'); for (const tag of seededTags) { diff --git a/scripts/seed.ts b/scripts/seed.ts index 5838ea1d36..43db46434e 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -29,6 +29,7 @@ import fs from 'fs'; import { animals, uniqueNamesGenerator } from 'unique-names-generator'; +import { AerieApi } from '../e2e-tests/utilities/api.js'; import { ConstraintDefinitionType } from '../src/enums/constraint.js'; import { SchedulingDefinitionType } from '../src/enums/scheduling.js'; import type { ActivityDirectiveInsertInput } from '../src/types/activity.js'; @@ -36,7 +37,6 @@ import type { SchedulingConditionInsertInput } from '../src/types/scheduling.js' import { ResourceType } from '../src/types/simulation.js'; import { getIntervalFromDoyRange, getUnixEpochTime } from '../src/utilities/time.js'; import { generateDefaultView } from '../src/utilities/view.js'; -import { AerieApi } from '../e2e-tests/utilities/api.js'; // Generate unique suffix for this seed run const uniqueSuffix = uniqueNamesGenerator({ dictionaries: [animals], separator: '-' }); @@ -189,14 +189,14 @@ const PLANS: PlanConfig[] = [ name: 'Monthly Production', startTime: '2024-001T00:00:00', }, - // { - // // Quarter-long mission (10K activities) - // activityMix: { BiteBanana: 2500, GrowBanana: 2000, PeelBanana: 2500, PickBanana: 3000 }, - // description: 'Full quarterly mission with comprehensive operations', - // endTime: '2024-091T00:00:00', - // name: 'Q1 Mission', - // startTime: '2024-001T00:00:00', - // }, + { + // Quarter-long mission (10K activities) + activityMix: { BiteBanana: 2500, GrowBanana: 2000, PeelBanana: 2500, PickBanana: 3000 }, + description: 'Full quarterly mission with comprehensive operations', + endTime: '2024-091T00:00:00', + name: 'Q1 Mission', + startTime: '2024-001T00:00:00', + }, ]; // Constraint definitions (EDSL format) @@ -693,6 +693,9 @@ async function seed() { .replace('mission_name="GENERIC"', `mission_name="${missionName}"`); const commandDictResult = await api.createDictionary(commandDictXml); const commandDictId = commandDictResult.command?.id; + if (commandDictId == null) { + throw new Error('Command dictionary creation failed: no command ID returned'); + } console.log(` - Created command dictionary (ID: ${commandDictId}, mission: ${missionName})`); // Read and upload channel dictionary with customized mission name @@ -724,7 +727,7 @@ async function seed() { const parcelName = `Seed Parcel (${uniqueSuffix})`; const parcel = await api.createParcel({ channel_dictionary_id: channelDictId ?? null, - command_dictionary_id: commandDictId!, + command_dictionary_id: commandDictId, name: parcelName, sequence_adaptation_id: null, // Adaptations are linked separately }); @@ -840,7 +843,7 @@ async function seed() { // Reusable content const sequenceContent = `@ID "seed_sequence"\n\nC FSW_CMD_0 "ON" true 0.5\nC FSW_CMD_1 1.0 10 "2024-001T00:00:00" 100 "0000"\n`; const textContent = `Seed Workspace Notes\n====================\n\nCreated by seed script. Suffix: ${uniqueSuffix}\n`; - const jsonContent = `{ seeded_json: ${uniqueSuffix} }`; + const jsonContent = JSON.stringify({ seeded_json: uniqueSuffix }, null, 2); const binaryContent = new Uint8Array(256).map(() => Math.floor(Math.random() * 256)); // Minimal valid JPEG (1x1 pixel) // prettier-ignore @@ -870,7 +873,7 @@ async function seed() { { content: textContent, path: 'seed_notes.txt' }, { content: binaryContent, path: 'seed_data.bin' }, { content: jpegContent, path: 'seed_image.jpg' }, - { content: jsonContent, path: 'seed_image.json' }, + { content: jsonContent, path: 'seed_data.json' }, { path: 'seed_folder' }, // folder { content: sequenceContent, path: 'seed_folder/folder_sequence.seq' }, { content: binaryContent, path: 'seed_folder/folder_data.bin' }, @@ -903,7 +906,7 @@ async function seed() { let largeWorkspaceItemCount = 0; for (const project of projects) { - // Create project folder + // Create project folder (must exist before children) await api.createWorkspaceItem(largeWorkspaceId, `project_${project}`); largeWorkspaceItemCount++; @@ -913,14 +916,16 @@ async function seed() { await api.createWorkspaceItem(largeWorkspaceId, modulePath); largeWorkspaceItemCount++; - // Create regular files in module + // Create regular files in module in parallel + const filePromises: Promise[] = []; for (let f = 1; f <= 15; f++) { const ext = fileTypes[(f - 1) % fileTypes.length]; const filePath = `${modulePath}/file_${f.toString().padStart(3, '0')}${ext}`; const content = fileMap[ext]; - await api.createWorkspaceItem(largeWorkspaceId, filePath, content); + filePromises.push(api.createWorkspaceItem(largeWorkspaceId, filePath, content)); largeWorkspaceItemCount++; } + await Promise.all(filePromises); // Add deep nesting for every other module (up to 7 levels deep) if (m % 2 === 1) { @@ -928,15 +933,20 @@ async function seed() { let currentPath = modulePath; for (const depth of depths) { currentPath = `${currentPath}/${depth}`; + // Create nested folder (must exist before children) await api.createWorkspaceItem(largeWorkspaceId, currentPath); largeWorkspaceItemCount++; - // Add a few files at each level + // Create files at this level in parallel + const nestedFilePromises: Promise[] = []; for (let f = 1; f <= 3; f++) { const ext = fileTypes[(f - 1) % fileTypes.length]; const content = fileMap[ext]; - await api.createWorkspaceItem(largeWorkspaceId, `${currentPath}/nested_${f}${ext}`, content); + nestedFilePromises.push( + api.createWorkspaceItem(largeWorkspaceId, `${currentPath}/nested_${f}${ext}`, content), + ); largeWorkspaceItemCount++; } + await Promise.all(nestedFilePromises); } } } diff --git a/src/components/timeline/Row.svelte b/src/components/timeline/Row.svelte index df455eae09..4c8ab3eac7 100644 --- a/src/components/timeline/Row.svelte +++ b/src/components/timeline/Row.svelte @@ -524,6 +524,7 @@ link => link.plan_id === plan?.id && !($derivationGroupVisibilityMap[link.derivation_group_name] ?? true), ) .map(link => link.derivation_group_name); + // Apply filter for hiding derivation groups let externalEventsFilteredByDG = externalEvents.filter(ee => { let derivationGroup = From 32fef9ddb8f0fd820a6d55056555c97c89b3e715 Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Fri, 27 Feb 2026 10:07:10 -0800 Subject: [PATCH 8/9] Update package-lock --- package-lock.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/package-lock.json b/package-lock.json index 5e4b8f2d1c..e01f033d4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,6 +91,7 @@ "@vitest/ui": "^1.4.0", "autoprefixer": "^10.4.20", "cloc": "2.0.0-cloc", + "dotenv": "^17.3.1", "esbuild": "^0.24.0", "eslint": "^8.43.0", "eslint-config-prettier": "^9.1.0", @@ -4377,6 +4378,19 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", From 39d2bd8473cd911bf6708c06c70bf5b09e251889 Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Wed, 27 May 2026 16:57:25 -0700 Subject: [PATCH 9/9] fix: seed/deseed compatibility with action versioning + bullet marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createActionDefinition: action_file_id moved from action_definition to action_definition_version (nested versions insert pattern) - Add `•` marker to seeded item names; tighten deseed regex so a generic `(word)` suffix no longer false-matches organic names - Identify seeded derivation groups by ASCII source_type_name instead of name to avoid Hasura's `_in` no-op behavior on non-ASCII strings --- e2e-tests/utilities/api.ts | 4 +- scripts/deseed.ts | 13 +++++-- scripts/seed.ts | 80 ++++++++++++++++++++++++++++---------- 3 files changed, 71 insertions(+), 26 deletions(-) diff --git a/e2e-tests/utilities/api.ts b/e2e-tests/utilities/api.ts index 5ffbe5ba1d..25435c0b7e 100644 --- a/e2e-tests/utilities/api.ts +++ b/e2e-tests/utilities/api.ts @@ -139,12 +139,12 @@ export class AerieApi { // Upload the action file first const actionFileId = await this.uploadFile(actionFilePath); - // Create the action definition + // Create the action definition with an initial version (revision 0) const data = await this.gqlQuery<{ insert_action_definition_one: { id: number } }>(gql.CREATE_ACTION_DEFINITION, { actionDefinitionInsertInput: { - action_file_id: actionFileId, description, name, + versions: { data: [{ action_file_id: actionFileId }] }, workspace_id: workspaceId, }, }); diff --git a/scripts/deseed.ts b/scripts/deseed.ts index c1da27300f..ac04bb3704 100644 --- a/scripts/deseed.ts +++ b/scripts/deseed.ts @@ -11,8 +11,10 @@ import { AerieApi } from '../e2e-tests/utilities/api.js'; -// Pattern to identify seeded items: name ends with " (animal-suffix)" from unique-names-generator -const isSeedItem = (name: string): boolean => /\(\w+\)$/.test(name); +// Pattern to identify seeded items: name ends with " • animal-suffix". +// The bullet sentinel is paired with the seed script and is vanishingly +// unlikely to collide with organically created plan/model/etc. names. +const isSeedItem = (name: string): boolean => / • [a-z][a-z-]*$/.test(name); // External type name prefixes from seed script (actual names have _suffix appended) const SEED_EXTERNAL_SOURCE_TYPE_PREFIX = 'BananaSupplySource_'; @@ -86,7 +88,12 @@ async function deseed() { const seededGoals = schedulingGoals.filter(g => isSeedItem(g.name)); const seededConditions = schedulingConditions.filter(c => isSeedItem(c.name)); const seededViews = views.filter(v => isSeedItem(v.name)); - const seededDerivationGroups = derivationGroups.filter(dg => isSeedItem(dg.name)); + // Derivation groups are identified by their ASCII source_type_name (the seed + // intentionally omits the unicode marker from the group's name so the bulk + // `_in`-based delete mutation works — see seed.ts for details). + const seededDerivationGroups = derivationGroups.filter(dg => + dg.source_type_name.startsWith(SEED_EXTERNAL_SOURCE_TYPE_PREFIX), + ); // External sources belong to seeded derivation groups const seededDerivationGroupNames = new Set(seededDerivationGroups.map(dg => dg.name)); const seededExternalSources = externalSources.filter(s => seededDerivationGroupNames.has(s.derivation_group_name)); diff --git a/scripts/seed.ts b/scripts/seed.ts index 43db46434e..1658b9f8bc 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -38,9 +38,19 @@ import { ResourceType } from '../src/types/simulation.js'; import { getIntervalFromDoyRange, getUnixEpochTime } from '../src/utilities/time.js'; import { generateDefaultView } from '../src/utilities/view.js'; +// Seed marker — embedded in human-readable names so deseed can identify +// seeded items unambiguously (a plain "(word)" suffix collides with organic names). +// The bullet acts as both visual separator and the seed sentinel. +const SEED_MARKER = '•'; + // Generate unique suffix for this seed run const uniqueSuffix = uniqueNamesGenerator({ dictionaries: [animals], separator: '-' }); +// Suffix appended to human-readable names as `Name • animal`. ASCII-only +// `uniqueSuffix` is still used for filesystem paths, dictionary mission names, +// and external type names. +const seedNameSuffix = `${SEED_MARKER} ${uniqueSuffix}`; + // Banana-themed tags with colors const TAGS = [ { color: '#fbbf24', name: 'Ripe' }, @@ -374,8 +384,14 @@ function generateExternalDataset(startTime: string, durationHours: number) { }; } +function fmtMs(ms: number): string { + if (ms < 1000) return `${ms.toFixed(0)}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + async function seed() { - console.log(`Starting Aerie seed (${uniqueSuffix})...\n`); + const seedStart = performance.now(); + console.log(`Starting Aerie seed ${seedNameSuffix}...\n`); const api = new AerieApi(); @@ -390,7 +406,7 @@ async function seed() { console.log(`JAR uploaded with ID: ${jarId}\n`); // Create model - const modelName = `Banananation (${uniqueSuffix})`; + const modelName = `Banananation ${seedNameSuffix}`; console.log('Creating model...'); const model = await api.createModel({ description: 'Seeded model for development and testing', @@ -405,7 +421,7 @@ async function seed() { console.log('Creating tags...'); const createdTags: Array<{ id: number; name: string }> = []; for (const tag of TAGS) { - const tagName = `${tag.name} (${uniqueSuffix})`; + const tagName = `${tag.name} ${seedNameSuffix}`; const created = await api.createTag(tagName, tag.color); createdTags.push({ id: created.id, name: tagName }); console.log(` - Created tag: ${tagName} (ID: ${created.id})`); @@ -424,7 +440,7 @@ async function seed() { for (const planConfig of PLANS) { // Create plan with unique name - const planName = `${planConfig.name} (${uniqueSuffix})`; + const planName = `${planConfig.name} ${seedNameSuffix}`; const plan = await api.createPlan({ duration: getIntervalFromDoyRange(planConfig.startTime, planConfig.endTime), model_id: model.id, @@ -468,12 +484,27 @@ async function seed() { // Bulk insert in batches of 1000 const BATCH_SIZE = 1000; + const planInsertStart = performance.now(); + const batchTimings: number[] = []; for (let i = 0; i < activities.length; i += BATCH_SIZE) { const batch = activities.slice(i, i + BATCH_SIZE); + const batchStart = performance.now(); await api.createActivityDirectives(batch); - console.log(` Progress: ${Math.min(i + BATCH_SIZE, totalActivities)}/${totalActivities}`); + const batchMs = performance.now() - batchStart; + batchTimings.push(batchMs); + const done = Math.min(i + BATCH_SIZE, totalActivities); + const rate = Math.round(batch.length / (batchMs / 1000)); + console.log(` Progress: ${done}/${totalActivities} (batch=${fmtMs(batchMs)}, ${rate} rows/s)`); } - console.log(` Created ${totalActivities} activities`); + const planInsertMs = performance.now() - planInsertStart; + const overallRate = Math.round(totalActivities / (planInsertMs / 1000)); + const avgBatch = batchTimings.reduce((a, b) => a + b, 0) / batchTimings.length; + const minBatch = Math.min(...batchTimings); + const maxBatch = Math.max(...batchTimings); + console.log( + ` Created ${totalActivities} activities in ${fmtMs(planInsertMs)} (${overallRate} rows/s, ` + + `batch avg=${fmtMs(avgBatch)} min=${fmtMs(minBatch)} max=${fmtMs(maxBatch)})`, + ); const durationHours = planDurationMinutes / 60; createdPlans.push({ @@ -489,7 +520,7 @@ async function seed() { console.log('\nCreating constraints...'); const createdConstraints: Array<{ id: number; name: string }> = []; for (const constraint of CONSTRAINTS) { - const constraintName = `${constraint.name} (${uniqueSuffix})`; + const constraintName = `${constraint.name} ${seedNameSuffix}`; const created = await api.createConstraint({ description: constraint.description, name: constraintName, @@ -514,7 +545,7 @@ async function seed() { console.log('\nCreating scheduling goals...'); const createdGoals: Array<{ id: number; name: string }> = []; for (const goal of SCHEDULING_GOALS) { - const goalName = `${goal.name} (${uniqueSuffix})`; + const goalName = `${goal.name} ${seedNameSuffix}`; const created = await api.createSchedulingGoal({ description: goal.description, name: goalName, @@ -539,7 +570,7 @@ async function seed() { console.log('\nCreating scheduling conditions...'); const createdConditions: Array<{ id: number; name: string }> = []; for (const condition of SCHEDULING_CONDITIONS) { - const conditionName = `${condition.name} (${uniqueSuffix})`; + const conditionName = `${condition.name} ${seedNameSuffix}`; const created = await api.createSchedulingCondition({ description: condition.description, name: conditionName, @@ -580,7 +611,11 @@ async function seed() { // Create external source and event types first (needed for views) console.log('\nCreating external source types...'); - const derivationGroupName = `Banana Supply (${uniqueSuffix})`; + // Derivation group name is intentionally ASCII-only (no marker): we identify + // seeded ones in deseed via their ASCII source_type_name, which lets the + // bulk `_in`-based delete mutation work (Hasura's `_in` silently no-ops on + // strings containing non-ASCII characters). + const derivationGroupName = `Banana Supply ${uniqueSuffix}`; const sourceTypeName = `${EXTERNAL_SOURCE_TYPE}_${uniqueSuffix.replace(/-/g, '_')}`; const eventTypeName = `${EXTERNAL_EVENT_TYPE}_${uniqueSuffix.replace(/-/g, '_')}`; const eventTypeSchema = { @@ -663,7 +698,7 @@ async function seed() { const createdViews: Array<{ id: number; name: string }> = []; for (const viewConfig of VIEW_CONFIGS) { - const viewName = `${viewConfig.name} (${uniqueSuffix})`; + const viewName = `${viewConfig.name} ${seedNameSuffix}`; const defaultView = generateDefaultView(allResourceTypes, externalEventTypes); Object.assign(defaultView.definition.plan.grid, viewConfig.grid); const created = await api.createView({ @@ -716,7 +751,7 @@ async function seed() { // Read and upload sequence adaptation const adaptationCode = fs.readFileSync('e2e-tests/data/sequence-adaptation.js', 'utf-8'); - const adaptationName = `Seed Adaptation (${uniqueSuffix})`; + const adaptationName = `Seed Adaptation ${seedNameSuffix}`; const adaptationResult = await api.createSequenceAdaptation({ adaptation: adaptationCode, name: adaptationName, @@ -724,7 +759,7 @@ async function seed() { console.log(` - Created sequence adaptation: ${adaptationResult.name}`); // Create parcel bundling the dictionaries - const parcelName = `Seed Parcel (${uniqueSuffix})`; + const parcelName = `Seed Parcel ${seedNameSuffix}`; const parcel = await api.createParcel({ channel_dictionary_id: channelDictId ?? null, command_dictionary_id: commandDictId, @@ -748,7 +783,7 @@ async function seed() { }) ]; }`, - name: `BiteBanana Expansion (${uniqueSuffix})`, + name: `BiteBanana Expansion ${seedNameSuffix}`, }, { activity_type: 'PeelBanana', @@ -764,7 +799,7 @@ async function seed() { }) ]; }`, - name: `PeelBanana Expansion (${uniqueSuffix})`, + name: `PeelBanana Expansion ${seedNameSuffix}`, }, { activity_type: 'PickBanana', @@ -778,7 +813,7 @@ async function seed() { }) ]; }`, - name: `PickBanana Expansion (${uniqueSuffix})`, + name: `PickBanana Expansion ${seedNameSuffix}`, }, ]; @@ -797,7 +832,7 @@ async function seed() { } // Create expansion set bundling all rules - const expansionSetName = `Seed Expansion Set (${uniqueSuffix})`; + const expansionSetName = `Seed Expansion Set ${seedNameSuffix}`; const expansionSet = await api.createExpansionSet( parcel.id, model.id, @@ -809,14 +844,14 @@ async function seed() { // Create workspace using the parcel console.log('\nCreating workspace...'); - const workspaceName = `Seed Workspace (${uniqueSuffix})`; + const workspaceName = `Seed Workspace ${seedNameSuffix}`; const workspaceLocation = `seed_workspace_${uniqueSuffix}`; const workspaceId = await api.createWorkspace(workspaceLocation, parcel.id, workspaceName); console.log(` - Created workspace: ${workspaceName} (ID: ${workspaceId}, location: ${workspaceLocation})`); // Create action definition in the workspace console.log('\nCreating action...'); - const actionName = `Seed Action (${uniqueSuffix})`; + const actionName = `Seed Action ${seedNameSuffix}`; const actionDescription = 'Demo action that fetches data from GitHub API'; const action = await api.createActionDefinition( workspaceId, @@ -830,7 +865,7 @@ async function seed() { // Extensions receive POST with { planId, selectedActivityDirectiveId, simulationDatasetId, gateway, hasura } // and must return { success: boolean, message: string, url: string } console.log('\nCreating extension...'); - const extensionName = `Plan Analyzer (${uniqueSuffix})`; + const extensionName = `Plan Analyzer ${seedNameSuffix}`; const extensionDescription = 'Demo extension - analyzes plan data and opens results (requires local extension server)'; const extensionUrl = 'http://localhost:8000/analyze'; @@ -889,7 +924,7 @@ async function seed() { // Create second workspace with thousands of files for performance testing console.log('\nCreating large workspace...'); - const largeWorkspaceName = `Large Workspace (${uniqueSuffix})`; + const largeWorkspaceName = `Large Workspace ${seedNameSuffix}`; const largeWorkspaceLocation = `large_workspace_${uniqueSuffix}`; const largeWorkspaceId = await api.createWorkspace(largeWorkspaceLocation, parcel.id, largeWorkspaceName); console.log(` - Created workspace: ${largeWorkspaceName} (ID: ${largeWorkspaceId})`); @@ -1006,6 +1041,9 @@ async function seed() { console.log(` Action: ${actionName} (ID: ${action.id})`); console.log(` Extension: ${extensionName} (ID: ${extension.id})`); console.log('\nYou can now view these in the Aerie UI at http://localhost:3000'); + + const totalMs = performance.now() - seedStart; + console.log(`\nTotal seed time: ${fmtMs(totalMs)}`); } // Run the seed script